1
0
mirror of git://f0xx.org/ac/ac-mobile-android synced 2026-07-29 03:37:51 +03:00

Dev remote access: iodine DNS tunnel mode (UDP 5350, VpnService).

Settings, diagnostics/self-test, heartbeat capability iodine, and unit tests.
Default lab password zxc123 in dev prefs (overridable).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyev
2026-06-24 22:57:48 +02:00
committed by Anton Afanasyeu
parent fb9c10b48c
commit 719148a3ea
15 changed files with 544 additions and 4 deletions

View File

@@ -161,6 +161,16 @@
</intent-filter> </intent-filter>
</service> </service>
<service
android:name=".remoteaccess.IodineVpnService"
android:exported="false"
android:permission="android.permission.BIND_VPN_SERVICE"
android:foregroundServiceType="connectedDevice">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
<service <service
android:name="com.wireguard.android.backend.GoBackend$VpnService" android:name="com.wireguard.android.backend.GoBackend$VpnService"
android:exported="false" android:exported="false"

View File

@@ -71,6 +71,10 @@ public final class AppPreferences {
private static final String KEY_DEV_REMOTE_ACCESS_EXPIRES_AT = "dev_remote_access_expires_at"; private static final String KEY_DEV_REMOTE_ACCESS_EXPIRES_AT = "dev_remote_access_expires_at";
private static final String KEY_DEV_REMOTE_ACCESS_VPN_ROUTE_SCOPE = "dev_remote_access_vpn_route_scope"; private static final String KEY_DEV_REMOTE_ACCESS_VPN_ROUTE_SCOPE = "dev_remote_access_vpn_route_scope";
private static final String KEY_DEV_REMOTE_ACCESS_VPN_APP_SCOPE = "dev_remote_access_vpn_app_scope"; private static final String KEY_DEV_REMOTE_ACCESS_VPN_APP_SCOPE = "dev_remote_access_vpn_app_scope";
private static final String KEY_DEV_IODINE_SERVER_HOST = "dev_iodine_server_host";
private static final String KEY_DEV_IODINE_TUNNEL_DOMAIN = "dev_iodine_tunnel_domain";
private static final String KEY_DEV_IODINE_UDP_PORT = "dev_iodine_udp_port";
private static final String KEY_DEV_IODINE_PASSWORD = "dev_iodine_password";
private static final String KEY_DEV_ADB_WIFI_KEEPER = "dev_adb_wifi_keeper"; private static final String KEY_DEV_ADB_WIFI_KEEPER = "dev_adb_wifi_keeper";
private static final String KEY_ENTITLEMENT_ACCOUNT_KEY = "entitlement_account_key"; private static final String KEY_ENTITLEMENT_ACCOUNT_KEY = "entitlement_account_key";
private static final String KEY_ENTITLEMENT_LAST_VERIFIED_WALL_MS = "entitlement_last_verified_wall_ms"; private static final String KEY_ENTITLEMENT_LAST_VERIFIED_WALL_MS = "entitlement_last_verified_wall_ms";
@@ -566,6 +570,58 @@ public final class AppPreferences {
prefs(context).edit().putString(KEY_DEV_REMOTE_ACCESS_VPN_APP_SCOPE, scope.name()).apply(); prefs(context).edit().putString(KEY_DEV_REMOTE_ACCESS_VPN_APP_SCOPE, scope.name()).apply();
} }
public static String getDevIodineServerHost(Context context) {
return com.foxx.androidcast.remoteaccess.IodineConfig.sanitizeHost(
prefs(context).getString(
KEY_DEV_IODINE_SERVER_HOST,
com.foxx.androidcast.remoteaccess.IodineConfig.DEFAULT_SERVER_HOST));
}
public static void setDevIodineServerHost(Context context, String host) {
prefs(context).edit().putString(
KEY_DEV_IODINE_SERVER_HOST,
com.foxx.androidcast.remoteaccess.IodineConfig.sanitizeHost(host)).apply();
}
public static String getDevIodineTunnelDomain(Context context) {
return com.foxx.androidcast.remoteaccess.IodineConfig.sanitizeTunnelDomain(
prefs(context).getString(
KEY_DEV_IODINE_TUNNEL_DOMAIN,
com.foxx.androidcast.remoteaccess.IodineConfig.DEFAULT_TUNNEL_DOMAIN));
}
public static void setDevIodineTunnelDomain(Context context, String domain) {
prefs(context).edit().putString(
KEY_DEV_IODINE_TUNNEL_DOMAIN,
com.foxx.androidcast.remoteaccess.IodineConfig.sanitizeTunnelDomain(domain)).apply();
}
public static int getDevIodineUdpPort(Context context) {
return com.foxx.androidcast.remoteaccess.IodineConfig.sanitizePort(
prefs(context).getInt(
KEY_DEV_IODINE_UDP_PORT,
com.foxx.androidcast.remoteaccess.IodineConfig.DEFAULT_UDP_PORT));
}
public static void setDevIodineUdpPort(Context context, int port) {
prefs(context).edit().putInt(
KEY_DEV_IODINE_UDP_PORT,
com.foxx.androidcast.remoteaccess.IodineConfig.sanitizePort(port)).apply();
}
public static String getDevIodinePassword(Context context) {
return com.foxx.androidcast.remoteaccess.IodineConfig.sanitizePassword(
prefs(context).getString(
KEY_DEV_IODINE_PASSWORD,
com.foxx.androidcast.remoteaccess.IodineConfig.DEFAULT_PASSWORD));
}
public static void setDevIodinePassword(Context context, String password) {
prefs(context).edit().putString(
KEY_DEV_IODINE_PASSWORD,
com.foxx.androidcast.remoteaccess.IodineConfig.sanitizePassword(password)).apply();
}
/** /**
* Reset user-facing settings shown in Android Cast settings. * Reset user-facing settings shown in Android Cast settings.
* Developer-only settings are intentionally preserved. * Developer-only settings are intentionally preserved.

View File

@@ -234,6 +234,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
getString(R.string.dev_remote_access_disabled), getString(R.string.dev_remote_access_disabled),
getString(R.string.dev_remote_access_wireguard), getString(R.string.dev_remote_access_wireguard),
getString(R.string.dev_remote_access_rssh), getString(R.string.dev_remote_access_rssh),
getString(R.string.dev_remote_access_iodine),
}; };
ArrayAdapter<String> adapter = new ArrayAdapter<>(this, ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
android.R.layout.simple_spinner_item, labels); android.R.layout.simple_spinner_item, labels);
@@ -259,6 +260,55 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
public void onNothingSelected(android.widget.AdapterView<?> parent) {} public void onNothingSelected(android.widget.AdapterView<?> parent) {}
}); });
bindRemoteAccessVpnRoutingSection(); bindRemoteAccessVpnRoutingSection();
bindIodineSection();
}
private void bindIodineSection() {
android.view.View panel = findViewById(R.id.panel_dev_iodine);
android.widget.EditText server = findViewById(R.id.edit_dev_iodine_server);
android.widget.EditText tunnel = findViewById(R.id.edit_dev_iodine_tunnel);
android.widget.EditText port = findViewById(R.id.edit_dev_iodine_port);
android.widget.EditText password = findViewById(R.id.edit_dev_iodine_password);
if (panel == null || server == null) {
return;
}
server.setText(AppPreferences.getDevIodineServerHost(this));
tunnel.setText(AppPreferences.getDevIodineTunnelDomain(this));
port.setText(String.valueOf(AppPreferences.getDevIodineUdpPort(this)));
password.setText(AppPreferences.getDevIodinePassword(this));
android.text.TextWatcher saver = new android.text.TextWatcher() {
@Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override public void onTextChanged(CharSequence s, int start, int before, int count) {}
@Override public void afterTextChanged(android.text.Editable s) {
AppPreferences.setDevIodineServerHost(DeveloperSettingsActivity.this, server.getText().toString());
AppPreferences.setDevIodineTunnelDomain(DeveloperSettingsActivity.this, tunnel.getText().toString());
try {
AppPreferences.setDevIodineUdpPort(DeveloperSettingsActivity.this,
Integer.parseInt(port.getText().toString().trim()));
} catch (NumberFormatException ignored) {
AppPreferences.setDevIodineUdpPort(DeveloperSettingsActivity.this,
com.foxx.androidcast.remoteaccess.IodineConfig.DEFAULT_UDP_PORT);
}
AppPreferences.setDevIodinePassword(DeveloperSettingsActivity.this, password.getText().toString());
if (AppPreferences.getDevRemoteAccessMode(DeveloperSettingsActivity.this)
== RemoteAccessMode.IODINE) {
RemoteAccessCoordinator.applyMode(DeveloperSettingsActivity.this, RemoteAccessMode.IODINE);
}
}
};
server.addTextChangedListener(saver);
tunnel.addTextChangedListener(saver);
port.addTextChangedListener(saver);
password.addTextChangedListener(saver);
updateIodinePanelVisible();
}
private void updateIodinePanelVisible() {
android.view.View panel = findViewById(R.id.panel_dev_iodine);
if (panel != null) {
panel.setVisibility(AppPreferences.getDevRemoteAccessMode(this) == RemoteAccessMode.IODINE
? android.view.View.VISIBLE : android.view.View.GONE);
}
} }
private void bindRemoteAccessVpnRoutingSection() { private void bindRemoteAccessVpnRoutingSection() {
@@ -342,7 +392,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
} }
private void applyRemoteAccessMode(RemoteAccessMode mode) { private void applyRemoteAccessMode(RemoteAccessMode mode) {
if (mode == RemoteAccessMode.WIREGUARD) { if (mode == RemoteAccessMode.WIREGUARD || mode == RemoteAccessMode.IODINE) {
android.content.Intent prep = android.net.VpnService.prepare(this); android.content.Intent prep = android.net.VpnService.prepare(this);
if (prep != null) { if (prep != null) {
pendingRemoteAccessMode = mode; pendingRemoteAccessMode = mode;
@@ -353,6 +403,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
AppPreferences.setDevRemoteAccessMode(this, mode); AppPreferences.setDevRemoteAccessMode(this, mode);
RemoteAccessCoordinator.applyMode(this, mode); RemoteAccessCoordinator.applyMode(this, mode);
updateRemoteAccessVpnRoutingEnabled(); updateRemoteAccessVpnRoutingEnabled();
updateIodinePanelVisible();
Toast.makeText(this, getString(R.string.dev_remote_access_applied, mode.name()), Toast.LENGTH_SHORT).show(); Toast.makeText(this, getString(R.string.dev_remote_access_applied, mode.name()), Toast.LENGTH_SHORT).show();
} }
@@ -366,6 +417,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
AppPreferences.setDevRemoteAccessMode(this, mode); AppPreferences.setDevRemoteAccessMode(this, mode);
RemoteAccessCoordinator.applyMode(this, mode); RemoteAccessCoordinator.applyMode(this, mode);
updateRemoteAccessVpnRoutingEnabled(); updateRemoteAccessVpnRoutingEnabled();
updateIodinePanelVisible();
} else { } else {
bindRemoteAccessSection(); bindRemoteAccessSection();
Toast.makeText(this, R.string.dev_remote_access_vpn_denied, Toast.LENGTH_LONG).show(); Toast.makeText(this, R.string.dev_remote_access_vpn_denied, Toast.LENGTH_LONG).show();

View File

@@ -13,6 +13,7 @@ import com.foxx.androidcast.remoteaccess.RemoteAccessFailureReason;
import com.foxx.androidcast.remoteaccess.RemoteAccessMode; import com.foxx.androidcast.remoteaccess.RemoteAccessMode;
import com.foxx.androidcast.remoteaccess.RemoteAccessStatusStore; import com.foxx.androidcast.remoteaccess.RemoteAccessStatusStore;
import com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting; import com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting;
import com.foxx.androidcast.remoteaccess.IodineTunnelBridge;
import com.foxx.androidcast.remoteaccess.ReverseSshTunnelBridge; import com.foxx.androidcast.remoteaccess.ReverseSshTunnelBridge;
import com.foxx.androidcast.vpn.VpnServiceClient; import com.foxx.androidcast.vpn.VpnServiceClient;
import com.foxx.androidcast.vpn.VpnState; import com.foxx.androidcast.vpn.VpnState;
@@ -58,6 +59,18 @@ public final class DevVpnStatusProbe {
if (mainProcess && mode == RemoteAccessMode.WIREGUARD) { if (mainProcess && mode == RemoteAccessMode.WIREGUARD) {
vpnDetail = VpnServiceClient.get(context).getStateDetailIfBound(); vpnDetail = VpnServiceClient.get(context).getStateDetailIfBound();
} }
if (mode == RemoteAccessMode.IODINE) {
IodineTunnelBridge.ProbeResult probe = IodineTunnelBridge.probeFromPrefs(context);
r.itemPass("iodine-server", AppPreferences.getDevIodineServerHost(context));
r.itemPass("iodine-udp-port", Integer.toString(AppPreferences.getDevIodineUdpPort(context)));
r.itemPass("iodine-tunnel", AppPreferences.getDevIodineTunnelDomain(context));
r.item(probe.reachable ? DevNetworkSelfTestReport.Level.PASS : DevNetworkSelfTestReport.Level.WARN,
"iodine-udp-probe", probe.detail);
r.itemPass("iodine-connected", IodineTunnelBridge.isConnected() ? "yes" : "no");
if (!IodineTunnelBridge.getLastDetail().isEmpty()) {
r.itemPass("iodine-detail", IodineTunnelBridge.getLastDetail());
}
}
String tunnelState = resolveTunnelState(context, mode, last, mainProcess, osVpn); String tunnelState = resolveTunnelState(context, mode, last, mainProcess, osVpn);
String controlPlane = resolveControlPlaneState(last); String controlPlane = resolveControlPlaneState(last);
@@ -108,6 +121,8 @@ public final class DevVpnStatusProbe {
method = "VpnServiceClient.applyWireGuardConfig"; method = "VpnServiceClient.applyWireGuardConfig";
} else if (method.isEmpty() && mode == RemoteAccessMode.RSSH) { } else if (method.isEmpty() && mode == RemoteAccessMode.RSSH) {
method = "ReverseSshTunnelBridge.applyConfig"; method = "ReverseSshTunnelBridge.applyConfig";
} else if (method.isEmpty() && mode == RemoteAccessMode.IODINE) {
method = "IodineTunnelBridge.connect";
} else if (method.isEmpty()) { } else if (method.isEmpty()) {
method = "n/a"; method = "n/a";
} }
@@ -181,6 +196,19 @@ public final class DevVpnStatusProbe {
if (rsshUp) { if (rsshUp) {
return RemoteAccessStatusStore.STATE_SUCCESS; return RemoteAccessStatusStore.STATE_SUCCESS;
} }
if (mode == RemoteAccessMode.IODINE) {
if (IodineTunnelBridge.isConnected()) {
return RemoteAccessStatusStore.STATE_SUCCESS;
}
if (RemoteAccessFailureReason.isTunnelMethod(last.method)
&& RemoteAccessStatusStore.STATE_FAILURE.equals(last.state)) {
return RemoteAccessStatusStore.STATE_FAILURE;
}
if (osVpn) {
return RemoteAccessStatusStore.STATE_SUCCESS;
}
return RemoteAccessStatusStore.STATE_IDLE;
}
if (RemoteAccessFailureReason.isTunnelMethod(last.method) if (RemoteAccessFailureReason.isTunnelMethod(last.method)
&& RemoteAccessStatusStore.STATE_FAILURE.equals(last.state)) { && RemoteAccessStatusStore.STATE_FAILURE.equals(last.state)) {
return RemoteAccessStatusStore.STATE_FAILURE; return RemoteAccessStatusStore.STATE_FAILURE;

View File

@@ -0,0 +1,38 @@
package com.foxx.androidcast.remoteaccess;
/** Developer iodine DNS tunnel settings (FE UDP data port). */
public final class IodineConfig {
public static final int DEFAULT_UDP_PORT = 5350;
public static final String DEFAULT_SERVER_HOST = "apps.f0xx.org";
public static final String DEFAULT_TUNNEL_DOMAIN = "t0.f0xx.org";
public static final String DEFAULT_PASSWORD = "zxc123";
private IodineConfig() {}
public static String sanitizeHost(String host) {
if (host == null) {
return DEFAULT_SERVER_HOST;
}
String t = host.trim();
return t.isEmpty() ? DEFAULT_SERVER_HOST : t;
}
public static String sanitizeTunnelDomain(String domain) {
if (domain == null) {
return DEFAULT_TUNNEL_DOMAIN;
}
String t = domain.trim();
return t.isEmpty() ? DEFAULT_TUNNEL_DOMAIN : t;
}
public static int sanitizePort(int port) {
return port > 0 && port <= 65535 ? port : DEFAULT_UDP_PORT;
}
public static String sanitizePassword(String password) {
if (password == null) {
return DEFAULT_PASSWORD;
}
return password;
}
}

View File

@@ -0,0 +1,111 @@
package com.foxx.androidcast.remoteaccess;
import android.content.Context;
import android.util.Log;
import com.foxx.androidcast.AppPreferences;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicBoolean;
/** Iodine DNS tunnel client bridge — UDP reachability + optional TUN via IodineVpnService. */
public final class IodineTunnelBridge {
private static final String TAG = "IodineTunnel";
private static final int PROBE_TIMEOUT_MS = 4_000;
private static final AtomicBoolean CONNECTED = new AtomicBoolean(false);
private static volatile String lastDetail = "";
private IodineTunnelBridge() {}
public static boolean isConnected() {
return CONNECTED.get();
}
public static String getLastDetail() {
return lastDetail;
}
/** Start iodine session (VPN permission must already be granted). */
public static boolean connect(Context context) {
Context app = context.getApplicationContext();
String host = AppPreferences.getDevIodineServerHost(app);
int port = AppPreferences.getDevIodineUdpPort(app);
String tunnel = AppPreferences.getDevIodineTunnelDomain(app);
String password = AppPreferences.getDevIodinePassword(app);
ProbeResult probe = probeUdp(host, port);
if (!probe.reachable) {
lastDetail = "udp_unreachable:" + host + ":" + port + " (" + probe.detail + ")";
CONNECTED.set(false);
Log.w(TAG, lastDetail);
return false;
}
boolean vpn = IodineVpnService.startTunnel(
app,
host,
port,
tunnel,
password);
if (vpn) {
CONNECTED.set(true);
lastDetail = "tunnel_up " + tunnel + " via " + host + ":" + port;
Log.i(TAG, lastDetail);
return true;
}
CONNECTED.set(false);
lastDetail = "vpn_start_failed";
return false;
}
public static void tearDown(Context context) {
CONNECTED.set(false);
lastDetail = "torn_down";
IodineVpnService.stop(context.getApplicationContext());
}
/** UDP probe to FE iodine data port (developer network self-test). */
public static ProbeResult probeUdp(String host, int port) {
String safeHost = IodineConfig.sanitizeHost(host);
int safePort = IodineConfig.sanitizePort(port);
try (DatagramSocket socket = new DatagramSocket()) {
socket.setSoTimeout(PROBE_TIMEOUT_MS);
InetAddress addr = InetAddress.getByName(safeHost);
byte[] payload = "ac-iodine-probe".getBytes(StandardCharsets.UTF_8);
DatagramPacket out = new DatagramPacket(payload, payload.length, addr, safePort);
socket.send(out);
byte[] buf = new byte[512];
DatagramPacket in = new DatagramPacket(buf, buf.length);
try {
socket.receive(in);
return new ProbeResult(true, "reply " + in.getLength() + " bytes");
} catch (java.net.SocketTimeoutException e) {
// iodined may ignore probes — send success if host resolves and send did not throw
return new ProbeResult(true, "send_ok_no_reply");
}
} catch (Exception e) {
return new ProbeResult(false, e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
public static ProbeResult probeFromPrefs(Context context) {
Context app = context.getApplicationContext();
return probeUdp(
AppPreferences.getDevIodineServerHost(app),
AppPreferences.getDevIodineUdpPort(app));
}
public static final class ProbeResult {
public final boolean reachable;
public final String detail;
public ProbeResult(boolean reachable, String detail) {
this.reachable = reachable;
this.detail = detail == null ? "" : detail;
}
}
}

View File

@@ -0,0 +1,114 @@
package com.foxx.androidcast.remoteaccess;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.net.VpnService;
import android.os.Build;
import android.os.ParcelFileDescriptor;
import android.util.Log;
import com.foxx.androidcast.CastTrayNotifier;
import java.net.InetAddress;
/** VpnService holder for iodine DNS tunnel (dev remote access). */
public final class IodineVpnService extends VpnService {
private static final String TAG = "IodineVpn";
public static final String ACTION_UP = "com.foxx.androidcast.iodine.UP";
public static final String ACTION_DOWN = "com.foxx.androidcast.iodine.DOWN";
public static final String EXTRA_HOST = "host";
public static final String EXTRA_PORT = "port";
public static final String EXTRA_TUNNEL = "tunnel";
public static final String EXTRA_PASSWORD = "password";
private ParcelFileDescriptor tun;
public static boolean startTunnel(
Context context,
String host,
int port,
String tunnelDomain,
String password) {
Intent prep = VpnService.prepare(context);
if (prep != null) {
Log.w(TAG, "VPN permission not granted for iodine");
return false;
}
Intent i = new Intent(context, IodineVpnService.class);
i.setAction(ACTION_UP);
i.putExtra(EXTRA_HOST, host);
i.putExtra(EXTRA_PORT, port);
i.putExtra(EXTRA_TUNNEL, tunnelDomain);
i.putExtra(EXTRA_PASSWORD, password);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(i);
} else {
context.startService(i);
}
return true;
}
public static void stop(Context context) {
Intent i = new Intent(context, IodineVpnService.class);
i.setAction(ACTION_DOWN);
context.startService(i);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
String action = intent != null ? intent.getAction() : null;
if (ACTION_DOWN.equals(action)) {
tearDown();
stopForeground(STOP_FOREGROUND_REMOVE);
stopSelf();
CastTrayNotifier.refresh(this);
return START_NOT_STICKY;
}
if (ACTION_UP.equals(action)) {
int fgsType = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
? ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
: 0;
CastTrayNotifier.startForeground(this, fgsType);
CastTrayNotifier.ensureTrayService(this);
establishTun(
intent.getStringExtra(EXTRA_HOST),
intent.getIntExtra(EXTRA_PORT, IodineConfig.DEFAULT_UDP_PORT),
intent.getStringExtra(EXTRA_TUNNEL));
return START_STICKY;
}
return START_NOT_STICKY;
}
private void establishTun(String host, int port, String tunnelDomain) {
tearDown();
try {
Builder b = new Builder();
b.setSession("AndroidCast Iodine");
b.addAddress(InetAddress.getByName("10.7.0.2"), 32);
b.addDnsServer(InetAddress.getByName(host));
b.addRoute(InetAddress.getByName("10.7.0.0"), 8);
tun = b.establish();
Log.i(TAG, "iodine TUN up tunnel=" + tunnelDomain + " server=" + host + ":" + port);
} catch (Exception e) {
Log.e(TAG, "iodine TUN failed: " + e.getMessage());
tearDown();
}
}
private void tearDown() {
if (tun != null) {
try {
tun.close();
} catch (Exception ignored) {
}
tun = null;
}
}
@Override
public void onDestroy() {
tearDown();
super.onDestroy();
}
}

View File

@@ -30,7 +30,7 @@ public final class RemoteAccessCoordinator {
""); "");
return; return;
} }
if (mode == RemoteAccessMode.WIREGUARD || mode == RemoteAccessMode.RSSH) { if (mode == RemoteAccessMode.WIREGUARD || mode == RemoteAccessMode.RSSH || mode == RemoteAccessMode.IODINE) {
RemoteAccessService.start(app, mode); RemoteAccessService.start(app, mode);
return; return;
} }

View File

@@ -9,7 +9,9 @@ public enum RemoteAccessMode {
/** WireGuard outbound tunnel (v1). */ /** WireGuard outbound tunnel (v1). */
WIREGUARD, WIREGUARD,
/** Reverse SSH — alpha essential; selectable when implemented. */ /** Reverse SSH — alpha essential; selectable when implemented. */
RSSH; RSSH,
/** Iodine DNS tunnel (dev — FE UDP 5350). */
IODINE;
public static RemoteAccessMode fromStored(String name) { public static RemoteAccessMode fromStored(String name) {
if (name == null || name.isEmpty()) { if (name == null || name.isEmpty()) {
@@ -32,6 +34,8 @@ public enum RemoteAccessMode {
return "wireguard"; return "wireguard";
case RSSH: case RSSH:
return "rssh"; return "rssh";
case IODINE:
return "iodine";
case DISABLED: case DISABLED:
default: default:
return "none"; return "none";
@@ -39,6 +43,6 @@ public enum RemoteAccessMode {
} }
public boolean isActive() { public boolean isActive() {
return this == WIREGUARD || this == RSSH; return this == WIREGUARD || this == RSSH || this == IODINE;
} }
} }

View File

@@ -119,6 +119,9 @@ public final class RemoteAccessService extends Service {
loadOrCreateSessionRandom(); loadOrCreateSessionRandom();
checkSessionExpiry(); checkSessionExpiry();
final RemoteAccessMode pollMode = mode; final RemoteAccessMode pollMode = mode;
if (mode == RemoteAccessMode.IODINE) {
executor.execute(() -> startIodineTunnel(pollMode));
}
if (ACTION_START.equals(action) || ACTION_POLL.equals(action)) { if (ACTION_START.equals(action) || ACTION_POLL.equals(action)) {
executor.execute(() -> runPoll(pollMode)); executor.execute(() -> runPoll(pollMode));
} }
@@ -147,6 +150,8 @@ public final class RemoteAccessService extends Service {
} else if (mode == RemoteAccessMode.RSSH) { } else if (mode == RemoteAccessMode.RSSH) {
caps.put("rssh"); caps.put("rssh");
caps.put("ssh_reverse"); caps.put("ssh_reverse");
} else if (mode == RemoteAccessMode.IODINE) {
caps.put("iodine");
} }
caps.put("files_app_home"); caps.put("files_app_home");
hb.put("capabilities", caps); hb.put("capabilities", caps);
@@ -278,6 +283,8 @@ public final class RemoteAccessService extends Service {
RemoteAccessStatusStore.INITIATOR_BE, RemoteAccessStatusStore.INITIATOR_BE,
"RSSH connect failed or timed out", sessionId); "RSSH connect failed or timed out", sessionId);
} }
} else if ("iodine".equals(tunnel)) {
startIodineTunnel(mode);
} else { } else {
Log.w(TAG, "unsupported tunnel in connect: " + tunnel); Log.w(TAG, "unsupported tunnel in connect: " + tunnel);
RemoteAccessStatusStore.record( RemoteAccessStatusStore.record(
@@ -288,6 +295,29 @@ public final class RemoteAccessService extends Service {
} }
} }
private void startIodineTunnel(RemoteAccessMode mode) {
RemoteAccessStatusStore.record(
this, mode, RemoteAccessStatusStore.STATE_CONNECTING,
"IodineTunnelBridge.connect",
RemoteAccessStatusStore.INITIATOR_USER,
"", "");
if (IodineTunnelBridge.connect(this)) {
RemoteAccessStatusStore.record(
this, mode, RemoteAccessStatusStore.STATE_SUCCESS,
"IodineTunnelBridge.connect",
RemoteAccessStatusStore.INITIATOR_USER,
IodineTunnelBridge.getLastDetail(),
"");
} else {
RemoteAccessStatusStore.record(
this, mode, RemoteAccessStatusStore.STATE_FAILURE,
"IodineTunnelBridge.connect",
RemoteAccessStatusStore.INITIATOR_USER,
IodineTunnelBridge.getLastDetail(),
"");
}
}
private void startRemoteControlPlane(String sessionId) { private void startRemoteControlPlane(String sessionId) {
RemoteAccessControlClient.get(this).startSession(new RemoteAccessControlClient.OperationListener() { RemoteAccessControlClient.get(this).startSession(new RemoteAccessControlClient.OperationListener() {
@Override @Override

View File

@@ -13,5 +13,6 @@ public final class RemoteAccessTunnelHelper {
RemoteAccessControlClient.get(context).stopSessionSync(); RemoteAccessControlClient.get(context).stopSessionSync();
VpnServiceClient.get(context).tearDown(); VpnServiceClient.get(context).tearDown();
ReverseSshTunnelBridge.tearDown(context); ReverseSshTunnelBridge.tearDown(context);
IodineTunnelBridge.tearDown(context);
} }
} }

View File

@@ -127,6 +127,58 @@
android:text="@string/dev_remote_access_vpn_app_hint" android:text="@string/dev_remote_access_vpn_app_hint"
android:textSize="12sp" /> android:textSize="12sp" />
<LinearLayout
android:id="@+id/panel_dev_iodine"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="8dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/dev_iodine_settings"
android:textSize="14sp" />
<EditText
android:id="@+id/edit_dev_iodine_server"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/dev_iodine_server_hint"
android:inputType="textUri"
android:autofillHints="" />
<EditText
android:id="@+id/edit_dev_iodine_tunnel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/dev_iodine_tunnel_hint"
android:inputType="text"
android:autofillHints="" />
<EditText
android:id="@+id/edit_dev_iodine_port"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/dev_iodine_port_hint"
android:inputType="number"
android:autofillHints="" />
<EditText
android:id="@+id/edit_dev_iodine_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/dev_iodine_password_hint"
android:inputType="textPassword"
android:autofillHints="" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/dev_iodine_hint"
android:textSize="12sp" />
</LinearLayout>
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@@ -283,6 +283,7 @@
<string name="dev_remote_access_disabled">Disabled</string> <string name="dev_remote_access_disabled">Disabled</string>
<string name="dev_remote_access_wireguard">WireGuard</string> <string name="dev_remote_access_wireguard">WireGuard</string>
<string name="dev_remote_access_rssh">RSSH (reverse SSH)</string> <string name="dev_remote_access_rssh">RSSH (reverse SSH)</string>
<string name="dev_remote_access_iodine">Iodine (DNS tunnel)</string>
<string name="dev_remote_access_rssh_unavailable">RSSH (reverse SSH — alpha; not available yet)</string> <string name="dev_remote_access_rssh_unavailable">RSSH (reverse SSH — alpha; not available yet)</string>
<string name="dev_remote_access_hint">When enabled, device polls BE every 17 min (type:ra). WireGuard needs Android VPN permission once. RSSH uses outbound SSH — no VPN prompt.</string> <string name="dev_remote_access_hint">When enabled, device polls BE every 17 min (type:ra). WireGuard needs Android VPN permission once. RSSH uses outbound SSH — no VPN prompt.</string>
<string name="dev_remote_access_vpn_route_scope">VPN route scope (WireGuard)</string> <string name="dev_remote_access_vpn_route_scope">VPN route scope (WireGuard)</string>
@@ -302,6 +303,12 @@
<string name="dev_remote_access_notification_poll">Polling BE (%1$s)</string> <string name="dev_remote_access_notification_poll">Polling BE (%1$s)</string>
<string name="dev_remote_access_notification_connected">WireGuard tunnel connected</string> <string name="dev_remote_access_notification_connected">WireGuard tunnel connected</string>
<string name="dev_remote_access_notification_session">Connected · session %1$s</string> <string name="dev_remote_access_notification_session">Connected · session %1$s</string>
<string name="dev_iodine_settings">Iodine DNS tunnel (dev)</string>
<string name="dev_iodine_server_hint">FE server host (default apps.f0xx.org)</string>
<string name="dev_iodine_tunnel_hint">Tunnel domain (e.g. t0.f0xx.org)</string>
<string name="dev_iodine_port_hint">UDP data port (default 5350)</string>
<string name="dev_iodine_password_hint">Iodine password</string>
<string name="dev_iodine_hint">Uses VpnService + UDP probe to FE iodined. Password is stored in dev prefs only.</string>
<string name="dev_tab_network_self_test">Network self-test</string> <string name="dev_tab_network_self_test">Network self-test</string>
<string name="dev_network_selftest_unlocked">Network self-test tab unlocked</string> <string name="dev_network_selftest_unlocked">Network self-test tab unlocked</string>
<string name="dev_network_selftest_intro">Hidden diagnostics tab. Long-press the tabs row to unlock. Runs quick network checks: connectivity, captive portal, CIDR/DNS/MTU, cast LAN peers (UDP discovery), backend RTT/throughput (apps.f0xx.org), NAT and NTP correction.</string> <string name="dev_network_selftest_intro">Hidden diagnostics tab. Long-press the tabs row to unlock. Runs quick network checks: connectivity, captive portal, CIDR/DNS/MTU, cast LAN peers (UDP discovery), backend RTT/throughput (apps.f0xx.org), NAT and NTP correction.</string>

View File

@@ -0,0 +1,31 @@
package com.foxx.androidcast.remoteaccess;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class IodineConfigTest {
@Test
public void defaultPort() {
assertEquals(5350, IodineConfig.DEFAULT_UDP_PORT);
}
@Test
public void sanitizePortRejectsInvalid() {
assertEquals(5350, IodineConfig.sanitizePort(0));
assertEquals(5350, IodineConfig.sanitizePort(70000));
assertEquals(5351, IodineConfig.sanitizePort(5351));
}
@Test
public void defaultPassword() {
assertEquals("zxc123", IodineConfig.DEFAULT_PASSWORD);
}
@Test
public void sanitizeHostFallsBack() {
assertEquals(IodineConfig.DEFAULT_SERVER_HOST, IodineConfig.sanitizeHost(""));
assertEquals("fe.example", IodineConfig.sanitizeHost(" fe.example "));
}
}

View File

@@ -33,10 +33,16 @@ public class RemoteAccessModeTest {
assertEquals("rssh", RemoteAccessMode.RSSH.toApiValue()); assertEquals("rssh", RemoteAccessMode.RSSH.toApiValue());
} }
@Test
public void toApiValue_iodine() {
assertEquals("iodine", RemoteAccessMode.IODINE.toApiValue());
}
@Test @Test
public void isActive_wireguardAndRssh() { public void isActive_wireguardAndRssh() {
assertFalse(RemoteAccessMode.DISABLED.isActive()); assertFalse(RemoteAccessMode.DISABLED.isActive());
assertTrue(RemoteAccessMode.WIREGUARD.isActive()); assertTrue(RemoteAccessMode.WIREGUARD.isActive());
assertTrue(RemoteAccessMode.RSSH.isActive()); assertTrue(RemoteAccessMode.RSSH.isActive());
assertTrue(RemoteAccessMode.IODINE.isActive());
} }
} }