diff --git a/app/build.gradle b/app/build.gradle
index 62faccc..bcf4542 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -139,5 +139,9 @@ dependencies {
implementation 'com.github.mwiede:jsch:0.2.21'
+ implementation 'org.apache.sshd:sshd-core:2.14.0'
+ implementation 'org.apache.sshd:sshd-scp:2.14.0'
+ 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 f9d8c6e..785c741 100644
--- a/app/src/main/assets/licenses/combined.html
+++ b/app/src/main/assets/licenses/combined.html
@@ -19,6 +19,7 @@
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)
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 93f7a55..48572b2 100644
--- a/app/src/main/java/com/foxx/androidcast/remoteaccess/ReverseSshTunnelBridge.java
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/ReverseSshTunnelBridge.java
@@ -42,6 +42,10 @@ public final class ReverseSshTunnelBridge {
Log.w(TAG, "incomplete RSSH credentials");
return false;
}
+ if (!RsshLocalSshServer.start(user, pass, localPort)) {
+ Log.w(TAG, "local SSH on :" + localPort + " failed");
+ return false;
+ }
CountDownLatch connectedLatch = new CountDownLatch(1);
AtomicBoolean ok = new AtomicBoolean(false);
worker = new Thread(() -> {
@@ -54,7 +58,7 @@ public final class ReverseSshTunnelBridge {
s.setConfig(cfg);
s.setServerAliveInterval(30_000);
s.connect(CONNECT_TIMEOUT_MS);
- s.setPortForwardingR(remotePort, localHost, localPort);
+ s.setPortForwardingR("127.0.0.1", remotePort, localHost, localPort);
session = s;
connected.set(true);
ok.set(true);
@@ -98,6 +102,7 @@ public final class ReverseSshTunnelBridge {
Session s = session;
session = null;
connected.set(false);
+ RsshLocalSshServer.stop();
if (s != null) {
try {
s.disconnect();
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RsshLocalSshServer.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RsshLocalSshServer.java
new file mode 100644
index 0000000..3cc3ce5
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RsshLocalSshServer.java
@@ -0,0 +1,83 @@
+package com.foxx.androidcast.remoteaccess;
+
+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 java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Embedded SSH 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 {
+ private static final String TAG = "RsshLocalSsh";
+
+ private static volatile SshServer server;
+ private static volatile String activeUser = "";
+ private static final AtomicBoolean running = new AtomicBoolean(false);
+
+ private RsshLocalSshServer() {}
+
+ public static boolean start(String username, String password, int port) {
+ stop();
+ if (username == null || username.isEmpty() || password == null || password.isEmpty()) {
+ return false;
+ }
+ if (port <= 0 || port > 65535) {
+ port = 8022;
+ }
+ final String user = username;
+ final String pass = password;
+ final int listenPort = port;
+ try {
+ Path hostKey = Files.createTempFile("rssh_host", ".key");
+ hostKey.toFile().deleteOnExit();
+ SshServer sshd = SshServer.setUpDefaultServer();
+ sshd.setHost("127.0.0.1");
+ sshd.setPort(listenPort);
+ sshd.setKeyPairProvider(new SimpleGeneratorHostKeyProvider(hostKey));
+ sshd.setPasswordAuthenticator((PasswordAuthenticator) (u, p, session) ->
+ user.equals(u) && pass.equals(p));
+ sshd.setShellFactory(new ProcessShellFactory("/system/bin/sh", "-"));
+ sshd.start();
+ server = sshd;
+ activeUser = user;
+ running.set(true);
+ Log.i(TAG, "local SSH listening 127.0.0.1:" + listenPort + " user=" + user);
+ return true;
+ } catch (Exception e) {
+ Log.w(TAG, "local SSH start failed: " + e.getMessage());
+ stop();
+ return false;
+ }
+ }
+
+ public static void stop() {
+ running.set(false);
+ activeUser = "";
+ SshServer local = server;
+ server = null;
+ if (local != null) {
+ try {
+ local.stop(true);
+ } catch (Exception e) {
+ Log.d(TAG, "local SSH stop: " + e.getMessage());
+ }
+ }
+ }
+
+ public static boolean isRunning() {
+ SshServer local = server;
+ return running.get() && local != null && local.isStarted();
+ }
+
+ public static String activeUsername() {
+ return activeUser != null ? activeUser : "";
+ }
+}
diff --git a/docs/OPEN_TASKS_GRAPH.md b/docs/OPEN_TASKS_GRAPH.md
index 1b07ac4..b8504a0 100644
--- a/docs/OPEN_TASKS_GRAPH.md
+++ b/docs/OPEN_TASKS_GRAPH.md
@@ -1,29 +1,8 @@
# Open tasks — dependency graph
-_Last synced: 2026-06-08 (post infra/browser/graphs/RSSH fixes). **DNS 2.x off graph** (postponed)._
+_Last synced: 2026-06-04 (RSSH full stack on `feature/rssh-alpha`, RA control HTTP, cluster acl0 DNS). **DNS 2.x off graph** (postponed)._
-**Documentation index:** [README.md](README.md) · **Service map:** [20260608_BE_SERVICES_and_infra.md](20260608_BE_SERVICES_and_infra.md)
-
----
-
----
-
----
-
----
-
-## Table of contents
-
-
-- [Removed from graph (done)](#removed-from-graph-done)
-- [Postponed / off graph (DNS + mail)](#postponed-off-graph-dns-mail)
-- [Priority tiers (open only)](#priority-tiers-open-only)
-- [Dependency graph](#dependency-graph)
-- [Task index (open)](#task-index-open)
-- [Critical path](#critical-path)
-
-
-**Documentation index:** [README.md](README.md)
+**Standing rules:** [../bottomline_reminder.txt](../bottomline_reminder.txt) · **Documentation index:** [README.md](README.md) · **Service map:** [20260608_BE_SERVICES_and_infra.md](20260608_BE_SERVICES_and_infra.md)
---
@@ -33,13 +12,22 @@ _Last synced: 2026-06-08 (post infra/browser/graphs/RSSH fixes). **DNS 2.x off g
|----|------|
| **0.1** | Stream dump |
| **3.x** | Auth register + verify + TOTP + migration `008` |
-| **4.x** | RSSH alpha — BE + Android + JSch |
-| **4.wg** | WG lab — BE `wg0`, FE + **router** DNAT persist |
+| **4.wg** | WG lab — BE `wg0`, FE + router DNAT persist |
| **RBAC** (core) | Admin panel + lockout clear |
| **Browser** | JS syntax validated; `validate_be_services.sh` |
-| **1.1 / 1.2** | Graphs drill-down, columns, full-width (`shell--graphs-full`) |
-| **5.3** | RSSH API validate — `test_rssh_api.sh` OK on BE |
+| **1.1 / 1.2** | Graphs drill-down, columns, full-width |
+| **5.3** | RSSH API smoke — `test_rssh_api.sh` |
| **Infra doc** | `20260608_BE_SERVICES_and_infra.md` + PDF |
+| **RA control** | `:ra_control` HTTP + WebSocket shell + adb JSON `ra_control` block |
+
+---
+
+## In progress (feature/rssh-alpha)
+
+| ID | Owner | Blocker | Status |
+|----|-------|---------|--------|
+| **4.rssh** | Agent | BE `provision_users` + FE stream deploy on PO | **In progress** — mobile MINA :8022, bastion provisioner, linux-sim, docs/tests in repo |
+| **cluster0** | Agent | cast01–03 reachable | **Ongoing** — lab VMs; DNS `c1–c3.acl0.f0xx.org` noted in `cluster.env` |
---
@@ -47,7 +35,7 @@ _Last synced: 2026-06-08 (post infra/browser/graphs/RSSH fixes). **DNS 2.x off g
| ID | Owner | Note |
|----|-------|------|
-| **2.1–2.4** | Developer | DNS / forwards — **postponed** |
+| **2.1–2.4** | Developer | DNS / forwards — **postponed** (acl0 CNAMEs added for cluster lab only) |
| **2.5** | Agent | SMTP — frozen |
| **5.4** (mail) | Agent | Auth mail E2E — frozen |
@@ -58,9 +46,10 @@ _Last synced: 2026-06-08 (post infra/browser/graphs/RSSH fixes). **DNS 2.x off g
| Tier | IDs | Alpha blocker |
|------|-----|---------------|
| **P4** | **5.1 OTA**, **5.2 URLs**, **5.5** RBAC UI soak | **Yes** |
+| **P4b** | **4.rssh** deploy soak | **Yes** (alpha essential) |
| **P5** | **6.1–6.6** app LAN | LAN alpha |
| **P9** | **9.x** landing (excl. globe FR) | No |
-| **Post-alpha** | **SFU** relay (`feature/sfu-relay`) | No |
+| **Post-alpha** | **SFU** relay | No |
---
@@ -72,6 +61,7 @@ flowchart TB
OTA["5.1 OTA channel
SVC-OTA · HTTP 400"]
URL["5.2 device URLs
SVC-URL · legacy host"]
RBACV["5.5 RBAC UI soak
API ✓"]
+ RSSH["4.rssh full stack
feature/rssh-alpha
Agent"]
end
subgraph P5["P5 — app LAN · dev"]
@@ -83,12 +73,16 @@ flowchart TB
A66["6.6 stream analysis"]
end
- subgraph P9["P9 — landing"]
- L9["9.x hub cards
no globe FR"]
+ subgraph LAB["Lab · cluster0"]
+ C0["cast01–03
c1–c3.acl0.f0xx.org"]
end
- subgraph POST["Post-alpha · hidden"]
- SFU["SFU / Janus relay
feature/sfu-relay"]
+ subgraph P9["P9 — landing"]
+ L9["9.x hub cards"]
+ end
+
+ subgraph POST["Post-alpha"]
+ SFU["SFU / Janus relay"]
end
FULL["FULL_ALPHA
no DNS/mail"]
@@ -96,7 +90,9 @@ flowchart TB
OTA --> FULL
URL --> FULL
RBACV --> FULL
+ RSSH --> FULL
A62 --> FULL
+ C0 -.-> RSSH
A63 <--> A64
A65 --> A66
@@ -106,6 +102,7 @@ flowchart TB
style FULL fill:#1e3a5f,color:#fff
style OTA fill:#991b1b,color:#fff
style URL fill:#b45309,color:#fff
+ style RSSH fill:#7c3aed,color:#fff
style SFU fill:#e5e7eb,color:#374151
```
@@ -115,12 +112,14 @@ flowchart TB
| ID | Owner | Depends on | Status |
|----|-------|------------|--------|
-| **5.1** | 50/50 | nginx OTA path | **Open** — `SVC-OTA` ticket |
-| **5.2** | 50/50 | — | **Open** — `SVC-URL` / `settings.json` base |
-| **5.5** | Agent | RBAC API ✓ | Light UI soak (optional) |
+| **4.rssh** | Agent | FE stream + BE sshd Match User | **In progress** — repo complete pending deploy soak |
+| **5.1** | 50/50 | nginx OTA path | **Open** |
+| **5.2** | 50/50 | — | **Open** |
+| **5.5** | Agent | RBAC API ✓ | Light UI soak |
| **6.1–6.6** | Developer (~99%) | 0.1 ✓ | Open |
-| **9.x** | Agent (low) | — | Open (globe out of scope) |
-| **SFU** | Agent | owner spec | Hidden preview on `feature/sfu-relay` |
+| **9.x** | Agent (low) | — | Open |
+| **cluster0** | Agent | NFS/DNS | cast01–03 lab |
+| **SFU** | Agent | owner spec | Hidden |
---
@@ -129,9 +128,23 @@ flowchart TB
```text
BLOCKER: 5.1 OTA nginx (/v0/ota/ on apps.f0xx.org) ──┐
SHARED: 5.2 production URLs (apps not f0xx.org) ─────┼──► FULL_ALPHA
-OPTIONAL: 5.5 RBAC UI click-through ──────────────────┤
-DEV: 6.2 LAN soak ───────────────────────────────┘
+ALPHA: 4.rssh deploy (FE stream + BE sshd + app) ───┤
+OPTIONAL: 5.5 RBAC UI click-through ────────────────────┤
+DEV: 6.2 LAN soak ────────────────────────────────┘
-Off graph: 2.x DNS/mail (postponed)
-Parallel: 6.1/6.3–6.6, 9.x (no globe), SFU post-alpha
+Parallel: cluster0 lab (c1–c3.acl0.f0xx.org), 6.1/6.3–6.6, 9.x, SFU
+Off graph: 2.x DNS/mail (postponed)
```
+
+---
+
+## Numbered priority legend (PO overrides AI)
+
+1. **5.1 OTA** — nginx path (PO/dev)
+2. **5.2 URLs** — production base URLs
+3. **4.rssh** — alpha remote access (Agent, `feature/rssh-alpha`)
+4. **5.5 RBAC UI soak** — optional click-through
+5. **6.2 LAN soak** — developer device validation
+6. **cluster0** — cast01–03 integration tests
+7. **6.1 / 6.3–6.6** — app LAN quality
+8. **9.x** — landing polish
diff --git a/docs/REMOTE_ACCESS_IMPL.md b/docs/REMOTE_ACCESS_IMPL.md
index 2b2b1cd..32b6d67 100644
--- a/docs/REMOTE_ACCESS_IMPL.md
+++ b/docs/REMOTE_ACCESS_IMPL.md
@@ -186,11 +186,28 @@ bash scripts/init-third-party-submodules.sh # or ./rebuild.sh (includes init +
Gradle fails fast if the submodule is missing. VPN runs in `:vpn` (`AndroidCastVpnService` + AIDL); `WireGuardVpnEngine` uses `GoBackend` with TUN fallback in the same process.
+## RSSH alpha (reverse SSH)
+
+| Layer | Component | Notes |
+|-------|-----------|--------|
+| **App** | `ReverseSshTunnelBridge` | JSch outbound `-R 127.0.0.1::127.0.0.1:8022` |
+| **App** | `RsshLocalSshServer` | Apache MINA SSHD on `127.0.0.1:8022` |
+| **BE** | `RsshSessionProvisioner` / `RsshBastionProvisioner` | DB creds; optional Linux user via `rssh_bastion_user.sh` |
+| **FE** | nginx `stream` | `nginx/rssh-bastion-stream.conf.example` |
+| **Sim** | `examples/rssh/linux-sim/` | Laptop heartbeat + `ssh -R` |
+
+Developer settings → **RSSH** (no VPN dialog). Operator command shown in admin active sessions.
+
+## RA control HTTP (`:ra_control`)
+
+Isolated process: template web UI + sandbox shell when RA session is up. Port/token in `adb.json` `ra_control` block.
+
## Tests
```bash
./gradlew :app:testDebugUnitTest --tests 'com.foxx.androidcast.remoteaccess.*'
-bash examples/crash_reporter/backend/scripts/test_rbac_api.sh
+bash examples/crash_reporter/backend/scripts/test_rssh_unit.sh
+bash examples/crash_reporter/backend/scripts/test_rssh_api.sh
bash examples/crash_reporter/backend/scripts/test_remote_access_api.sh
BASE=https://apps.f0xx.org/app/androidcast_project/crashes bash examples/crash_reporter/backend/scripts/test_remote_access_api.sh
BASE=https://apps.f0xx.org/app/androidcast_project/crashes bash examples/crash_reporter/backend/scripts/ra_e2e_cli.sh
diff --git a/examples/crash_reporter/backend/config/config.example.php b/examples/crash_reporter/backend/config/config.example.php
index 5903f97..1d55b10 100644
--- a/examples/crash_reporter/backend/config/config.example.php
+++ b/examples/crash_reporter/backend/config/config.example.php
@@ -71,6 +71,9 @@ return [
'local_forward_port' => 8022,
'remote_port_min' => 18000,
'remote_port_max' => 18999,
+ // Lab/prod: create ephemeral Match User ra-* on BE sshd (see scripts/rssh_bastion_user.sh)
+ 'provision_users' => false,
+ 'provision_script' => '', // default: backend/scripts/rssh_bastion_user.sh
],
],
// Outbound mail (registration verify, password reset) — see docs/20260607-2FA-email-mobile-auth-flow.md
diff --git a/examples/crash_reporter/backend/nginx/rssh-bastion-stream.conf.example b/examples/crash_reporter/backend/nginx/rssh-bastion-stream.conf.example
new file mode 100644
index 0000000..c7453e8
--- /dev/null
+++ b/examples/crash_reporter/backend/nginx/rssh-bastion-stream.conf.example
@@ -0,0 +1,16 @@
+# FE nginx stream — TCP proxy to BE sshd for RSSH bastion (ra.apps.f0xx.org:443).
+# Install on FE (Gentoo); BE runs openssh with Match User ra-* (see sshd_config.d/ra.conf).
+#
+# Include from nginx.conf:
+# include /etc/nginx/stream.d/rssh-bastion.conf;
+
+upstream rssh_bastion_be {
+ server 10.7.16.128:22; # artc0 / alpine-be — adjust for cluster/lab
+}
+
+server {
+ listen 443;
+ proxy_pass rssh_bastion_be;
+ proxy_connect_timeout 30s;
+ proxy_timeout 24h;
+}
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 26f6f7a..b042d8c 100644
--- a/examples/crash_reporter/backend/public/assets/js/remote_access.js
+++ b/examples/crash_reporter/backend/public/assets/js/remote_access.js
@@ -598,12 +598,23 @@
const closeBtn = canOperate()
? ''
: '';
+ let endpoint = s.endpoint || '—';
+ if (s.tunnel === 'ssh_reverse' && s.rssh_remote_bind) {
+ endpoint = endpoint + ' → ' + esc(s.rssh_remote_bind);
+ }
+ let operator = '';
+ if (s.rssh_operator) {
+ operator =
+ '';
+ }
tr.innerHTML =
'' + esc(s.session_id) + ' | ' +
'' + esc(s.device_id) + ' | ' +
'' + esc(s.status) + ' | ' +
'' + esc(s.tunnel) + ' | ' +
- '' + esc(s.endpoint || '—') + ' | ' +
+ '' + endpoint + operator + ' | ' +
'' + closeBtn + ' | ';
aBody.appendChild(tr);
});
diff --git a/examples/crash_reporter/backend/scripts/rssh_bastion_user.sh b/examples/crash_reporter/backend/scripts/rssh_bastion_user.sh
new file mode 100755
index 0000000..7ad5e1d
--- /dev/null
+++ b/examples/crash_reporter/backend/scripts/rssh_bastion_user.sh
@@ -0,0 +1,59 @@
+#!/bin/sh
+# Ephemeral RSSH bastion users (Match User ra-* in sshd_config.d/ra.conf).
+# Usage: rssh_bastion_user.sh add|remove USERNAME [PASSWORD]
+set -eu
+
+ACTION="${1:-}"
+USER="${2:-}"
+PASS="${3:-}"
+
+die() {
+ echo "rssh_bastion_user: $*" >&2
+ exit 1
+}
+
+validate_user() {
+ case "$USER" in
+ ra-*)
+ return 0
+ ;;
+ *)
+ die "invalid username (expected ra-* prefix)"
+ ;;
+ esac
+}
+
+cmd_add() {
+ validate_user
+ [ -n "$PASS" ] || die "password required for add"
+ if id "$USER" >/dev/null 2>&1; then
+ echo "$USER:$PASS" | chpasswd
+ exit 0
+ fi
+ if command -v adduser >/dev/null 2>&1; then
+ adduser -D -h /dev/null -s /bin/sh "$USER"
+ elif command -v useradd >/dev/null 2>&1; then
+ useradd -M -s /bin/sh "$USER"
+ else
+ die "no adduser/useradd"
+ fi
+ echo "$USER:$PASS" | chpasswd
+}
+
+cmd_remove() {
+ validate_user
+ if ! id "$USER" >/dev/null 2>&1; then
+ exit 0
+ fi
+ if command -v deluser >/dev/null 2>&1; then
+ deluser "$USER" 2>/dev/null || true
+ elif command -v userdel >/dev/null 2>&1; then
+ userdel "$USER" 2>/dev/null || true
+ fi
+}
+
+case "$ACTION" in
+ add) cmd_add ;;
+ remove) cmd_remove ;;
+ *) die "usage: $0 add|remove USERNAME [PASSWORD]" ;;
+esac
diff --git a/examples/crash_reporter/backend/scripts/test_rssh_api.sh b/examples/crash_reporter/backend/scripts/test_rssh_api.sh
index 41c3320..20088c2 100755
--- a/examples/crash_reporter/backend/scripts/test_rssh_api.sh
+++ b/examples/crash_reporter/backend/scripts/test_rssh_api.sh
@@ -25,6 +25,6 @@ body=$(printf '{"heartbeat":{"type":"ra","status":"enable","device_id":"%s","ran
curl -sf -X POST -H 'Content-Type: application/json' -d "$body" "$(ra_heartbeat_url)" > /tmp/rssh-connect.json
grep -q '"action":"connect"' /tmp/rssh-connect.json && echo OK connect
grep -q 'bastion_host' /tmp/rssh-connect.json && echo OK bastion fields
-grep -q 'remote_port' /tmp/rssh-connect.json && echo OK remote_port
+grep -q 'remote_bind_port' /tmp/rssh-connect.json && echo OK remote_bind_port
echo "All RSSH API checks passed."
diff --git a/examples/crash_reporter/backend/scripts/test_rssh_unit.sh b/examples/crash_reporter/backend/scripts/test_rssh_unit.sh
new file mode 100755
index 0000000..591a42b
--- /dev/null
+++ b/examples/crash_reporter/backend/scripts/test_rssh_unit.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+# Unit checks for RsshSessionProvisioner + RsshBastionProvisioner (no DB).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+export CRASHES_CONFIG="${CRASHES_CONFIG:-$ROOT/config/config.example.php}"
+
+php -r '
+require "'"$ROOT"'/src/bootstrap.php";
+$alloc = RsshSessionProvisioner::allocate("test-session-abc");
+assert($alloc["username"] !== "");
+assert($alloc["remote_bind_port"] >= 18000);
+assert($alloc["local_forward_port"] === 8022);
+$cmds = RsshBastionProvisioner::operatorCommands([
+ "rssh_username" => $alloc["username"],
+ "rssh_remote_port" => $alloc["remote_bind_port"],
+]);
+assert(isset($cmds["shell"]));
+assert(str_contains($cmds["shell"], (string) $alloc["remote_bind_port"]));
+echo "OK RsshSessionProvisioner + operatorCommands\n";
+'
+
+echo "All RSSH PHP unit checks passed."
diff --git a/examples/crash_reporter/backend/src/RemoteAccessRepository.php b/examples/crash_reporter/backend/src/RemoteAccessRepository.php
index 6eafca8..d0d4758 100644
--- a/examples/crash_reporter/backend/src/RemoteAccessRepository.php
+++ b/examples/crash_reporter/backend/src/RemoteAccessRepository.php
@@ -473,6 +473,10 @@ final class RemoteAccessRepository {
];
}
$alloc = RsshSessionProvisioner::allocate($sessionId);
+ $bastion = RsshBastionProvisioner::addUser($alloc['username'], $alloc['password']);
+ if (!$bastion['ok']) {
+ throw new RuntimeException((string) ($bastion['error'] ?? 'bastion_user_add_failed'));
+ }
Database::pdo()->prepare(
'UPDATE remote_access_sessions SET rssh_bastion_host = ?, rssh_bastion_port = ?, rssh_username = ?, rssh_secret = ?, rssh_remote_port = ?, rssh_local_port = ? WHERE session_id = ?'
)->execute([
@@ -572,14 +576,18 @@ final class RemoteAccessRepository {
public static function closeSession(string $sessionId, string $status, string $reason): void {
$st = Database::pdo()->prepare(
- 'SELECT tunnel, wg_client_public_key FROM remote_access_sessions WHERE session_id = ? LIMIT 1'
+ 'SELECT tunnel, wg_client_public_key, rssh_username FROM remote_access_sessions WHERE session_id = ? LIMIT 1'
);
$st->execute([$sessionId]);
$row = $st->fetch(PDO::FETCH_ASSOC);
- if (is_array($row) && ($row['tunnel'] ?? 'wireguard') === 'wireguard') {
- $pub = $row['wg_client_public_key'] ?? '';
- if (is_string($pub) && $pub !== '') {
- WireGuardPeerProvisioner::removeClientPeer($pub);
+ if (is_array($row)) {
+ if (($row['tunnel'] ?? 'wireguard') === 'wireguard') {
+ $pub = $row['wg_client_public_key'] ?? '';
+ if (is_string($pub) && $pub !== '') {
+ WireGuardPeerProvisioner::removeClientPeer($pub);
+ }
+ } elseif (($row['tunnel'] ?? '') === 'ssh_reverse') {
+ RsshBastionProvisioner::removeUser((string) ($row['rssh_username'] ?? ''));
}
}
$now = self::nowSql();
@@ -857,12 +865,27 @@ final class RemoteAccessRepository {
return [
'devices' => self::listDevicesForDashboard($issuesBase, $projectBase),
- 'active_sessions' => $active,
- 'inactive_sessions' => array_slice($inactive, 0, 50),
+ 'active_sessions' => array_map([self::class, 'enrichSessionRow'], $active),
+ 'inactive_sessions' => array_map([self::class, 'enrichSessionRow'], array_slice($inactive, 0, 50)),
'recent_events' => self::listEvents(30),
];
}
+ /** @param array $row @return array */
+ private static function enrichSessionRow(array $row): array {
+ if (($row['tunnel'] ?? '') === 'ssh_reverse') {
+ $cmds = RsshBastionProvisioner::operatorCommands($row);
+ if ($cmds !== []) {
+ $row['rssh_operator'] = $cmds;
+ }
+ $remote = (int) ($row['rssh_remote_port'] ?? 0);
+ if ($remote > 0) {
+ $row['rssh_remote_bind'] = '127.0.0.1:' . $remote;
+ }
+ }
+ return $row;
+ }
+
/** @return list> */
public static function listDevicesForDashboard(string $issuesBase, string $projectBase): array {
$devices = self::listDevices();
diff --git a/examples/crash_reporter/backend/src/RsshBastionProvisioner.php b/examples/crash_reporter/backend/src/RsshBastionProvisioner.php
new file mode 100644
index 0000000..8f24a59
--- /dev/null
+++ b/examples/crash_reporter/backend/src/RsshBastionProvisioner.php
@@ -0,0 +1,89 @@
+ false, 'error' => 'invalid_username'];
+ }
+ if (!self::isEnabled()) {
+ return ['ok' => true];
+ }
+ $code = self::runScript('add', $user, $password);
+ if ($code !== 0) {
+ error_log('RsshBastionProvisioner add failed for ' . $user . ' exit=' . $code);
+ return ['ok' => false, 'error' => 'bastion_user_add_failed'];
+ }
+ return ['ok' => true];
+ }
+
+ public static function removeUser(?string $username): void {
+ $user = self::normalizeUsername((string) $username);
+ if ($user === '' || !self::isEnabled()) {
+ return;
+ }
+ $code = self::runScript('remove', $user, '');
+ if ($code !== 0) {
+ error_log('RsshBastionProvisioner remove failed for ' . $user . ' exit=' . $code);
+ }
+ }
+
+ /** Operator command lines once device has connected reverse forward. */
+ public static function operatorCommands(array $session): array {
+ $username = trim((string) ($session['rssh_username'] ?? ''));
+ $remotePort = (int) ($session['rssh_remote_port'] ?? 0);
+ if ($username === '' || $remotePort <= 0) {
+ return [];
+ }
+ $host = '127.0.0.1';
+ return [
+ 'shell' => sprintf('ssh -p %d %s@%s', $remotePort, $username, $host),
+ 'sftp' => sprintf('sftp -P %d %s@%s', $remotePort, $username, $host),
+ 'scp_example' => sprintf('scp -P %d %s@%s:/path/on/device ./', $remotePort, $username, $host),
+ ];
+ }
+
+ private static function normalizeUsername(string $username): string {
+ $user = trim($username);
+ if ($user === '' || !preg_match('/^ra-[a-zA-Z0-9_-]{1,30}$/', $user)) {
+ return '';
+ }
+ return $user;
+ }
+
+ private static function runScript(string $action, string $username, string $password): int {
+ $script = self::scriptPath();
+ if (!is_file($script) || !is_executable($script)) {
+ error_log('RsshBastionProvisioner: script missing or not executable: ' . $script);
+ return 127;
+ }
+ $cmd = escapeshellarg($script) . ' ' . escapeshellarg($action) . ' ' . escapeshellarg($username);
+ if ($action === 'add') {
+ $cmd .= ' ' . escapeshellarg($password);
+ }
+ $out = [];
+ exec($cmd . ' 2>&1', $out, $code);
+ if ($code !== 0 && $out !== []) {
+ error_log('RsshBastionProvisioner: ' . implode("\n", $out));
+ }
+ return (int) $code;
+ }
+}
diff --git a/examples/crash_reporter/backend/src/bootstrap.php b/examples/crash_reporter/backend/src/bootstrap.php
index 876098a..207b651 100644
--- a/examples/crash_reporter/backend/src/bootstrap.php
+++ b/examples/crash_reporter/backend/src/bootstrap.php
@@ -70,6 +70,7 @@ require_once __DIR__ . '/WireGuardPeerProvisioner.php';
require_once __DIR__ . '/WireGuardAddressPool.php';
require_once __DIR__ . '/WireGuardTrafficStats.php';
require_once __DIR__ . '/RsshSessionProvisioner.php';
+require_once __DIR__ . '/RsshBastionProvisioner.php';
require_once __DIR__ . '/AnalyticsHead.php';
function cfg(string $key, $default = null) {
diff --git a/examples/rssh/linux-sim/README.md b/examples/rssh/linux-sim/README.md
new file mode 100644
index 0000000..ca9b840
--- /dev/null
+++ b/examples/rssh/linux-sim/README.md
@@ -0,0 +1,42 @@
+# Linux RSSH device/operator simulation
+
+Simulates the **Android RSSH path** on a laptop with real OpenSSH client + optional local `sshd`.
+
+## Prerequisites
+
+- `curl`, `jq`, `sshpass` (for non-interactive sim)
+- Backend reachable (`CRASHES_BASE` or default from `ra_lib.sh`)
+- Admin whitelist + open session for the simulated `device_id`
+
+## Device sim (heartbeat + reverse forward)
+
+```bash
+export CRASHES_BASE="http://cast01.intra.raptor.org/app/androidcast_project/crashes"
+export RA_DEVICE_ID="linux-rssh-lab-01"
+
+# 1) First run registers heartbeat (wait)
+./ra_device_sim.sh
+
+# 2) Whitelist device + open session in admin UI, then:
+export RA_SKIP_ADMIN=1
+./ra_device_sim.sh
+```
+
+## Operator sim (on bastion / BE)
+
+After device sim prints `REMOTE_BIND_PORT` and username:
+
+```bash
+./ra_operator_connect.sh 18022 ra-SESSIONID shell
+./ra_operator_connect.sh 18022 ra-SESSIONID sftp
+```
+
+## Android parity
+
+| Android | Linux sim |
+|---------|-----------|
+| `RemoteAccessService` poll | `ra_ra_post` via `ra_lib.sh` |
+| `RsshLocalSshServer` :8022 | local `sshd -p 8022` |
+| `ReverseSshTunnelBridge` JSch `-R` | `ssh -N -R …` |
+
+See [REMOTE_ACCESS_IMPL.md](../../../docs/REMOTE_ACCESS_IMPL.md) and [20260602_REVERSE_SSH_proposals_summary.md](../../../docs/20260602_REVERSE_SSH_proposals_summary.md).
diff --git a/examples/rssh/linux-sim/ra_device_sim.sh b/examples/rssh/linux-sim/ra_device_sim.sh
new file mode 100755
index 0000000..197bf48
--- /dev/null
+++ b/examples/rssh/linux-sim/ra_device_sim.sh
@@ -0,0 +1,75 @@
+#!/usr/bin/env bash
+# Linux laptop simulates Android RSSH device: heartbeat poll + outbound -R + local sshd on 8022.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+BE_SCRIPTS="$(cd "$(dirname "$0")/../../crash_reporter/backend/scripts" && pwd)"
+# shellcheck source=../../crash_reporter/backend/scripts/ra_lib.sh
+source "$BE_SCRIPTS/ra_lib.sh"
+
+DEVICE_ID="${RA_DEVICE_ID:-linux-rssh-$(hostname -s)-$$}"
+RANDOM_ID="${RA_RANDOM:-rssh-linux-$(date +%s)}"
+LOCAL_PORT="${RA_LOCAL_PORT:-8022}"
+WORKDIR="${RA_SIM_WORKDIR:-/tmp/rssh-sim-$$}"
+SSH_PID=""
+DB_PID=""
+
+cleanup() {
+ [ -n "$SSH_PID" ] && kill "$SSH_PID" 2>/dev/null || true
+ [ -n "$DB_PID" ] && kill "$DB_PID" 2>/dev/null || true
+ rm -rf "$WORKDIR"
+}
+trap cleanup EXIT
+
+mkdir -p "$WORKDIR"
+HOST_KEY="$WORKDIR/host_key"
+
+if ! command -v ssh-keygen >/dev/null 2>&1; then
+ echo "openssh client required" >&2
+ exit 1
+fi
+
+echo "== local device SSH on 127.0.0.1:${LOCAL_PORT} =="
+ssh-keygen -t ed25519 -f "$HOST_KEY" -N "" -q
+/usr/sbin/sshd -D -f /dev/null -h "$HOST_KEY" -p "$LOCAL_PORT" -o AuthorizedKeysFile=/dev/null \
+ -o PasswordAuthentication=yes -o PermitRootLogin=no -o UsePAM=no \
+ -o AllowUsers="${RA_SSH_USER:-rssh-sim}" 2>/dev/null &
+DB_PID=$!
+sleep 0.5
+
+echo "== heartbeat enable (tunnel_mode=rssh) =="
+export RA_TUNNEL_MODE=rssh
+ra_ra_post enable "$DEVICE_ID" "$RANDOM_ID" "linux-sim/1.0" >/tmp/rssh-sim-wait.json
+grep -q '"action":"wait"' /tmp/rssh-sim-wait.json && echo OK wait
+
+echo "Operator must whitelist + open session in admin UI, then re-run with RA_SKIP_ADMIN=1"
+if [[ "${RA_SKIP_ADMIN:-}" != "1" ]]; then
+ echo "Set RA_SKIP_ADMIN=1 after whitelisting device_id=$DEVICE_ID"
+ exit 0
+fi
+
+echo "== heartbeat connect =="
+ra_ra_post enable "$DEVICE_ID" "$RANDOM_ID-b" "linux-sim/1.0" >/tmp/rssh-sim-connect.json
+grep -q '"action":"connect"' /tmp/rssh-sim-connect.json || { cat /tmp/rssh-sim-connect.json; exit 1; }
+echo OK connect payload
+
+BASTION="$(ra_json -r '.credentials.bastion_host // empty' /tmp/rssh-sim-connect.json)"
+BPORT="$(ra_json -r '.credentials.bastion_port // 443' /tmp/rssh-sim-connect.json)"
+USER="$(ra_json -r '.credentials.username // empty' /tmp/rssh-sim-connect.json)"
+PASS="$(ra_json -r '.credentials.password // empty' /tmp/rssh-sim-connect.json)"
+RPORT="$(ra_json -r '.credentials.remote_bind_port // 0' /tmp/rssh-sim-connect.json)"
+
+[ -n "$BASTION" ] && [ -n "$USER" ] && [ "$RPORT" -gt 0 ] || { echo "missing credentials"; exit 1; }
+
+echo "== reverse SSH -R 127.0.0.1:${RPORT}:127.0.0.1:${LOCAL_PORT} =="
+sshpass -p "$PASS" ssh -N \
+ -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
+ -p "$BPORT" -R "127.0.0.1:${RPORT}:127.0.0.1:${LOCAL_PORT}" \
+ "${USER}@${BASTION}" &
+SSH_PID=$!
+sleep 2
+
+echo "Tunnel up. Operator on bastion:"
+echo " ssh -p ${RPORT} ${USER}@127.0.0.1"
+echo "Press Ctrl+C to stop."
+wait "$SSH_PID"
diff --git a/examples/rssh/linux-sim/ra_operator_connect.sh b/examples/rssh/linux-sim/ra_operator_connect.sh
new file mode 100755
index 0000000..93fb286
--- /dev/null
+++ b/examples/rssh/linux-sim/ra_operator_connect.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+# Operator-side: SSH/SFTP to forwarded port on bastion (run on BE or jump host).
+set -euo pipefail
+
+RPORT="${1:-}"
+USER="${2:-}"
+MODE="${3:-shell}"
+
+if [ -z "$RPORT" ] || [ -z "$USER" ]; then
+ echo "usage: $0 REMOTE_BIND_PORT RA_USERNAME [shell|sftp]" >&2
+ exit 1
+fi
+
+case "$MODE" in
+ shell)
+ exec ssh -p "$RPORT" -o StrictHostKeyChecking=no "${USER}@127.0.0.1"
+ ;;
+ sftp)
+ exec sftp -P "$RPORT" -o StrictHostKeyChecking=no "${USER}@127.0.0.1"
+ ;;
+ *)
+ echo "unknown mode: $MODE" >&2
+ exit 1
+ ;;
+esac
diff --git a/orchestration/sim/cluster0/cluster.env b/orchestration/sim/cluster0/cluster.env
index f2afb17..58a73e7 100644
--- a/orchestration/sim/cluster0/cluster.env
+++ b/orchestration/sim/cluster0/cluster.env
@@ -36,6 +36,12 @@ PUBLIC_ORIGIN=https://apps.f0xx.org
SHORT_LINKS_PUBLIC_BASE=https://s.f0xx.org
FE_PROXY_TARGET=cast01.intra.raptor.org:80
+# Lab DNS (2026-06): acl0.f0xx.org + c1–c3.acl0.f0xx.org → FE (cast01–03 cluster)
+ACL0_CNAME=acl0.f0xx.org
+CAST01_PUBLIC=c1.acl0.f0xx.org
+CAST02_PUBLIC=c2.acl0.f0xx.org
+CAST03_PUBLIC=c3.acl0.f0xx.org
+
EXPECTED_CRASHES_TABLES=16
EXPECTED_URL_SHORTENER_TABLES=4