mirror of
git://f0xx.org/ac/ac-mobile-android
synced 2026-07-29 01:47:35 +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:
committed by
Anton Afanasyeu
parent
fb9c10b48c
commit
719148a3ea
@@ -161,6 +161,16 @@
|
||||
</intent-filter>
|
||||
</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
|
||||
android:name="com.wireguard.android.backend.GoBackend$VpnService"
|
||||
android:exported="false"
|
||||
|
||||
@@ -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_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_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_ENTITLEMENT_ACCOUNT_KEY = "entitlement_account_key";
|
||||
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();
|
||||
}
|
||||
|
||||
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.
|
||||
* Developer-only settings are intentionally preserved.
|
||||
|
||||
@@ -234,6 +234,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
getString(R.string.dev_remote_access_disabled),
|
||||
getString(R.string.dev_remote_access_wireguard),
|
||||
getString(R.string.dev_remote_access_rssh),
|
||||
getString(R.string.dev_remote_access_iodine),
|
||||
};
|
||||
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
|
||||
android.R.layout.simple_spinner_item, labels);
|
||||
@@ -259,6 +260,55 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
public void onNothingSelected(android.widget.AdapterView<?> parent) {}
|
||||
});
|
||||
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() {
|
||||
@@ -342,7 +392,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
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);
|
||||
if (prep != null) {
|
||||
pendingRemoteAccessMode = mode;
|
||||
@@ -353,6 +403,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
AppPreferences.setDevRemoteAccessMode(this, mode);
|
||||
RemoteAccessCoordinator.applyMode(this, mode);
|
||||
updateRemoteAccessVpnRoutingEnabled();
|
||||
updateIodinePanelVisible();
|
||||
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);
|
||||
RemoteAccessCoordinator.applyMode(this, mode);
|
||||
updateRemoteAccessVpnRoutingEnabled();
|
||||
updateIodinePanelVisible();
|
||||
} else {
|
||||
bindRemoteAccessSection();
|
||||
Toast.makeText(this, R.string.dev_remote_access_vpn_denied, Toast.LENGTH_LONG).show();
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.foxx.androidcast.remoteaccess.RemoteAccessFailureReason;
|
||||
import com.foxx.androidcast.remoteaccess.RemoteAccessMode;
|
||||
import com.foxx.androidcast.remoteaccess.RemoteAccessStatusStore;
|
||||
import com.foxx.androidcast.remoteaccess.RemoteAccessVpnRouting;
|
||||
import com.foxx.androidcast.remoteaccess.IodineTunnelBridge;
|
||||
import com.foxx.androidcast.remoteaccess.ReverseSshTunnelBridge;
|
||||
import com.foxx.androidcast.vpn.VpnServiceClient;
|
||||
import com.foxx.androidcast.vpn.VpnState;
|
||||
@@ -58,6 +59,18 @@ public final class DevVpnStatusProbe {
|
||||
if (mainProcess && mode == RemoteAccessMode.WIREGUARD) {
|
||||
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 controlPlane = resolveControlPlaneState(last);
|
||||
@@ -108,6 +121,8 @@ public final class DevVpnStatusProbe {
|
||||
method = "VpnServiceClient.applyWireGuardConfig";
|
||||
} else if (method.isEmpty() && mode == RemoteAccessMode.RSSH) {
|
||||
method = "ReverseSshTunnelBridge.applyConfig";
|
||||
} else if (method.isEmpty() && mode == RemoteAccessMode.IODINE) {
|
||||
method = "IodineTunnelBridge.connect";
|
||||
} else if (method.isEmpty()) {
|
||||
method = "n/a";
|
||||
}
|
||||
@@ -181,6 +196,19 @@ public final class DevVpnStatusProbe {
|
||||
if (rsshUp) {
|
||||
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)
|
||||
&& RemoteAccessStatusStore.STATE_FAILURE.equals(last.state)) {
|
||||
return RemoteAccessStatusStore.STATE_FAILURE;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ public final class RemoteAccessCoordinator {
|
||||
"");
|
||||
return;
|
||||
}
|
||||
if (mode == RemoteAccessMode.WIREGUARD || mode == RemoteAccessMode.RSSH) {
|
||||
if (mode == RemoteAccessMode.WIREGUARD || mode == RemoteAccessMode.RSSH || mode == RemoteAccessMode.IODINE) {
|
||||
RemoteAccessService.start(app, mode);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ public enum RemoteAccessMode {
|
||||
/** WireGuard outbound tunnel (v1). */
|
||||
WIREGUARD,
|
||||
/** Reverse SSH — alpha essential; selectable when implemented. */
|
||||
RSSH;
|
||||
RSSH,
|
||||
/** Iodine DNS tunnel (dev — FE UDP 5350). */
|
||||
IODINE;
|
||||
|
||||
public static RemoteAccessMode fromStored(String name) {
|
||||
if (name == null || name.isEmpty()) {
|
||||
@@ -32,6 +34,8 @@ public enum RemoteAccessMode {
|
||||
return "wireguard";
|
||||
case RSSH:
|
||||
return "rssh";
|
||||
case IODINE:
|
||||
return "iodine";
|
||||
case DISABLED:
|
||||
default:
|
||||
return "none";
|
||||
@@ -39,6 +43,6 @@ public enum RemoteAccessMode {
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return this == WIREGUARD || this == RSSH;
|
||||
return this == WIREGUARD || this == RSSH || this == IODINE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,9 @@ public final class RemoteAccessService extends Service {
|
||||
loadOrCreateSessionRandom();
|
||||
checkSessionExpiry();
|
||||
final RemoteAccessMode pollMode = mode;
|
||||
if (mode == RemoteAccessMode.IODINE) {
|
||||
executor.execute(() -> startIodineTunnel(pollMode));
|
||||
}
|
||||
if (ACTION_START.equals(action) || ACTION_POLL.equals(action)) {
|
||||
executor.execute(() -> runPoll(pollMode));
|
||||
}
|
||||
@@ -147,6 +150,8 @@ public final class RemoteAccessService extends Service {
|
||||
} else if (mode == RemoteAccessMode.RSSH) {
|
||||
caps.put("rssh");
|
||||
caps.put("ssh_reverse");
|
||||
} else if (mode == RemoteAccessMode.IODINE) {
|
||||
caps.put("iodine");
|
||||
}
|
||||
caps.put("files_app_home");
|
||||
hb.put("capabilities", caps);
|
||||
@@ -278,6 +283,8 @@ public final class RemoteAccessService extends Service {
|
||||
RemoteAccessStatusStore.INITIATOR_BE,
|
||||
"RSSH connect failed or timed out", sessionId);
|
||||
}
|
||||
} else if ("iodine".equals(tunnel)) {
|
||||
startIodineTunnel(mode);
|
||||
} else {
|
||||
Log.w(TAG, "unsupported tunnel in connect: " + tunnel);
|
||||
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) {
|
||||
RemoteAccessControlClient.get(this).startSession(new RemoteAccessControlClient.OperationListener() {
|
||||
@Override
|
||||
|
||||
@@ -13,5 +13,6 @@ public final class RemoteAccessTunnelHelper {
|
||||
RemoteAccessControlClient.get(context).stopSessionSync();
|
||||
VpnServiceClient.get(context).tearDown();
|
||||
ReverseSshTunnelBridge.tearDown(context);
|
||||
IodineTunnelBridge.tearDown(context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,58 @@
|
||||
android:text="@string/dev_remote_access_vpn_app_hint"
|
||||
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
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
||||
@@ -283,6 +283,7 @@
|
||||
<string name="dev_remote_access_disabled">Disabled</string>
|
||||
<string name="dev_remote_access_wireguard">WireGuard</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_hint">When enabled, device polls BE every 1–7 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>
|
||||
@@ -302,6 +303,12 @@
|
||||
<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_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_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>
|
||||
|
||||
@@ -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 "));
|
||||
}
|
||||
}
|
||||
@@ -33,10 +33,16 @@ public class RemoteAccessModeTest {
|
||||
assertEquals("rssh", RemoteAccessMode.RSSH.toApiValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toApiValue_iodine() {
|
||||
assertEquals("iodine", RemoteAccessMode.IODINE.toApiValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isActive_wireguardAndRssh() {
|
||||
assertFalse(RemoteAccessMode.DISABLED.isActive());
|
||||
assertTrue(RemoteAccessMode.WIREGUARD.isActive());
|
||||
assertTrue(RemoteAccessMode.RSSH.isActive());
|
||||
assertTrue(RemoteAccessMode.IODINE.isActive());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user