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 = + '
    ' + + esc(s.rssh_operator.shell || '') + + '
    '; + } 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