This commit is contained in:
Anton Afanasyeu
2026-06-23 12:29:33 +02:00
commit 59fb5013c3
26 changed files with 2375 additions and 0 deletions

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# ac-ms-remote-access
Microservice API. Remote: `git://f0xx.org/ac/ac-ms-remote-access`

25
composer.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "androidcast/ms-remote-access",
"description": "WireGuard + RSSH control plane",
"type": "project",
"require": {
"php": ">=8.1",
"androidcast/platform-php": "dev-next",
"androidcast/platform-db": "dev-next"
},
"repositories": [
{
"type": "vcs",
"url": "git://f0xx.org/ac/ac-platform-php"
},
{
"type": "vcs",
"url": "git://f0xx.org/ac/ac-platform-db"
}
],
"autoload": {
"classmap": [
"src/"
]
}
}

View File

@@ -0,0 +1,2 @@
<?php
return ['db' => ['driver' => 'sqlite']];

View File

@@ -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).

View File

@@ -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"

View File

@@ -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

View File

@@ -0,0 +1,9 @@
alpine:
# apk add wireguard-tools iptables
# wg genkey | tee server.privatekey | wg pubkey > server.publickey
create /etc/wireguard/wg0.conf -> symlinked to /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/wireguard/BE_alpine/wg0.conf
follow https://wiki.alpinelinux.org/wiki/Configure_a_Wireguard_interface_(wg)
# chmod go= server.privatekey

View File

@@ -0,0 +1 @@
uIhZdcs0U6W09PVytrMD7qChzzvAgBAnZUI47V/axX4=

View File

@@ -0,0 +1 @@
EV2nnwHH7xe3jA5J46Rjtl8ao+ybZinibKg2Hy83GBw=

View File

@@ -0,0 +1,12 @@
[Interface]
Address = 172.200.2.1/16, fddd::ffff/64
ListenPort = 45340
# the key from the previously generated privatekey file
PrivateKey = uIhZdcs0U6W09PVytrMD7qChzzvAgBAnZUI47V/axX4=
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE;iptables -A FORWARD -o %i -j ACCEPT; ip6tables -A FORWARD -i %i -j ACCEPT; ip6tables -t nat -A POSTROUTING -o eth0 -j MASQUERADE;ip6tables -A FORWARD -o %i -j ACCEPT
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE;iptables -D FORWARD -o %i -j ACCEPT; ip6tables -D FORWARD -i %i -j ACCEPT; ip6tables -t nat -D POSTROUTING -o eth0 -j MASQUERADE;ip6tables -D FORWARD -o %i -j ACCEPT
[Peer]
# Static lab peer only — remote-access clients are added dynamically via wg set (PHP).
# PublicKey = <client-device-pubkey>
# AllowedIPs = 172.200.2.2/32

View File

@@ -0,0 +1,43 @@
#!/bin/bash
# FE (f0xx-monstro): DNAT public UDP → BE wg0. Matches existing rules to 10.7.16.128 (8088, etc.).
# Invoked from xen_firewall.sh start/stop; also: wireguard_fe_dnat.sh [start|stop]
set -euo pipefail
BE_IP="${BE_IP:-10.7.16.128}"
WG_PORT="${WG_PORT:-45340}"
function start() {
if iptables -t nat -C PREROUTING -p udp --dport "$WG_PORT" -j DNAT --to-destination "${BE_IP}:${WG_PORT}" 2>/dev/null; then
echo "DNAT rule already present for udp/${WG_PORT}"
else
iptables -t nat -A PREROUTING -p udp --dport "$WG_PORT" -j DNAT --to-destination "${BE_IP}:${WG_PORT}"
echo "Added PREROUTING DNAT udp/${WG_PORT} -> ${BE_IP}:${WG_PORT}"
fi
if iptables -C FORWARD -p udp -d "${BE_IP}" --dport "$WG_PORT" -j ACCEPT 2>/dev/null; then
echo "FORWARD rule already present"
else
iptables -A FORWARD -p udp -d "${BE_IP}" --dport "$WG_PORT" -j ACCEPT
echo "Added FORWARD udp -> ${BE_IP}:${WG_PORT}"
fi
}
function stop() {
if iptables -t nat -C PREROUTING -p udp --dport "$WG_PORT" -j DNAT --to-destination "${BE_IP}:${WG_PORT}" 2>/dev/null; then
iptables -t nat -D PREROUTING -p udp --dport "$WG_PORT" -j DNAT --to-destination "${BE_IP}:${WG_PORT}"
echo "Removed PREROUTING DNAT udp/${WG_PORT}"
else
echo "DNAT rule not present for udp/${WG_PORT}"
fi
if iptables -C FORWARD -p udp -d "${BE_IP}" --dport "$WG_PORT" -j ACCEPT 2>/dev/null; then
iptables -D FORWARD -p udp -d "${BE_IP}" --dport "$WG_PORT" -j ACCEPT
echo "Removed FORWARD udp -> ${BE_IP}:${WG_PORT}"
else
echo "FORWARD rule not present"
fi
}
cmd=$1
cmd=${cmd:-start}
[ "$cmd" = "start" ] && start || stop

View File

@@ -0,0 +1,8 @@
# Install on Alpine BE: /etc/sudoers.d/androidcast-wg (chmod 440)
# PHP-FPM pool user is nginx (see /etc/php81/php-fpm.d/www.conf).
Defaults:nobody !requiretty
Defaults:nginx !requiretty
nobody ALL=(root) NOPASSWD: /usr/bin/wg set wg0 peer *
nginx ALL=(root) NOPASSWD: /usr/bin/wg set wg0 peer *
nginx ALL=(root) NOPASSWD: /usr/bin/wg show wg0 dump
nobody ALL=(root) NOPASSWD: /usr/bin/wg show wg0 dump

View File

@@ -0,0 +1,47 @@
#!/bin/bash
# Run on alpine-be as root after syncing backend tree.
set -euo pipefail
ROOT="${1:-/var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend}"
CFG="$ROOT/config/config.php"
SUDOERS_DST="/etc/sudoers.d/androidcast-wg"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if [[ ! -f "$CFG" ]]; then
echo "Missing $CFG" >&2
exit 1
fi
php81 -r '
$path = $argv[1];
$c = require $path;
if (!is_array($c)) { fwrite(STDERR, "config.php must return array\n"); exit(1); }
if (isset($c["remote_access"]) && is_array($c["remote_access"]) && ($c["remote_access"]["wg_server_public_key"] ?? "") !== "") {
echo "remote_access already configured\n";
exit(0);
}
$c["remote_access"] = array_merge($c["remote_access"] ?? [], [
"wg_endpoint" => "ra.apps.f0xx.org:45340",
"wg_server_public_key" => "EV2nnwHH7xe3jA5J46Rjtl8ao+ybZinibKg2Hy83GBw=",
"wg_interface" => "wg0",
"wg_server_allowed_ips" => "172.200.2.1/32",
"wg_client_ip_prefix" => "172.200.2.",
"wg_client_ip_min" => 10,
"wg_client_ip_max" => 250,
"wg_set_prefix" => "sudo -n ",
"provision_peers" => true,
"require_wg_tools" => true,
"session_ttl_s" => 3600,
"min_poll_interval_s" => 45,
]);
$export = var_export($c, true);
file_put_contents($path, "<?php\nreturn " . $export . ";\n");
echo "Wrote remote_access into config.php\n";
' "$CFG"
install -m 440 "$SCRIPT_DIR/alpine-be-sudoers-wg" "$SUDOERS_DST"
visudo -cf "$SUDOERS_DST"
echo "Installed $SUDOERS_DST"
rc-service php-fpm81 restart 2>/dev/null || rc-service php-fpm restart 2>/dev/null || true
echo "Restarted PHP-FPM"

View File

@@ -0,0 +1,44 @@
#!/bin/bash
# Raspberry Pi router (f0xx.org): WAN UDP 45340 → FE (10.7.0.10).
# Invoked at boot or from iodine_warmup_helper; also: router_wireguard_dnat.sh [start|stop]
set -euo pipefail
FE_IP="${FE_IP:-10.7.0.10}"
WG_PORT="${WG_PORT:-45340}"
WAN_IF="${WAN_IF:-eth0}"
function start() {
if iptables -t nat -C PREROUTING -i "$WAN_IF" -p udp --dport "$WG_PORT" -j DNAT --to-destination "${FE_IP}:${WG_PORT}" 2>/dev/null; then
echo "Router DNAT already present for udp/${WG_PORT}"
else
iptables -t nat -A PREROUTING -i "$WAN_IF" -p udp --dport "$WG_PORT" -j DNAT --to-destination "${FE_IP}:${WG_PORT}"
echo "Added router PREROUTING udp/${WG_PORT} -> ${FE_IP}:${WG_PORT}"
fi
if iptables -C FORWARD -p udp -d "${FE_IP}" --dport "$WG_PORT" -j ACCEPT 2>/dev/null; then
echo "Router FORWARD rule already present"
else
iptables -A FORWARD -p udp -d "${FE_IP}" --dport "$WG_PORT" -j ACCEPT
echo "Added router FORWARD udp -> ${FE_IP}:${WG_PORT}"
fi
}
function stop() {
if iptables -t nat -C PREROUTING -i "$WAN_IF" -p udp --dport "$WG_PORT" -j DNAT --to-destination "${FE_IP}:${WG_PORT}" 2>/dev/null; then
iptables -t nat -D PREROUTING -i "$WAN_IF" -p udp --dport "$WG_PORT" -j DNAT --to-destination "${FE_IP}:${WG_PORT}"
echo "Removed router PREROUTING DNAT udp/${WG_PORT}"
else
echo "Router DNAT rule not present for udp/${WG_PORT}"
fi
if iptables -C FORWARD -p udp -d "${FE_IP}" --dport "$WG_PORT" -j ACCEPT 2>/dev/null; then
iptables -D FORWARD -p udp -d "${FE_IP}" --dport "$WG_PORT" -j ACCEPT
echo "Removed router FORWARD udp -> ${FE_IP}:${WG_PORT}"
else
echo "Router FORWARD rule not present"
fi
}
cmd=$1
cmd=${cmd:-start}
[ "$cmd" = "start" ] && start || stop

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
require_once __DIR__ . '/../../src/RemoteAccessRepository.php';
header('Content-Type: application/json; charset=utf-8');
Auth::check();
if (!Rbac::can('remote_access_view')) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$action = trim((string)($_GET['action'] ?? ''));
if ($method === 'GET') {
if ($action === 'devices') {
json_out(['ok' => true, 'devices' => RemoteAccessRepository::listDevices()]);
}
if ($action === 'sessions') {
$filter = trim((string)($_GET['status'] ?? ''));
json_out(['ok' => true, 'sessions' => RemoteAccessRepository::listSessions($filter)]);
}
if ($action === 'events') {
json_out(['ok' => true, 'events' => RemoteAccessRepository::listEvents((int)($_GET['limit'] ?? 100))]);
}
if ($action === 'dashboard') {
$payload = RemoteAccessRepository::buildDashboardPayload(
rtrim(Auth::basePath(), '/'),
'/app/androidcast_project'
);
json_out([
'ok' => true,
'devices' => $payload['devices'],
'active_sessions' => $payload['active_sessions'],
'inactive_sessions' => $payload['inactive_sessions'],
'recent_events' => $payload['recent_events'],
'permissions' => [
'can_operate' => Rbac::can('remote_access_operate'),
'can_admin' => Rbac::can('remote_access_admin'),
],
'config' => [
'wg_endpoint' => (string) cfg('remote_access.wg_endpoint', ''),
],
]);
}
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
}
if ($method !== 'POST') {
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
}
$raw = file_get_contents('php://input') ?: '';
$body = json_decode($raw, true);
if (!is_array($body)) {
json_out(['ok' => false, 'error' => 'invalid_json'], 400);
}
$user = Auth::user();
$userId = (int)($user['id'] ?? 0);
if ($action === 'whitelist') {
if (!Rbac::can('remote_access_admin')) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$deviceId = trim((string)($body['device_id'] ?? ''));
if ($deviceId === '') {
json_out(['ok' => false, 'error' => 'missing_device_id'], 400);
}
if (!RemoteAccessRepository::canAccessDevice($deviceId)) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
if (!RemoteAccessRepository::setDeviceWhitelist($deviceId, !empty($body['whitelisted']), $body['notes'] ?? null)) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
RemoteAccessRepository::logEvent($deviceId, null, $userId, 'whitelist_update', null, [
'whitelisted' => !empty($body['whitelisted']),
], '');
json_out(['ok' => true]);
}
if ($action === 'open_session') {
if (!Rbac::can('remote_access_operate')) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$deviceId = trim((string)($body['device_id'] ?? ''));
if ($deviceId === '') {
json_out(['ok' => false, 'error' => 'missing_device_id'], 400);
}
if (!RemoteAccessRepository::canAccessDevice($deviceId)) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$result = RemoteAccessRepository::openSession($deviceId, $userId);
if (empty($result['ok'])) {
$err = $result['error'] ?? 'failed';
json_out(['ok' => false, 'error' => $err], $err === 'forbidden' ? 403 : 400);
}
json_out(['ok' => true, 'session_id' => $result['session_id'] ?? '']);
}
if ($action === 'close_session') {
if (!Rbac::can('remote_access_operate')) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$sessionId = trim((string)($body['session_id'] ?? ''));
if ($sessionId === '') {
json_out(['ok' => false, 'error' => 'missing_session_id'], 400);
}
if (!RemoteAccessRepository::canOperatorCloseSession($sessionId, $userId)) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
RemoteAccessRepository::closeSession($sessionId, RemoteAccessRepository::STATUS_CLOSED, 'operator_closed');
RemoteAccessRepository::logEvent((string)($body['device_id'] ?? ''), $sessionId, $userId, 'session_close', 'operator', null, '');
json_out(['ok' => true]);
}
json_out(['ok' => false, 'error' => 'unknown_action'], 400);

2
public/index.php Normal file
View File

@@ -0,0 +1,2 @@
<?php
// API entry — wire nginx to public/

View File

@@ -0,0 +1,56 @@
-- Remote access control plane (WireGuard v1, reverse SSH reserved).
-- MariaDB / production: mysql -u root -p androidcast_crashes < sql/migrations/007_remote_access.sql
CREATE TABLE IF NOT EXISTS remote_access_devices (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
device_id VARCHAR(128) NOT NULL,
company_id INT UNSIGNED NOT NULL DEFAULT 1,
whitelisted TINYINT(1) NOT NULL DEFAULT 0,
opt_in_mode ENUM('none','wireguard','rssh') NOT NULL DEFAULT 'none',
last_random VARCHAR(96) NULL,
app_version VARCHAR(64) NULL,
last_seen_at TIMESTAMP NULL,
notes TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_ra_device (device_id),
KEY idx_ra_device_company (company_id),
KEY idx_ra_device_whitelist (whitelisted, opt_in_mode)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS remote_access_sessions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(36) NOT NULL,
device_id VARCHAR(128) NOT NULL,
company_id INT UNSIGNED NOT NULL DEFAULT 1,
operator_user_id INT UNSIGNED NULL,
status ENUM('pending','active','inactive','closed','closed_timeout') NOT NULL DEFAULT 'pending',
tunnel ENUM('wireguard','ssh_reverse') NOT NULL DEFAULT 'wireguard',
endpoint VARCHAR(255) NULL,
wg_client_private_key VARCHAR(64) NULL,
wg_client_address VARCHAR(64) NULL,
wg_server_public_key VARCHAR(64) NULL,
wg_peer_allowed_ips VARCHAR(255) NULL,
expires_at INT UNSIGNED NULL,
last_activity_at TIMESTAMP NULL,
close_reason VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
closed_at TIMESTAMP NULL,
UNIQUE KEY uq_ra_session (session_id),
KEY idx_ra_sess_device (device_id, status),
KEY idx_ra_sess_status (status, last_activity_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS remote_access_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(36) NULL,
device_id VARCHAR(128) NOT NULL,
actor_user_id INT UNSIGNED NULL,
action VARCHAR(64) NOT NULL,
reason VARCHAR(255) NULL,
meta_json LONGTEXT NULL,
client_ip VARCHAR(64) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_ra_evt_device (device_id, created_at),
KEY idx_ra_evt_session (session_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,50 @@
-- SQLite dev mirror for remote access tables.
CREATE TABLE IF NOT EXISTS remote_access_devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL UNIQUE,
company_id INTEGER NOT NULL DEFAULT 1,
whitelisted INTEGER NOT NULL DEFAULT 0,
opt_in_mode TEXT NOT NULL DEFAULT 'none',
last_random TEXT NULL,
app_version TEXT NULL,
last_seen_at TEXT NULL,
notes TEXT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS remote_access_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
device_id TEXT NOT NULL,
company_id INTEGER NOT NULL DEFAULT 1,
operator_user_id INTEGER NULL,
status TEXT NOT NULL DEFAULT 'pending',
tunnel TEXT NOT NULL DEFAULT 'wireguard',
endpoint TEXT NULL,
wg_client_private_key TEXT NULL,
wg_client_address TEXT NULL,
wg_server_public_key TEXT NULL,
wg_peer_allowed_ips TEXT NULL,
expires_at INTEGER NULL,
last_activity_at TEXT NULL,
close_reason TEXT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
closed_at TEXT NULL
);
CREATE TABLE IF NOT EXISTS remote_access_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NULL,
device_id TEXT NOT NULL,
actor_user_id INTEGER NULL,
action TEXT NOT NULL,
reason TEXT NULL,
meta_json TEXT NULL,
client_ip TEXT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_ra_evt_device ON remote_access_events(device_id, created_at);
CREATE INDEX IF NOT EXISTS idx_ra_sess_device ON remote_access_sessions(device_id, status);

View File

@@ -0,0 +1,8 @@
-- RSSH session credentials (MariaDB). Idempotent ALTERs.
ALTER TABLE remote_access_sessions
ADD COLUMN IF NOT EXISTS rssh_bastion_host VARCHAR(255) NULL,
ADD COLUMN IF NOT EXISTS rssh_bastion_port INT UNSIGNED NULL,
ADD COLUMN IF NOT EXISTS rssh_username VARCHAR(64) NULL,
ADD COLUMN IF NOT EXISTS rssh_secret VARCHAR(128) NULL,
ADD COLUMN IF NOT EXISTS rssh_remote_port INT UNSIGNED NULL,
ADD COLUMN IF NOT EXISTS rssh_local_port INT UNSIGNED NULL DEFAULT 8022;

View File

@@ -0,0 +1 @@
-- RSSH columns for SQLite (applied via Database::ensureSchema).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/** Ephemeral Linux users on the RSSH bastion (sshd Match User ra-*). */
final class RsshBastionProvisioner {
private function __construct() {
}
public static function isEnabled(): bool {
return (bool) cfg('remote_access.rssh.provision_users', false);
}
public static function scriptPath(): string {
$configured = trim((string) cfg('remote_access.rssh.provision_script', ''));
if ($configured !== '') {
return $configured;
}
return dirname(__DIR__) . '/scripts/rssh_bastion_user.sh';
}
/** @return array{ok:bool,error?:string} */
public static function addUser(string $username, string $password): array {
$user = self::normalizeUsername($username);
if ($user === '') {
return ['ok' => 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;
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/** Ephemeral reverse-SSH session credentials (alpha). */
final class RsshSessionProvisioner {
private function __construct() {
}
/** @return array{bastion_host:string,bastion_port:int,username:string,password:string,remote_bind_port:int,local_forward_port:int} */
public static function allocate(string $sessionId): array {
$host = (string) cfg('remote_access.rssh.bastion_host', 'ra.apps.f0xx.org');
$port = max(1, min(65535, (int) cfg('remote_access.rssh.bastion_port', 443)));
$prefix = (string) cfg('remote_access.rssh.username_prefix', 'ra-');
$username = $prefix . preg_replace('/[^a-zA-Z0-9_-]/', '', $sessionId);
if (strlen($username) > 32) {
$username = substr($username, 0, 32);
}
$password = bin2hex(random_bytes(16));
$remotePort = self::allocateRemotePort($sessionId);
$localPort = max(1, min(65535, (int) cfg('remote_access.rssh.local_forward_port', 8022)));
return [
'bastion_host' => $host,
'bastion_port' => $port,
'username' => $username,
'password' => $password,
'remote_bind_port' => $remotePort,
'local_forward_port' => $localPort,
];
}
public static function allocateRemotePort(string $sessionId): int {
$min = (int) cfg('remote_access.rssh.remote_port_min', 18000);
$max = (int) cfg('remote_access.rssh.remote_port_max', 18999);
if ($max <= $min) {
$max = $min + 999;
}
$span = $max - $min + 1;
return $min + (abs(crc32($sessionId . '|' . time())) % $span);
}
/** @param array<string, mixed> $session */
public static function endpointFromSession(array $session): string {
$host = (string) ($session['rssh_bastion_host'] ?? cfg('remote_access.rssh.bastion_host', 'ra.apps.f0xx.org'));
$port = (int) ($session['rssh_bastion_port'] ?? cfg('remote_access.rssh.bastion_port', 443));
if ($port === 443) {
return $host;
}
return $host . ':' . $port;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/** WG client IPs + server AllowedIPs from config (matches live wg0 on BE). */
final class WireGuardAddressPool {
private function __construct() {
}
public static function defaultEndpoint(): string {
return (string) cfg('remote_access.wg_endpoint', 'ra.apps.f0xx.org:45340');
}
public static function serverAllowedIps(): string {
return (string) cfg('remote_access.wg_server_allowed_ips', '172.200.2.1/32');
}
public static function allocateClientAddress(string $sessionId): string {
$prefix = (string) cfg('remote_access.wg_client_ip_prefix', '172.200.2.');
$min = max(2, (int) cfg('remote_access.wg_client_ip_min', 10));
$max = max($min, (int) cfg('remote_access.wg_client_ip_max', 250));
$span = $max - $min + 1;
$host = $min + (abs(crc32($sessionId)) % $span);
return $prefix . $host . '/32';
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
/** Alpine BE WireGuard peer lifecycle (wg set) for remote-access sessions. */
final class WireGuardPeerProvisioner {
private function __construct() {}
public static function isEnabled(): bool {
return (bool) cfg('remote_access.provision_peers', true);
}
public static function interfaceName(): string {
return (string) cfg('remote_access.wg_interface', 'wg0');
}
public static function publicKeyFromPrivate(string $privateKey): string {
$priv = trim($privateKey);
if ($priv === '') {
return '';
}
$pub = trim((string) @shell_exec('echo ' . escapeshellarg($priv) . ' | wg pubkey 2>/dev/null') ?: '');
return $pub;
}
public static function addClientPeer(string $clientPublicKey, string $clientAddressCidr): bool {
if (!self::isEnabled()) {
return true;
}
$pub = trim($clientPublicKey);
if ($pub === '' || str_starts_with($pub, 'dev-placeholder-pub-')) {
return false;
}
$iface = self::interfaceName();
$allowed = trim($clientAddressCidr);
if ($allowed === '') {
$allowed = WireGuardAddressPool::allocateClientAddress('default');
}
$code = self::runWg(
'set ' . escapeshellarg($iface)
. ' peer ' . escapeshellarg($pub)
. ' allowed-ips ' . escapeshellarg($allowed)
);
return $code === 0;
}
public static function removeClientPeer(?string $clientPublicKey): void {
if (!self::isEnabled()) {
return;
}
$pub = trim((string) $clientPublicKey);
if ($pub === '' || str_starts_with($pub, 'dev-placeholder-pub-')) {
return;
}
$iface = self::interfaceName();
self::runWg(
'set ' . escapeshellarg($iface)
. ' peer ' . escapeshellarg($pub)
. ' remove'
);
}
/** @return list<array{public_key: string, allowed_ips: string}> */
public static function listPeerDumpRows(): array {
if (!self::isEnabled()) {
return [];
}
$iface = self::interfaceName();
$prefix = rtrim((string) cfg('remote_access.wg_set_prefix', ''), " \t");
$cmd = ($prefix !== '' ? $prefix . ' ' : '') . 'wg show ' . escapeshellarg($iface) . ' dump 2>/dev/null';
$raw = @shell_exec($cmd);
if (!is_string($raw) || trim($raw) === '') {
return [];
}
$rows = [];
foreach (preg_split('/\r\n|\n|\r/', trim($raw)) as $line) {
if ($line === '' || str_starts_with($line, 'interface:')) {
continue;
}
$cols = explode("\t", $line);
if (count($cols) < 4) {
continue;
}
$pub = trim($cols[0]);
if ($pub === '') {
continue;
}
$rows[] = [
'public_key' => $pub,
'allowed_ips' => trim((string) ($cols[3] ?? '')),
];
}
return $rows;
}
/**
* Remove live wg peers not tied to pending/active sessions (or with empty AllowedIPs).
*
* @param list<string> $keepPublicKeys
* @return list<array{public_key: string, allowed_ips: string, reason: string}>
*/
public static function pruneOrphanPeers(array $keepPublicKeys, bool $dryRun = false): array {
if (!self::isEnabled()) {
return [];
}
$keep = [];
foreach ($keepPublicKeys as $key) {
$pub = trim((string) $key);
if ($pub !== '') {
$keep[$pub] = true;
}
}
$pruned = [];
foreach (self::listPeerDumpRows() as $row) {
$pub = $row['public_key'];
$allowed = trim($row['allowed_ips']);
$broken = ($allowed === '' || $allowed === '(none)');
$orphan = !isset($keep[$pub]);
if (!$orphan && !$broken) {
continue;
}
if (!$dryRun) {
self::removeClientPeer($pub);
}
$pruned[] = [
'public_key' => $pub,
'allowed_ips' => $allowed,
'reason' => $broken ? 'broken_allowed_ips' : 'orphan',
];
}
return $pruned;
}
private static function runWg(string $args): int {
$prefix = rtrim((string) cfg('remote_access.wg_set_prefix', ''), " \t");
$cmd = ($prefix !== '' ? $prefix . ' ' : '') . 'wg ' . $args;
exec($cmd . ' 2>&1', $out, $code);
if ($code !== 0) {
error_log('WireGuardPeerProvisioner: ' . $cmd . ' failed: ' . implode(' ', $out));
}
return $code;
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/** Live WireGuard transfer counters from `wg show dump` (BE data plane). */
final class WireGuardTrafficStats {
private function __construct() {}
/** @return array<string, array{rx_bytes: int, tx_bytes: int, latest_handshake: int, endpoint: string}> */
public static function peerMap(): array {
static $cache = null;
static $cacheAt = 0;
if ($cache !== null && (time() - $cacheAt) < 5) {
return $cache;
}
$cache = self::loadPeerMap();
$cacheAt = time();
return $cache;
}
/** @return array{rx_bytes: int, tx_bytes: int, latest_handshake: int, endpoint: string}|null */
public static function forPublicKey(string $publicKey): ?array {
$pub = trim($publicKey);
if ($pub === '') {
return null;
}
return self::peerMap()[$pub] ?? null;
}
/** @return array<string, array{rx_bytes: int, tx_bytes: int, latest_handshake: int, endpoint: string}> */
private static function loadPeerMap(): array {
$iface = (string) cfg('remote_access.wg_interface', 'wg0');
$prefix = rtrim((string) cfg('remote_access.wg_set_prefix', ''), " \t");
$cmd = ($prefix !== '' ? $prefix . ' ' : '') . 'wg show ' . escapeshellarg($iface) . ' dump 2>/dev/null';
$raw = @shell_exec($cmd);
if (!is_string($raw) || trim($raw) === '') {
return [];
}
$out = [];
foreach (preg_split('/\r\n|\n|\r/', trim($raw)) as $line) {
if ($line === '' || str_starts_with($line, 'interface:')) {
continue;
}
$cols = explode("\t", $line);
if (count($cols) < 7) {
continue;
}
$pub = trim($cols[0]);
if ($pub === '') {
continue;
}
$out[$pub] = [
'rx_bytes' => (int) ($cols[5] ?? 0),
'tx_bytes' => (int) ($cols[6] ?? 0),
'latest_handshake' => (int) ($cols[4] ?? 0),
'endpoint' => trim((string) ($cols[2] ?? '')),
];
}
return $out;
}
public static function formatBytes(int $bytes): string {
if ($bytes < 1024) {
return $bytes . ' B';
}
if ($bytes < 1024 * 1024) {
return round($bytes / 1024, 1) . ' KiB';
}
if ($bytes < 1024 * 1024 * 1024) {
return round($bytes / (1024 * 1024), 2) . ' MiB';
}
return round($bytes / (1024 * 1024 * 1024), 2) . ' GiB';
}
}