1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 04:18:09 +03:00

bastion RSSH + other changes

This commit is contained in:
Anton Afanasyeu
2026-06-15 11:56:23 +02:00
parent 22cc1c6a6a
commit 2421c4007b
19 changed files with 549 additions and 53 deletions

View File

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

View File

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

View File

@@ -598,12 +598,23 @@
const closeBtn = canOperate()
? '<button type="button" class="btn btn-sm" data-close-session="' + esc(s.session_id) + '" data-device="' + esc(s.device_id) + '">Close</button>'
: '';
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 =
'<div class="ra-rssh-cmds muted"><code>' +
esc(s.rssh_operator.shell || '') +
'</code></div>';
}
tr.innerHTML =
'<td><code>' + esc(s.session_id) + '</code></td>' +
'<td><code>' + esc(s.device_id) + '</code></td>' +
'<td>' + esc(s.status) + '</td>' +
'<td>' + esc(s.tunnel) + '</td>' +
'<td>' + esc(s.endpoint || '—') + '</td>' +
'<td>' + endpoint + operator + '</td>' +
'<td>' + closeBtn + '</td>';
aBody.appendChild(tr);
});

View File

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

View File

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

View File

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

View File

@@ -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<string, mixed> $row @return array<string, mixed> */
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<array<string, mixed>> */
public static function listDevicesForDashboard(string $issuesBase, string $projectBase): array {
$devices = self::listDevices();

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

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