diff --git a/app/build.gradle b/app/build.gradle
index 8b390e4..b9ccf1d 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -119,6 +119,7 @@ android {
excludes += '/META-INF/LICENSE.txt'
excludes += '/META-INF/NOTICE'
excludes += '/META-INF/NOTICE.txt'
+ excludes += '/META-INF/versions/**'
}
}
@@ -146,6 +147,8 @@ dependencies {
implementation 'com.github.mwiede:jsch:0.2.21'
implementation 'org.apache.sshd:sshd-core:2.14.0'
+ implementation 'org.apache.sshd:sshd-sftp:2.14.0'
+ implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1'
implementation 'org.slf4j:slf4j-android:1.7.36'
implementation project(':tunnel')
diff --git a/app/src/main/assets/licenses/combined.html b/app/src/main/assets/licenses/combined.html
index 785c741..a09de93 100644
--- a/app/src/main/assets/licenses/combined.html
+++ b/app/src/main/assets/licenses/combined.html
@@ -19,7 +19,8 @@
Google Play In-App Review — Google Play SDK terms
Eclipse Paho MQTT client — Eclipse Public License 1.0 and Eclipse Distribution License 1.0 (dual-licensed)
JSch (mwiede fork) — BSD 3-Clause License (reverse SSH remote access)
-Apache MINA SSHD — Apache License 2.0 (embedded local SSH on device for RSSH alpha)
+Apache MINA SSHD — Apache License 2.0 (embedded local SSH/SFTP for RSSH alpha)
+Bouncy Castle — MIT-style license (SSHD crypto on Android)
JUnit 4
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/ReverseSshTunnelBridge.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/ReverseSshTunnelBridge.java
index 48572b2..c4bb929 100644
--- a/app/src/main/java/com/foxx/androidcast/remoteaccess/ReverseSshTunnelBridge.java
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/ReverseSshTunnelBridge.java
@@ -42,7 +42,7 @@ public final class ReverseSshTunnelBridge {
Log.w(TAG, "incomplete RSSH credentials");
return false;
}
- if (!RsshLocalSshServer.start(user, pass, localPort)) {
+ if (!RsshLocalSshServer.start(context, user, pass, localPort)) {
Log.w(TAG, "local SSH on :" + localPort + " failed");
return false;
}
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RsshLocalSshServer.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RsshLocalSshServer.java
index 3cc3ce5..e3dd32a 100644
--- a/app/src/main/java/com/foxx/androidcast/remoteaccess/RsshLocalSshServer.java
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RsshLocalSshServer.java
@@ -1,18 +1,19 @@
package com.foxx.androidcast.remoteaccess;
+import android.content.Context;
import android.util.Log;
import org.apache.sshd.server.SshServer;
import org.apache.sshd.server.auth.password.PasswordAuthenticator;
import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
import org.apache.sshd.server.shell.ProcessShellFactory;
+import org.apache.sshd.sftp.server.SftpSubsystemFactory;
-import java.nio.file.Files;
-import java.nio.file.Path;
+import java.util.Collections;
import java.util.concurrent.atomic.AtomicBoolean;
/**
- * Embedded SSH on {@code 127.0.0.1:8022} for RSSH reverse forward target.
+ * Embedded SSH/SFTP on {@code 127.0.0.1:8022} for RSSH reverse forward target.
* Operator reaches this via bastion forwarded port (same username/password).
*/
public final class RsshLocalSshServer {
@@ -24,9 +25,10 @@ public final class RsshLocalSshServer {
private RsshLocalSshServer() {}
- public static boolean start(String username, String password, int port) {
+ public static boolean start(Context context, String username, String password, int port) {
stop();
- if (username == null || username.isEmpty() || password == null || password.isEmpty()) {
+ if (context == null || username == null || username.isEmpty()
+ || password == null || password.isEmpty()) {
return false;
}
if (port <= 0 || port > 65535) {
@@ -36,8 +38,8 @@ public final class RsshLocalSshServer {
final String pass = password;
final int listenPort = port;
try {
- Path hostKey = Files.createTempFile("rssh_host", ".key");
- hostKey.toFile().deleteOnExit();
+ RsshSecuritySetup.ensureReady(context);
+ java.nio.file.Path hostKey = context.getCacheDir().toPath().resolve("rssh_host.key");
SshServer sshd = SshServer.setUpDefaultServer();
sshd.setHost("127.0.0.1");
sshd.setPort(listenPort);
@@ -45,14 +47,15 @@ public final class RsshLocalSshServer {
sshd.setPasswordAuthenticator((PasswordAuthenticator) (u, p, session) ->
user.equals(u) && pass.equals(p));
sshd.setShellFactory(new ProcessShellFactory("/system/bin/sh", "-"));
+ sshd.setSubsystemFactories(Collections.singletonList(new SftpSubsystemFactory()));
sshd.start();
server = sshd;
activeUser = user;
running.set(true);
- Log.i(TAG, "local SSH listening 127.0.0.1:" + listenPort + " user=" + user);
+ Log.i(TAG, "local SSH/SFTP listening 127.0.0.1:" + listenPort + " user=" + user);
return true;
- } catch (Exception e) {
- Log.w(TAG, "local SSH start failed: " + e.getMessage());
+ } catch (Throwable e) {
+ Log.e(TAG, "local SSH start failed: " + e.getMessage(), e);
stop();
return false;
}
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RsshSecuritySetup.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RsshSecuritySetup.java
new file mode 100644
index 0000000..c0b9ea7
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RsshSecuritySetup.java
@@ -0,0 +1,45 @@
+package com.foxx.androidcast.remoteaccess;
+
+import android.content.Context;
+import android.util.Log;
+
+import org.apache.sshd.common.util.OsUtils;
+import org.apache.sshd.common.util.io.PathUtils;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+
+import java.nio.file.Path;
+import java.security.Security;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/** One-time MINA SSHD + BouncyCastle setup for Android (see mina-sshd docs/android.md). */
+final class RsshSecuritySetup {
+ private static final String TAG = "RsshSecurity";
+ private static final AtomicBoolean READY = new AtomicBoolean(false);
+
+ private RsshSecuritySetup() {}
+
+ static void ensureReady(Context context) {
+ if (!READY.compareAndSet(false, true)) {
+ return;
+ }
+ Context app = context.getApplicationContext();
+ try {
+ try {
+ Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME);
+ } catch (Exception ignored) {
+ }
+ if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+ Path home = app.getFilesDir().toPath();
+ System.setProperty("user.home", home.toString());
+ PathUtils.setUserHomeFolderResolver(() -> home);
+ OsUtils.setCurrentUser(app.getPackageName());
+ Log.i(TAG, "SSHD security providers ready; user.home=" + home);
+ } catch (Exception e) {
+ READY.set(false);
+ Log.e(TAG, "SSHD security setup failed: " + e.getMessage(), e);
+ throw new IllegalStateException("RSSH security setup failed", e);
+ }
+ }
+}
diff --git a/examples/crash_reporter/backend/public/assets/js/remote_access.js b/examples/crash_reporter/backend/public/assets/js/remote_access.js
index b042d8c..b4aa098 100644
--- a/examples/crash_reporter/backend/public/assets/js/remote_access.js
+++ b/examples/crash_reporter/backend/public/assets/js/remote_access.js
@@ -518,8 +518,16 @@
const rowKey = 'ra-' + i;
const briefId = 'ra-brief-' + rowKey;
const wl = Number(d.whitelisted) === 1;
- const canOpen = canOperate() && wl && (d.opt_in_mode === 'wireguard' || d.opt_in_mode === 'rssh');
- const openDisabled = canOpen ? '' : ' disabled title="Whitelist device and wait for WireGuard/RSSH opt-in poll"';
+ const optIn = d.opt_in_mode || 'none';
+ const openReady = wl && (optIn === 'wireguard' || optIn === 'rssh');
+ const openHint = !canOperate()
+ ? 'Need remote_access_operate permission'
+ : !wl
+ ? 'Whitelist device first'
+ : openReady
+ ? 'Open session — device connects on next poll (≤7 min)'
+ : 'Phone must poll with RSSH/WG enabled (dev settings on device; wait ≤7 min)';
+ const openDisabled = canOperate() ? '' : ' disabled';
const wlDisabled = canAdmin() ? '' : ' disabled';
const isOpen = lastExpanded.has(String(d.device_id || ''));
const rowClass =
@@ -545,6 +553,8 @@
openDisabled +
' data-open-session="' +
esc(d.device_id) +
+ '" title="' +
+ esc(openHint) +
'">Open session ' +
'