commit 59fb5013c3ee5398dfe486547e8d9d06ab954075 Author: Anton Afanasyeu Date: Tue Jun 23 12:29:33 2026 +0200 initial diff --git a/README.md b/README.md new file mode 100644 index 0000000..c10a716 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ac-ms-remote-access + +Microservice API. Remote: `git://f0xx.org/ac/ac-ms-remote-access` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..c25f335 --- /dev/null +++ b/composer.json @@ -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/" + ] + } +} diff --git a/config/config.example.php b/config/config.example.php new file mode 100644 index 0000000..9a59c16 --- /dev/null +++ b/config/config.example.php @@ -0,0 +1,2 @@ + ['driver' => 'sqlite']]; 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/examples/wireguard/BE_alpine/README.txt b/examples/wireguard/BE_alpine/README.txt new file mode 100644 index 0000000..7749939 --- /dev/null +++ b/examples/wireguard/BE_alpine/README.txt @@ -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 diff --git a/examples/wireguard/BE_alpine/server.privatekey b/examples/wireguard/BE_alpine/server.privatekey new file mode 100644 index 0000000..e3020b6 --- /dev/null +++ b/examples/wireguard/BE_alpine/server.privatekey @@ -0,0 +1 @@ +uIhZdcs0U6W09PVytrMD7qChzzvAgBAnZUI47V/axX4= diff --git a/examples/wireguard/BE_alpine/server.publickey b/examples/wireguard/BE_alpine/server.publickey new file mode 100644 index 0000000..37bcda4 --- /dev/null +++ b/examples/wireguard/BE_alpine/server.publickey @@ -0,0 +1 @@ +EV2nnwHH7xe3jA5J46Rjtl8ao+ybZinibKg2Hy83GBw= diff --git a/examples/wireguard/BE_alpine/wg0.conf b/examples/wireguard/BE_alpine/wg0.conf new file mode 100644 index 0000000..2c87b01 --- /dev/null +++ b/examples/wireguard/BE_alpine/wg0.conf @@ -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 = +# AllowedIPs = 172.200.2.2/32 diff --git a/examples/wireguard/FE_wireguard_dnat.sh b/examples/wireguard/FE_wireguard_dnat.sh new file mode 100755 index 0000000..28b75a9 --- /dev/null +++ b/examples/wireguard/FE_wireguard_dnat.sh @@ -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 diff --git a/examples/wireguard/alpine-be-sudoers-wg b/examples/wireguard/alpine-be-sudoers-wg new file mode 100644 index 0000000..70989e5 --- /dev/null +++ b/examples/wireguard/alpine-be-sudoers-wg @@ -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 diff --git a/examples/wireguard/install_be_remote_access.sh b/examples/wireguard/install_be_remote_access.sh new file mode 100755 index 0000000..2d55f98 --- /dev/null +++ b/examples/wireguard/install_be_remote_access.sh @@ -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, "/dev/null || rc-service php-fpm restart 2>/dev/null || true +echo "Restarted PHP-FPM" diff --git a/examples/wireguard/router_wireguard_dnat.sh b/examples/wireguard/router_wireguard_dnat.sh new file mode 100644 index 0000000..c6c99ae --- /dev/null +++ b/examples/wireguard/router_wireguard_dnat.sh @@ -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 diff --git a/public/api/remote_access.php b/public/api/remote_access.php new file mode 100644 index 0000000..fe96592 --- /dev/null +++ b/public/api/remote_access.php @@ -0,0 +1,119 @@ + 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); diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..922b7c2 --- /dev/null +++ b/public/index.php @@ -0,0 +1,2 @@ +exec('ALTER TABLE ' . $table . ' ADD COLUMN ' . $column . ' ' . $ddl); + } + + private static function ensureLastClientIpColumn(PDO $pdo): void { + $col = Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL'; + self::addColumnIfMissing($pdo, 'remote_access_devices', 'last_client_ip', $col); + } + + private static function ensureDeviceMetaColumn(PDO $pdo): void { + $col = Database::isMysql() ? 'LONGTEXT NULL' : 'TEXT NULL'; + self::addColumnIfMissing($pdo, 'remote_access_devices', 'device_meta_json', $col); + } + + /** @param list $where @param list $params */ + private static function appendCompanyScopeSql(string $column, array &$where, array &$params): void { + Rbac::appendCompanyScope($column, $where, $params); + } + + public static function canAccessDevice(string $deviceId): bool { + $device = self::findDevice($deviceId); + if ($device === null) { + return Rbac::isGlobalAdmin(); + } + + return Rbac::canAccessCompany((int) ($device['company_id'] ?? 0)); + } + + public static function canOperatorCloseSession(string $sessionId, int $operatorUserId): bool { + $session = self::findSession($sessionId); + if ($session === null) { + return false; + } + $companyId = (int) ($session['company_id'] ?? 0); + if (!Rbac::canAccessCompany($companyId)) { + return false; + } + if (Rbac::isGlobalAdmin() || Rbac::can('remote_access_admin', $companyId)) { + return true; + } + + return $operatorUserId > 0 + && (int) ($session['operator_user_id'] ?? 0) === $operatorUserId + && Rbac::can('remote_access_operate', $companyId); + } + + /** @return array|null */ + public static function findSession(string $sessionId): ?array { + self::ensureSchema(); + $st = Database::pdo()->prepare('SELECT * FROM remote_access_sessions WHERE session_id = ? LIMIT 1'); + $st->execute([$sessionId]); + $row = $st->fetch(PDO::FETCH_ASSOC); + + return $row ?: null; + } + + private static function ensureRsshColumns(PDO $pdo): void { + if (!Database::tableExists($pdo, 'remote_access_sessions')) { + return; + } + $cols = [ + 'rssh_bastion_host' => Database::isMysql() ? 'VARCHAR(255) NULL' : 'TEXT NULL', + 'rssh_bastion_port' => Database::isMysql() ? 'INT UNSIGNED NULL' : 'INTEGER NULL', + 'rssh_username' => Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL', + 'rssh_secret' => Database::isMysql() ? 'VARCHAR(128) NULL' : 'TEXT NULL', + 'rssh_remote_port' => Database::isMysql() ? 'INT UNSIGNED NULL' : 'INTEGER NULL', + 'rssh_local_port' => Database::isMysql() ? 'INT UNSIGNED NULL' : 'INTEGER NULL', + ]; + foreach ($cols as $name => $ddl) { + self::addColumnIfMissing($pdo, 'remote_access_sessions', $name, $ddl); + } + } + + private static function ensureSessionColumns(PDO $pdo): void { + $col = Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL'; + self::addColumnIfMissing($pdo, 'remote_access_sessions', 'wg_client_public_key', $col); + } + + private static function createTables(PDO $pdo): void { + $path = Database::isMysql() + ? __DIR__ . '/../sql/migrations/007_remote_access.sql' + : __DIR__ . '/../sql/migrations/007_remote_access.sqlite.sql'; + if (!is_readable($path)) { + throw new RuntimeException('remote access migration missing: ' . $path); + } + $sql = (string) file_get_contents($path); + foreach (array_filter(array_map('trim', explode(';', $sql))) as $stmt) { + if ($stmt !== '') { + $pdo->exec($stmt); + } + } + } + + /** @return array heartbeat.ra response payload */ + public static function handleDeviceHeartbeat(array $heartbeat, string $clientIp): array { + self::ensureSchema(); + $deviceId = trim((string) ($heartbeat['device_id'] ?? '')); + if ($deviceId === '') { + return self::raEnvelope('deny', ['reason' => 'missing_device_id']); + } + + $status = strtolower(trim((string) ($heartbeat['status'] ?? 'enable'))); + $random = trim((string) ($heartbeat['random'] ?? '')); + $appVersion = trim((string) ($heartbeat['app_version'] ?? '')); + $capabilities = $heartbeat['capabilities'] ?? []; + if (!is_array($capabilities)) { + $capabilities = []; + } + $tunnelMode = self::inferTunnelMode($capabilities, $heartbeat); + + if ($status === 'disable') { + return self::handleDisable($deviceId, $random, $clientIp, $appVersion); + } + + $existing = self::findDevice($deviceId); + $pendingSession = null; + if ($existing !== null && self::isRateLimited($existing)) { + $pendingSession = self::findConnectableSession($deviceId, $random); + if ($pendingSession === null) { + return self::raEnvelope('wait', ['reason' => 'rate_limited']); + } + } + + $deviceMeta = is_array($heartbeat['device'] ?? null) ? $heartbeat['device'] : []; + $routeScope = trim((string) ($heartbeat['vpn_route_scope'] ?? '')); + $appScope = trim((string) ($heartbeat['vpn_app_scope'] ?? '')); + if ($routeScope !== '') { + $deviceMeta['vpn_route_scope'] = $routeScope; + } + if ($appScope !== '') { + $deviceMeta['vpn_app_scope'] = $appScope; + } + + self::upsertDevicePoll($deviceId, $random, $appVersion, $tunnelMode, $clientIp, $deviceMeta); + + $device = self::findDevice($deviceId); + if (!$device || !(int) ($device['whitelisted'] ?? 0)) { + self::logEvent($deviceId, null, null, 'poll_wait', 'not_whitelisted', [ + 'opt_in' => $tunnelMode, + ], $clientIp); + return self::raEnvelope('wait', ['reason' => 'not_whitelisted']); + } + + if ($tunnelMode === self::OPT_NONE) { + return self::raEnvelope('wait', ['reason' => 'opt_in_none']); + } + + if ($tunnelMode === self::OPT_RSSH) { + $session = $pendingSession ?? self::findConnectableSession($deviceId, $random); + if ($session === null) { + self::logEvent($deviceId, null, null, 'poll_wait', 'no_pending_session', null, $clientIp); + return self::raEnvelope('wait', ['reason' => 'no_pending_session']); + } + self::touchSessionActivity((string) ($session['session_id'] ?? '')); + return self::buildConnectResponse($session, $deviceId, $clientIp); + } + + $session = $pendingSession ?? self::findConnectableSession($deviceId, $random); + if ($session === null) { + self::logEvent($deviceId, null, null, 'poll_wait', 'no_pending_session', null, $clientIp); + return self::raEnvelope('wait', ['reason' => 'no_pending_session']); + } + + self::touchSessionActivity((string) ($session['session_id'] ?? '')); + + return self::buildConnectResponse($session, $deviceId, $clientIp); + } + + /** @param array $device */ + private static function isRateLimited(array $device): bool { + $min = max(15, (int) cfg('remote_access.min_poll_interval_s', 45)); + $last = (string) ($device['last_seen_at'] ?? ''); + if ($last === '') { + return false; + } + $ts = strtotime($last); + if ($ts === false) { + return false; + } + return (time() - $ts) < $min; + } + + private static function touchSessionActivity(string $sessionId): void { + if ($sessionId === '') { + return; + } + Database::pdo()->prepare( + 'UPDATE remote_access_sessions SET last_activity_at = ? WHERE session_id = ?' + )->execute([self::nowSql(), $sessionId]); + } + + /** @param list $capabilities */ + private static function inferTunnelMode(array $capabilities, array $heartbeat): string { + foreach ($capabilities as $cap) { + $c = strtolower(trim((string) $cap)); + if ($c === 'wireguard' || $c === 'wg') { + return self::OPT_WIREGUARD; + } + if ($c === 'ssh_reverse' || $c === 'rssh') { + return self::OPT_RSSH; + } + } + $mode = strtolower(trim((string) ($heartbeat['tunnel_mode'] ?? ''))); + if (in_array($mode, [self::OPT_WIREGUARD, self::OPT_RSSH, self::OPT_NONE], true)) { + return $mode; + } + return self::OPT_WIREGUARD; + } + + private static function handleDisable(string $deviceId, string $random, string $clientIp, string $appVersion): array { + $pdo = Database::pdo(); + $now = self::nowSql(); + $pdo->prepare( + 'UPDATE remote_access_devices SET whitelisted = 0, opt_in_mode = ?, last_random = ?, app_version = ?, last_seen_at = ?, updated_at = ? WHERE device_id = ?' + )->execute([self::OPT_NONE, $random, $appVersion, $now, $now, $deviceId]); + + self::closeActiveSessionsForDevice($deviceId, 'user_disabled'); + self::logEvent($deviceId, null, null, 'user_disable', 'device_opt_out', null, $clientIp); + self::logEvent($deviceId, null, null, 'whitelist_revoked', 'device_opt_out', null, $clientIp); + + return self::raEnvelope('disabled', ['reason' => 'user_opt_out']); + } + + private static function upsertDevicePoll( + string $deviceId, + string $random, + string $appVersion, + string $tunnelMode, + string $clientIp, + array $deviceMeta = [] + ): void { + $pdo = Database::pdo(); + $now = self::nowSql(); + $metaJson = self::encodeDeviceMeta($deviceMeta); + $existing = self::findDevice($deviceId); + if ($existing) { + if ($metaJson !== null) { + $pdo->prepare( + 'UPDATE remote_access_devices SET opt_in_mode = ?, last_random = ?, app_version = ?, + device_meta_json = ?, last_client_ip = ?, last_seen_at = ?, updated_at = ? WHERE device_id = ?' + )->execute([$tunnelMode, $random, $appVersion, $metaJson, $clientIp, $now, $now, $deviceId]); + } else { + $pdo->prepare( + 'UPDATE remote_access_devices SET opt_in_mode = ?, last_random = ?, app_version = ?, + last_client_ip = ?, last_seen_at = ?, updated_at = ? WHERE device_id = ?' + )->execute([$tunnelMode, $random, $appVersion, $clientIp, $now, $now, $deviceId]); + } + return; + } + $pdo->prepare( + 'INSERT INTO remote_access_devices (device_id, company_id, whitelisted, opt_in_mode, last_random, + app_version, device_meta_json, last_client_ip, last_seen_at, created_at, updated_at) + VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)' + )->execute([ + $deviceId, + Rbac::defaultCompanyId(), + $tunnelMode, + $random, + $appVersion, + $metaJson, + $clientIp !== '' ? $clientIp : null, + $now, + $now, + $now, + ]); + self::logEvent($deviceId, null, null, 'device_seen', 'first_poll', ['opt_in' => $tunnelMode], $clientIp); + } + + /** @param array $deviceMeta */ + private static function encodeDeviceMeta(array $deviceMeta): ?string { + if ($deviceMeta === []) { + return null; + } + $allowed = ['name', 'manufacturer', 'brand', 'model', 'device', 'product', 'sdk_int', 'release', 'abis', 'lan_ip', 'wan_ip']; + $clean = []; + foreach ($allowed as $key) { + if (!array_key_exists($key, $deviceMeta)) { + continue; + } + $val = $deviceMeta[$key]; + if ($key === 'sdk_int') { + $clean[$key] = (int) $val; + } elseif ($key === 'abis' && is_array($val)) { + $clean[$key] = array_values(array_filter(array_map('strval', $val))); + } elseif (is_scalar($val)) { + $s = trim((string) $val); + if ($s !== '') { + $clean[$key] = $s; + } + } + } + if ($clean === []) { + return null; + } + $json = json_encode($clean, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + return $json === false ? null : $json; + } + + /** @return array|null */ + private static function findDevice(string $deviceId): ?array { + $st = Database::pdo()->prepare('SELECT * FROM remote_access_devices WHERE device_id = ? LIMIT 1'); + $st->execute([$deviceId]); + $row = $st->fetch(PDO::FETCH_ASSOC); + return is_array($row) ? $row : null; + } + + /** @return array|null */ + private static function findConnectableSession(string $deviceId, string $random): ?array { + $pdo = Database::pdo(); + $st = $pdo->prepare( + "SELECT * FROM remote_access_sessions + WHERE device_id = ? AND status IN ('pending','active') + ORDER BY id DESC LIMIT 5" + ); + $st->execute([$deviceId]); + $rows = $st->fetchAll(PDO::FETCH_ASSOC); + if (!is_array($rows)) { + return null; + } + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $exp = (int) ($row['expires_at'] ?? 0); + if ($exp > 0 && $exp < time()) { + self::closeSession((string) $row['session_id'], self::STATUS_CLOSED_TIMEOUT, 'expired'); + continue; + } + return $row; + } + return null; + } + + /** @param array $session */ + private static function buildConnectResponse(array $session, string $deviceId, string $clientIp): array { + $tunnel = (string) ($session['tunnel'] ?? 'wireguard'); + if ($tunnel === 'ssh_reverse') { + return self::buildRsshConnectResponse($session, $deviceId, $clientIp); + } + return self::buildWireGuardConnectResponse($session, $deviceId, $clientIp); + } + + /** @param array $session */ + private static function buildWireGuardConnectResponse(array $session, string $deviceId, string $clientIp): array { + $sessionId = (string) ($session['session_id'] ?? ''); + $tunnel = (string) ($session['tunnel'] ?? 'wireguard'); + if ($tunnel !== 'wireguard') { + return self::raEnvelope('deny', ['reason' => 'tunnel_not_supported', 'tunnel' => $tunnel]); + } + + $keys = []; + try { + $keys = self::ensureSessionWireGuardKeys($sessionId, $session); + } catch (Throwable $e) { + error_log('RemoteAccess connect keys: ' . $e->getMessage()); + return self::raEnvelope('deny', ['reason' => 'key_generation_failed']); + } + if (str_starts_with($keys['client_public'] ?? '', 'dev-placeholder-pub-') + && (bool) cfg('remote_access.require_wg_tools', false)) { + return self::raEnvelope('deny', ['reason' => 'wg_tools_required']); + } + $endpoint = (string) ($session['endpoint'] ?? WireGuardAddressPool::defaultEndpoint()); + $expiresAt = (int) ($session['expires_at'] ?? (time() + 3600)); + + self::markSessionActive($sessionId); + self::logEvent($deviceId, $sessionId, null, 'tunnel_connect', 'wireguard', null, $clientIp); + + return self::raEnvelope('connect', [ + 'session_id' => $sessionId, + 'endpoint' => $endpoint, + 'tunnel' => 'wireguard', + 'credentials' => [ + 'mode' => 'ephemeral_pubkey', + 'interface_private_key' => $keys['client_private'], + 'interface_address' => $keys['client_address'], + 'peer_public_key' => $keys['server_public'], + 'peer_endpoint' => $endpoint, + 'peer_allowed_ips' => $keys['allowed_ips'], + ], + 'expires_at' => $expiresAt, + ]); + } + + /** @param array $session */ + private static function buildRsshConnectResponse(array $session, string $deviceId, string $clientIp): array { + $sessionId = (string) ($session['session_id'] ?? ''); + $creds = self::ensureSessionRsshCredentials($sessionId, $session); + $expiresAt = (int) ($session['expires_at'] ?? (time() + 3600)); + $endpoint = RsshSessionProvisioner::endpointFromSession($session); + + self::markSessionActive($sessionId); + self::logEvent($deviceId, $sessionId, null, 'tunnel_connect', 'ssh_reverse', null, $clientIp); + + return self::raEnvelope('connect', [ + 'session_id' => $sessionId, + 'endpoint' => $endpoint, + 'tunnel' => 'ssh_reverse', + 'credentials' => [ + 'mode' => 'password', + 'bastion_host' => $creds['bastion_host'], + 'bastion_port' => $creds['bastion_port'], + 'username' => $creds['username'], + 'password' => $creds['password'], + 'remote_bind_port' => $creds['remote_bind_port'], + 'local_forward_host' => '127.0.0.1', + 'local_forward_port' => $creds['local_forward_port'], + ], + 'expires_at' => $expiresAt, + ]); + } + + /** @param array $session @return array{bastion_host:string,bastion_port:int,username:string,password:string,remote_bind_port:int,local_forward_port:int} */ + private static function ensureSessionRsshCredentials(string $sessionId, array $session): array { + $user = trim((string) ($session['rssh_username'] ?? '')); + $secret = trim((string) ($session['rssh_secret'] ?? '')); + $remote = (int) ($session['rssh_remote_port'] ?? 0); + $local = (int) ($session['rssh_local_port'] ?? 0); + $host = trim((string) ($session['rssh_bastion_host'] ?? '')); + $port = (int) ($session['rssh_bastion_port'] ?? 0); + if ($user !== '' && $secret !== '' && $remote > 0) { + return [ + 'bastion_host' => $host !== '' ? $host : (string) cfg('remote_access.rssh.bastion_host', 'ra.apps.f0xx.org'), + 'bastion_port' => $port > 0 ? $port : (int) cfg('remote_access.rssh.bastion_port', 443), + 'username' => $user, + 'password' => $secret, + 'remote_bind_port' => $remote, + 'local_forward_port' => $local > 0 ? $local : (int) cfg('remote_access.rssh.local_forward_port', 8022), + ]; + } + $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([ + $alloc['bastion_host'], + $alloc['bastion_port'], + $alloc['username'], + $alloc['password'], + $alloc['remote_bind_port'], + $alloc['local_forward_port'], + $sessionId, + ]); + return $alloc; + } + + /** @param array $session @return array{client_private:string,client_address:string,server_public:string,allowed_ips:string,client_public:string} */ + private static function ensureSessionWireGuardKeys(string $sessionId, array $session): array { + $priv = trim((string) ($session['wg_client_private_key'] ?? '')); + $pub = trim((string) ($session['wg_client_public_key'] ?? '')); + $addr = trim((string) ($session['wg_client_address'] ?? '')); + $serverPub = trim((string) ($session['wg_server_public_key'] ?? '')); + $allowed = trim((string) ($session['wg_peer_allowed_ips'] ?? '')); + if ($priv !== '' && $addr !== '' && $serverPub !== '') { + if ($pub === '') { + $pub = WireGuardPeerProvisioner::publicKeyFromPrivate($priv); + } + if (!WireGuardPeerProvisioner::addClientPeer($pub, $addr)) { + error_log('RemoteAccess: wg peer re-apply failed for session ' . $sessionId); + } + return [ + 'client_private' => $priv, + 'client_public' => $pub, + 'client_address' => $addr, + 'server_public' => $serverPub, + 'allowed_ips' => $allowed !== '' ? $allowed : WireGuardAddressPool::serverAllowedIps(), + ]; + } + $pair = self::generateWireGuardKeyPair(); + $priv = $pair['private']; + $pub = $pair['public']; + if ($pub === '' || str_starts_with($pub, 'dev-placeholder-pub-')) { + $pub = WireGuardPeerProvisioner::publicKeyFromPrivate($priv); + } + $clientIp = WireGuardAddressPool::allocateClientAddress($sessionId); + $serverPub = (string) cfg('remote_access.wg_server_public_key', ''); + if ($serverPub === '') { + $serverPair = self::generateWireGuardKeyPair(); + $serverPub = $serverPair['public']; + } + $allowed = WireGuardAddressPool::serverAllowedIps(); + Database::pdo()->prepare( + 'UPDATE remote_access_sessions SET wg_client_private_key = ?, wg_client_public_key = ?, wg_client_address = ?, wg_server_public_key = ?, wg_peer_allowed_ips = ? WHERE session_id = ?' + )->execute([$priv, $pub, $clientIp, $serverPub, $allowed, $sessionId]); + + if (!WireGuardPeerProvisioner::addClientPeer($pub, $clientIp)) { + error_log('RemoteAccess: wg peer add failed for session ' . $sessionId); + } + + return [ + 'client_private' => $priv, + 'client_public' => $pub, + 'client_address' => $clientIp, + 'server_public' => $serverPub, + 'allowed_ips' => $allowed, + ]; + } + + /** @return array{private:string,public:string} */ + public static function generateWireGuardKeyPair(): array { + $priv = trim((string) @shell_exec('wg genkey 2>/dev/null') ?: ''); + if ($priv !== '') { + $pub = trim((string) @shell_exec('echo ' . escapeshellarg($priv) . ' | wg pubkey 2>/dev/null') ?: ''); + if ($pub !== '') { + return ['private' => $priv, 'public' => $pub]; + } + } + if ((bool) cfg('remote_access.require_wg_tools', false)) { + throw new RuntimeException('wireguard-tools unavailable (wg genkey)'); + } + $raw = random_bytes(32); + $priv = rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + return ['private' => $priv, 'public' => 'dev-placeholder-pub-' . substr(hash('sha256', $priv), 0, 43)]; + } + + private static function markSessionActive(string $sessionId): void { + $now = self::nowSql(); + Database::pdo()->prepare( + "UPDATE remote_access_sessions SET status = 'active', last_activity_at = ? WHERE session_id = ?" + )->execute([$now, $sessionId]); + } + + public static function closeActiveSessionsForDevice(string $deviceId, string $reason): void { + $st = Database::pdo()->prepare( + "SELECT session_id FROM remote_access_sessions WHERE device_id = ? AND status IN ('pending','active')" + ); + $st->execute([$deviceId]); + foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $sid) { + self::closeSession((string) $sid, self::STATUS_CLOSED, $reason); + } + } + + public static function closeSession(string $sessionId, string $status, string $reason): void { + $st = Database::pdo()->prepare( + '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)) { + 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(); + Database::pdo()->prepare( + 'UPDATE remote_access_sessions SET status = ?, close_reason = ?, closed_at = ?, last_activity_at = ? WHERE session_id = ?' + )->execute([$status, $reason, $now, $now, $sessionId]); + } + + /** @return array{ok:bool,session_id?:string} */ + public static function openSession(string $deviceId, int $operatorUserId, ?int $companyId = null): array { + self::ensureSchema(); + $device = self::findDevice($deviceId); + if (!$device || !(int) ($device['whitelisted'] ?? 0)) { + return ['ok' => false, 'error' => 'device_not_whitelisted']; + } + $deviceCompanyId = (int) ($device['company_id'] ?? 0); + if (!Rbac::canAccessCompany($deviceCompanyId) || !Rbac::can('remote_access_operate', $deviceCompanyId)) { + return ['ok' => false, 'error' => 'forbidden']; + } + if ((string) ($device['opt_in_mode'] ?? self::OPT_NONE) === self::OPT_NONE) { + return ['ok' => false, 'error' => 'device_not_opted_in']; + } + $optIn = (string) ($device['opt_in_mode'] ?? self::OPT_NONE); + if (!in_array($optIn, [self::OPT_WIREGUARD, self::OPT_RSSH], true)) { + return ['ok' => false, 'error' => 'device_not_opted_in']; + } + self::closeActiveSessionsForDevice($deviceId, 'superseded'); + $sessionId = self::uuid4(); + $companyId = $companyId ?? (int) ($device['company_id'] ?? Rbac::defaultCompanyId()); + $tunnel = $optIn === self::OPT_RSSH ? 'ssh_reverse' : 'wireguard'; + if ($tunnel === 'ssh_reverse') { + $host = (string) cfg('remote_access.rssh.bastion_host', 'ra.apps.f0xx.org'); + $port = (int) cfg('remote_access.rssh.bastion_port', 443); + $endpoint = $port === 443 ? $host : $host . ':' . $port; + } else { + $endpoint = WireGuardAddressPool::defaultEndpoint(); + } + $expiresAt = time() + (int) cfg('remote_access.session_ttl_s', 3600); + $now = self::nowSql(); + Database::pdo()->prepare( + 'INSERT INTO remote_access_sessions (session_id, device_id, company_id, operator_user_id, status, tunnel, endpoint, expires_at, last_activity_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + )->execute([ + $sessionId, + $deviceId, + $companyId, + $operatorUserId, + self::STATUS_PENDING, + $tunnel, + $endpoint, + $expiresAt, + $now, + $now, + ]); + self::logEvent($deviceId, $sessionId, $operatorUserId, 'session_open', 'operator_requested', null, ''); + return ['ok' => true, 'session_id' => $sessionId]; + } + + /** @return list> */ + public static function listDevices(?int $companyId = null): array { + self::ensureSchema(); + $sql = 'SELECT * FROM remote_access_devices'; + $params = []; + if (!Rbac::isGlobalAdmin()) { + $allowed = Rbac::allowedCompanyIds(); + if ($allowed === []) { + return []; + } + if ($allowed !== null) { + $placeholders = implode(',', array_fill(0, count($allowed), '?')); + $sql .= ' WHERE company_id IN (' . $placeholders . ')'; + $params = $allowed; + } + } elseif ($companyId !== null) { + $sql .= ' WHERE company_id = ?'; + $params[] = $companyId; + } + $sql .= Database::isMysql() + ? ' ORDER BY (last_seen_at IS NULL), last_seen_at DESC, device_id ASC' + : ' ORDER BY datetime(COALESCE(last_seen_at, \'1970-01-01\')) DESC, device_id ASC'; + $st = Database::pdo()->prepare($sql); + $st->execute($params); + return $st->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + /** @return list> */ + public static function listSessions(string $statusFilter = ''): array { + self::ensureSchema(); + $sql = 'SELECT s.*, d.opt_in_mode, d.app_version AS device_app_version FROM remote_access_sessions s + LEFT JOIN remote_access_devices d ON d.device_id = s.device_id'; + $where = []; + $params = []; + if ($statusFilter !== '') { + $where[] = 's.status = ?'; + $params[] = $statusFilter; + } + self::appendCompanyScopeSql('s.company_id', $where, $params); + if ($where !== []) { + $sql .= ' WHERE ' . implode(' AND ', $where); + } + $sql .= ' ORDER BY s.id DESC LIMIT 200'; + $st = Database::pdo()->prepare($sql); + $st->execute($params); + return $st->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + /** @return list> */ + public static function listEvents(int $limit = 100): array { + self::ensureSchema(); + $sql = 'SELECT e.* FROM remote_access_events e'; + $where = []; + $params = []; + if (!Rbac::isGlobalAdmin()) { + $allowed = Rbac::allowedCompanyIds(); + if ($allowed === []) { + return []; + } + if ($allowed !== null) { + $sql .= ' INNER JOIN remote_access_devices d ON d.device_id = e.device_id'; + self::appendCompanyScopeSql('d.company_id', $where, $params); + } + } + if ($where !== []) { + $sql .= ' WHERE ' . implode(' AND ', $where); + } + $sql .= ' ORDER BY e.id DESC LIMIT ?'; + $params[] = max(1, min(500, $limit)); + $st = Database::pdo()->prepare($sql); + foreach ($params as $i => $v) { + $st->bindValue($i + 1, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR); + } + $st->execute(); + return $st->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + public static function setDeviceWhitelist(string $deviceId, bool $whitelisted, ?string $notes = null): bool { + self::ensureSchema(); + $device = self::findDevice($deviceId); + if (!$device) { + $companyId = Rbac::activeCompanyId() ?? Rbac::defaultCompanyId(); + if (!Rbac::can('remote_access_admin', $companyId)) { + return false; + } + Database::pdo()->prepare( + 'INSERT INTO remote_access_devices (device_id, company_id, whitelisted, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)' + )->execute([ + $deviceId, + $companyId, + $whitelisted ? 1 : 0, + $notes, + self::nowSql(), + self::nowSql(), + ]); + return true; + } + $companyId = (int) ($device['company_id'] ?? 0); + if (!Rbac::canAccessCompany($companyId) || !Rbac::can('remote_access_admin', $companyId)) { + return false; + } + Database::pdo()->prepare( + 'UPDATE remote_access_devices SET whitelisted = ?, notes = COALESCE(?, notes), updated_at = ? WHERE device_id = ?' + )->execute([$whitelisted ? 1 : 0, $notes, self::nowSql(), $deviceId]); + return true; + } + + /** @return list */ + public static function activeWireGuardSessionPublicKeys(): array { + $peers = self::activeWireGuardSessionPeers(); + $keys = []; + foreach ($peers as $row) { + $pub = trim((string) ($row['public_key'] ?? '')); + if ($pub !== '') { + $keys[] = $pub; + } + } + return $keys; + } + + /** + * Pending/active WG sessions with client pubkey + tunnel address (for wg set reconcile). + * + * @return list + */ + public static function activeWireGuardSessionPeers(): array { + self::ensureSchema(); + $st = Database::pdo()->query( + "SELECT session_id, wg_client_public_key, wg_client_address FROM remote_access_sessions + WHERE status IN ('pending','active') AND tunnel = 'wireguard' + AND wg_client_public_key IS NOT NULL AND wg_client_public_key != '' + AND wg_client_address IS NOT NULL AND wg_client_address != ''" + ); + if ($st === false) { + return []; + } + $rows = []; + foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) { + if (!is_array($row)) { + continue; + } + $pub = trim((string) ($row['wg_client_public_key'] ?? '')); + $addr = trim((string) ($row['wg_client_address'] ?? '')); + if ($pub === '' || $addr === '') { + continue; + } + $rows[] = [ + 'session_id' => (string) ($row['session_id'] ?? ''), + 'public_key' => $pub, + 'client_address' => $addr, + ]; + } + return $rows; + } + + /** + * Ensure wg0 has allowed-ips for every pending/active WG session (idempotent). + * + * @return list + */ + public static function reconcileActiveWireGuardPeers(bool $dryRun = false): array { + $results = []; + foreach (self::activeWireGuardSessionPeers() as $row) { + $ok = true; + if (!$dryRun) { + $ok = WireGuardPeerProvisioner::addClientPeer( + $row['public_key'], + $row['client_address'] + ); + } + $results[] = [ + 'session_id' => $row['session_id'], + 'public_key' => $row['public_key'], + 'client_address' => $row['client_address'], + 'ok' => $ok, + ]; + } + return $results; + } + + /** + * Drop wg0 peers that are not referenced by pending/active sessions. + * + * @return list + */ + public static function pruneOrphanWireGuardPeers(bool $dryRun = false): array { + return WireGuardPeerProvisioner::pruneOrphanPeers( + self::activeWireGuardSessionPublicKeys(), + $dryRun + ); + } + + public static function purgeStale(int $maxAgeHours = 24): int { + self::ensureSchema(); + $cutoff = time() - max(1, $maxAgeHours) * 3600; + $pdo = Database::pdo(); + if (Database::isMysql()) { + $st = $pdo->prepare( + "SELECT session_id FROM remote_access_sessions WHERE status IN ('inactive','closed','closed_timeout','pending','active') + AND ((expires_at IS NOT NULL AND expires_at < ?) OR (last_activity_at IS NOT NULL AND UNIX_TIMESTAMP(last_activity_at) < ?))" + ); + $st->execute([time(), $cutoff]); + } else { + $cutoffIso = gmdate('Y-m-d H:i:s', $cutoff); + $st = $pdo->prepare( + "SELECT session_id FROM remote_access_sessions WHERE status IN ('inactive','closed','closed_timeout','pending','active') + AND ((expires_at IS NOT NULL AND expires_at < ?) OR (last_activity_at IS NOT NULL AND last_activity_at < ?))" + ); + $st->execute([time(), $cutoffIso]); + } + $n = 0; + foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $sid) { + self::closeSession((string) $sid, self::STATUS_CLOSED_TIMEOUT, 'purge_stale'); + $n++; + } + return $n; + } + + public static function logEvent( + string $deviceId, + ?string $sessionId, + ?int $actorUserId, + string $action, + ?string $reason, + ?array $meta, + string $clientIp + ): void { + self::ensureSchema(); + Database::pdo()->prepare( + 'INSERT INTO remote_access_events (session_id, device_id, actor_user_id, action, reason, meta_json, client_ip, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + )->execute([ + $sessionId, + $deviceId, + $actorUserId, + $action, + $reason, + $meta !== null ? json_encode($meta, JSON_UNESCAPED_UNICODE) : null, + $clientIp !== '' ? $clientIp : null, + self::nowSql(), + ]); + } + + /** @param array $extra */ + private static function raEnvelope(string $action, array $extra = []): array { + return array_merge([ + 'type' => 'ra', + 'action' => $action, + 'server_date' => time(), + ], $extra); + } + + private static function nowSql(): string { + return Database::isMysql() ? date('Y-m-d H:i:s') : gmdate('Y-m-d H:i:s'); + } + + /** @return array{devices: list>, active_sessions: list>, inactive_sessions: list>, recent_events: list>} */ + public static function buildDashboardPayload(string $issuesBase, string $projectBase): array { + self::ensureSchema(); + $sessions = self::listSessions(''); + $active = array_values(array_filter( + $sessions, + static fn($s) => in_array($s['status'] ?? '', ['pending', 'active'], true) + )); + $inactive = array_values(array_filter( + $sessions, + static fn($s) => !in_array($s['status'] ?? '', ['pending', 'active'], true) + )); + + return [ + 'devices' => self::listDevicesForDashboard($issuesBase, $projectBase), + '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(); + if ($devices === []) { + return []; + } + $deviceIds = array_values(array_filter(array_map( + static fn($row) => (string) ($row['device_id'] ?? ''), + $devices + ))); + $sessionRows = self::batchActiveSessionRows($deviceIds); + $wgPeers = WireGuardTrafficStats::peerMap(); + $out = []; + foreach ($devices as $row) { + $deviceId = (string) ($row['device_id'] ?? ''); + $out[] = self::enrichDeviceRow( + $row, + $issuesBase, + $projectBase, + $sessionRows[$deviceId] ?? null, + $wgPeers + ); + } + return $out; + } + + /** @param list $deviceIds @return array> */ + private static function batchActiveSessionRows(array $deviceIds): array { + $deviceIds = array_values(array_unique(array_filter(array_map('strval', $deviceIds)))); + if ($deviceIds === []) { + return []; + } + $placeholders = implode(',', array_fill(0, count($deviceIds), '?')); + $st = Database::pdo()->prepare( + "SELECT device_id, tunnel, wg_client_address, wg_client_public_key + FROM remote_access_sessions + WHERE status IN ('pending', 'active') AND device_id IN ($placeholders) + ORDER BY id DESC" + ); + $st->execute($deviceIds); + $map = []; + foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) { + $did = (string) ($row['device_id'] ?? ''); + if ($did !== '' && !isset($map[$did])) { + $map[$did] = $row; + } + } + return $map; + } + + /** @param array $row @param array|null $sessionRow @param array|null $wgPeers */ + private static function enrichDeviceRow( + array $row, + string $issuesBase, + string $projectBase, + ?array $sessionRow = null, + ?array $wgPeers = null + ): array { + $deviceId = (string) ($row['device_id'] ?? ''); + $companyId = (int) ($row['company_id'] ?? 1); + $storedMeta = self::decodeDeviceMeta($row['device_meta_json'] ?? null); + $ctx = self::lookupDeviceContextFromMeta($deviceId, $companyId, $storedMeta); + $row['needs_whitelist'] = self::deviceNeedsWhitelist($row); + $row['status_label'] = self::deviceStatusLabel($row); + $row['device_name'] = $ctx['device_name'] ?? ''; + $row['device_display'] = $ctx['device_display'] ?? ''; + $row['manufacturer'] = $ctx['manufacturer'] ?? ''; + $row['brand'] = $ctx['brand'] ?? ''; + $row['model'] = $ctx['model'] ?? ''; + $row['product'] = $ctx['product'] ?? ''; + $row['hardware_device'] = $ctx['hardware_device'] ?? ''; + $row['os_release'] = $ctx['os_release'] ?? ''; + $row['sdk_int'] = $ctx['sdk_int'] ?? null; + $row['abis'] = $ctx['abis'] ?? []; + $row['lan_ip'] = self::resolveDeviceLanIp($storedMeta); + $row['poll_source_ip'] = self::resolvePollSourceIp($row); + $row['poll_source_is_private'] = self::isPrivateIp($row['poll_source_ip']); + $row['device_wan_ip'] = trim((string) ($storedMeta['wan_ip'] ?? '')); + $row['vpn_route_scope'] = trim((string) ($storedMeta['vpn_route_scope'] ?? '')); + $row['vpn_app_scope'] = trim((string) ($storedMeta['vpn_app_scope'] ?? '')); + $net = self::sessionNetworkFromRow($sessionRow, $wgPeers); + $row['vpn_ip'] = $net['vpn_ip'] ?? ''; + $row['vpn_public_key'] = $net['vpn_public_key'] ?? ''; + $row['wg_rx_bytes'] = $net['wg_rx_bytes'] ?? 0; + $row['wg_tx_bytes'] = $net['wg_tx_bytes'] ?? 0; + $row['wg_latest_handshake'] = $net['wg_latest_handshake'] ?? 0; + $row['wg_endpoint'] = $net['wg_endpoint'] ?? ''; + $row['issue_count'] = $ctx['issue_count'] ?? 0; + $row['graph_session_count'] = $ctx['graph_session_count'] ?? 0; + $row['latest_issue_id'] = $ctx['latest_issue_id'] ?? null; + $row['links'] = self::deviceLinks($deviceId, $issuesBase, $projectBase, $ctx); + if (($row['app_version'] ?? '') === '' && ($ctx['app_version'] ?? '') !== '') { + $row['app_version'] = $ctx['app_version']; + } + return $row; + } + + /** @return array */ + private static function decodeDeviceMeta(mixed $raw): array { + if (!is_string($raw) || trim($raw) === '') { + return []; + } + $decoded = json_decode($raw, true); + + return is_array($decoded) ? $decoded : []; + } + + /** @param array $row */ + private static function deviceNeedsWhitelist(array $row): bool { + if ((int) ($row['whitelisted'] ?? 0) === 1) { + return false; + } + $mode = (string) ($row['opt_in_mode'] ?? 'none'); + if ($mode === self::OPT_NONE) { + return false; + } + return self::isRecentTimestamp($row['last_seen_at'] ?? null, 7 * 86400); + } + + /** @param array $row */ + private static function deviceStatusLabel(array $row): string { + if ((int) ($row['whitelisted'] ?? 0) === 1) { + return 'whitelisted'; + } + if (self::deviceNeedsWhitelist($row)) { + return 'needs_whitelist'; + } + $mode = (string) ($row['opt_in_mode'] ?? 'none'); + if ($mode === self::OPT_NONE) { + return 'not_opted_in'; + } + if (!self::isRecentTimestamp($row['last_seen_at'] ?? null, 7 * 86400)) { + return 'stale'; + } + return 'polling'; + } + + private static function isRecentTimestamp(?string $ts, int $maxAgeSec): bool { + if ($ts === null || trim($ts) === '') { + return false; + } + $t = strtotime($ts); + if ($t === false) { + return false; + } + return (time() - $t) <= $maxAgeSec; + } + + /** @param array $row */ + private static function resolvePollSourceIp(array $row): string { + $fromRow = trim((string) ($row['last_client_ip'] ?? '')); + if ($fromRow !== '') { + return self::normalizeClientIp($fromRow); + } + $deviceId = (string) ($row['device_id'] ?? ''); + if ($deviceId === '') { + return ''; + } + $st = Database::pdo()->prepare( + "SELECT client_ip FROM remote_access_events + WHERE device_id = ? AND client_ip IS NOT NULL AND client_ip != '' + ORDER BY id DESC LIMIT 1" + ); + $st->execute([$deviceId]); + $ip = trim((string) ($st->fetchColumn() ?: '')); + + return self::normalizeClientIp($ip); + } + + private static function isPrivateIp(string $ip): bool { + if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return false; + } + if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return true; + } + + return false; + } + + /** @param array $storedMeta */ + private static function resolveDeviceLanIp(array $storedMeta): string { + return trim((string) ($storedMeta['lan_ip'] ?? '')); + } + + private static function normalizeClientIp(string $raw): string { + $raw = trim($raw); + if ($raw === '') { + return ''; + } + if (str_contains($raw, ',')) { + $raw = trim(explode(',', $raw)[0]); + } + + return $raw; + } + + /** @param array|null $sessionRow @param array|null $wgPeers @return array{vpn_ip: string, vpn_public_key: string, wg_rx_bytes: int, wg_tx_bytes: int, wg_latest_handshake: int, wg_endpoint: string} */ + private static function sessionNetworkFromRow(?array $sessionRow, ?array $wgPeers): array { + $empty = [ + 'vpn_ip' => '', + 'vpn_public_key' => '', + 'wg_rx_bytes' => 0, + 'wg_tx_bytes' => 0, + 'wg_latest_handshake' => 0, + 'wg_endpoint' => '', + ]; + if (!is_array($sessionRow)) { + return $empty; + } + $tunnel = (string) ($sessionRow['tunnel'] ?? ''); + if ($tunnel !== '' && $tunnel !== 'wireguard') { + return $empty; + } + $addr = trim((string) ($sessionRow['wg_client_address'] ?? '')); + $vpnIp = $addr !== '' ? preg_replace('/\/\d+$/', '', $addr) ?? $addr : ''; + $pub = trim((string) ($sessionRow['wg_client_public_key'] ?? '')); + $stats = ($wgPeers !== null && $pub !== '') ? ($wgPeers[$pub] ?? null) : null; + if ($stats === null && $pub !== '') { + $stats = WireGuardTrafficStats::forPublicKey($pub); + } + + return [ + 'vpn_ip' => is_string($vpnIp) ? trim($vpnIp) : '', + 'vpn_public_key' => $pub, + 'wg_rx_bytes' => (int) ($stats['rx_bytes'] ?? 0), + 'wg_tx_bytes' => (int) ($stats['tx_bytes'] ?? 0), + 'wg_latest_handshake' => (int) ($stats['latest_handshake'] ?? 0), + 'wg_endpoint' => (string) ($stats['endpoint'] ?? ''), + ]; + } + + /** Fast dashboard enrichment from heartbeat meta (no reports/graph table scans). */ + private static function lookupDeviceContextFromMeta( + string $deviceId, + int $companyId, + array $storedMeta = [] + ): array { + if ($deviceId === '') { + return []; + } + unset($companyId); + $deviceName = trim((string) ($storedMeta['name'] ?? '')); + $manufacturer = trim((string) ($storedMeta['manufacturer'] ?? '')); + $brand = trim((string) ($storedMeta['brand'] ?? '')); + $model = trim((string) ($storedMeta['model'] ?? '')); + $hardwareDevice = trim((string) ($storedMeta['device'] ?? '')); + $product = trim((string) ($storedMeta['product'] ?? '')); + $osRelease = trim((string) ($storedMeta['release'] ?? '')); + $sdkInt = isset($storedMeta['sdk_int']) ? (int) $storedMeta['sdk_int'] : null; + $abis = is_array($storedMeta['abis'] ?? null) ? $storedMeta['abis'] : []; + $appVersion = trim((string) ($storedMeta['app_version'] ?? '')); + $deviceDisplay = $deviceName !== '' + ? $deviceName + : trim($manufacturer . ' ' . $model); + + return [ + 'issue_count' => 0, + 'latest_issue_id' => null, + 'graph_session_count' => 0, + 'device_name' => $deviceName, + 'device_display' => $deviceDisplay, + 'manufacturer' => $manufacturer, + 'brand' => $brand, + 'model' => $model, + 'hardware_device' => $hardwareDevice, + 'product' => $product, + 'os_release' => $osRelease, + 'sdk_int' => $sdkInt, + 'abis' => $abis, + 'app_version' => $appVersion, + ]; + } + + /** @return array{vpn_ip: string, vpn_public_key: string, wg_rx_bytes: int, wg_tx_bytes: int, wg_latest_handshake: int, wg_endpoint: string} */ + private static function lookupDeviceSessionNetwork(string $deviceId): array { + if ($deviceId === '') { + return [ + 'vpn_ip' => '', + 'vpn_public_key' => '', + 'wg_rx_bytes' => 0, + 'wg_tx_bytes' => 0, + 'wg_latest_handshake' => 0, + 'wg_endpoint' => '', + ]; + } + $st = Database::pdo()->prepare( + "SELECT tunnel, wg_client_address, wg_client_public_key + FROM remote_access_sessions + WHERE device_id = ? AND status IN ('pending', 'active') + ORDER BY id DESC LIMIT 1" + ); + $st->execute([$deviceId]); + $row = $st->fetch(PDO::FETCH_ASSOC); + + return self::sessionNetworkFromRow(is_array($row) ? $row : null, null); + } + + /** @param array $storedMeta @return array */ + private static function lookupDeviceContext(string $deviceId, int $companyId, array $storedMeta = []): array { + if ($deviceId === '') { + return []; + } + $pdo = Database::pdo(); + $deviceName = trim((string) ($storedMeta['name'] ?? '')); + $manufacturer = trim((string) ($storedMeta['manufacturer'] ?? '')); + $brand = trim((string) ($storedMeta['brand'] ?? '')); + $model = trim((string) ($storedMeta['model'] ?? '')); + $hardwareDevice = trim((string) ($storedMeta['device'] ?? '')); + $product = trim((string) ($storedMeta['product'] ?? '')); + $osRelease = trim((string) ($storedMeta['release'] ?? '')); + $sdkInt = isset($storedMeta['sdk_int']) ? (int) $storedMeta['sdk_int'] : null; + $abis = is_array($storedMeta['abis'] ?? null) ? $storedMeta['abis'] : []; + $appVersion = ''; + + $issueCount = 0; + $latestIssueId = null; + $graphCount = 0; + + if (Database::tableExists($pdo, 'graph_sessions')) { + $gSt = $pdo->prepare( + 'SELECT app_version, sdk_int FROM graph_sessions WHERE device_id = ? ORDER BY started_at_ms DESC LIMIT 1' + ); + $gSt->execute([$deviceId]); + $gRow = $gSt->fetch(PDO::FETCH_ASSOC); + if (is_array($gRow)) { + if ($appVersion === '') { + $appVersion = trim((string) ($gRow['app_version'] ?? '')); + } + if ($sdkInt === null && isset($gRow['sdk_int'])) { + $sdkInt = (int) $gRow['sdk_int']; + } + } + $cntSt = $pdo->prepare('SELECT COUNT(*) FROM graph_sessions WHERE device_id = ?'); + $cntSt->execute([$deviceId]); + $graphCount = (int) $cntSt->fetchColumn(); + } + + if (Database::tableExists($pdo, 'reports')) { + $reportsDeviceId = self::resolveDeviceDbId($deviceId, $companyId); + if ($reportsDeviceId !== null) { + $cntSt = $pdo->prepare( + 'SELECT COUNT(*) FROM reports r WHERE r.company_id = ? AND r.device_id = ?' + ); + $cntSt->execute([$companyId, $reportsDeviceId]); + $issueCount = (int) $cntSt->fetchColumn(); + + $latestSt = $pdo->prepare( + 'SELECT r.id, r.device_model, r.app_version, r.payload_json + FROM reports r + WHERE r.company_id = ? AND r.device_id = ? + ORDER BY r.received_at_ms DESC LIMIT 1' + ); + $latestSt->execute([$companyId, $reportsDeviceId]); + $latest = $latestSt->fetch(PDO::FETCH_ASSOC); + } else { + // Avoid payload_json LIKE scans (full table) on unregistered devices. + $issueCount = 0; + $latest = null; + } + if (is_array($latest)) { + $latestIssueId = (int) ($latest['id'] ?? 0) ?: null; + if ($appVersion === '') { + $appVersion = trim((string) ($latest['app_version'] ?? '')); + } + $payload = json_decode((string) ($latest['payload_json'] ?? '{}'), true); + if (is_array($payload)) { + $device = is_array($payload['device'] ?? null) ? $payload['device'] : []; + if ($manufacturer === '') { + $manufacturer = trim((string) ($device['manufacturer'] ?? '')); + } + if ($brand === '') { + $brand = trim((string) ($device['brand'] ?? '')); + } + if ($model === '') { + $model = trim((string) ($device['model'] ?? $latest['device_model'] ?? '')); + } + if ($hardwareDevice === '') { + $hardwareDevice = trim((string) ($device['device'] ?? '')); + } + if ($product === '') { + $product = trim((string) ($device['product'] ?? '')); + } + if ($osRelease === '') { + $osRelease = trim((string) ($device['release'] ?? '')); + } + if ($sdkInt === null && isset($device['sdk_int'])) { + $sdkInt = (int) $device['sdk_int']; + } + if ($abis === [] && is_array($device['abis'] ?? null)) { + $abis = $device['abis']; + } + if ($appVersion === '' && is_array($payload['app'] ?? null)) { + $appVersion = trim((string) ($payload['app']['version_name'] ?? '')); + } + } + } + } + + $deviceDisplay = $deviceName !== '' + ? $deviceName + : trim($manufacturer . ' ' . $model); + + return [ + 'issue_count' => $issueCount, + 'latest_issue_id' => $latestIssueId, + 'graph_session_count' => $graphCount, + 'device_name' => $deviceName, + 'device_display' => $deviceDisplay, + 'manufacturer' => $manufacturer, + 'brand' => $brand, + 'model' => $model, + 'hardware_device' => $hardwareDevice, + 'product' => $product, + 'os_release' => $osRelease, + 'sdk_int' => $sdkInt, + 'abis' => $abis, + 'app_version' => $appVersion, + ]; + } + + private static function resolveDeviceDbId(string $deviceId, int $companyId): ?int { + static $cache = []; + $key = $companyId . ':' . $deviceId; + if (array_key_exists($key, $cache)) { + return $cache[$key]; + } + $pdo = Database::pdo(); + if (!Database::tableExists($pdo, 'devices')) { + $cache[$key] = null; + + return null; + } + $st = $pdo->prepare( + 'SELECT id FROM devices WHERE company_id = ? AND external_id = ? LIMIT 1' + ); + $st->execute([$companyId, $deviceId]); + $id = $st->fetchColumn(); + $cache[$key] = ($id !== false && $id !== null && $id !== '') ? (int) $id : null; + + return $cache[$key]; + } + + /** @param array $ctx @return list */ + private static function deviceLinks(string $deviceId, string $issuesBase, string $projectBase, array $ctx): array { + if ($deviceId === '') { + return []; + } + $links = []; + $issueCount = (int) ($ctx['issue_count'] ?? 0); + $issuesLabel = $issueCount > 0 ? 'Issues (' . $issueCount . ')' : 'Issues'; + $links[] = [ + 'label' => $issuesLabel, + 'href' => $issuesBase . '/?view=reports&q=' . rawurlencode($deviceId), + ]; + if (!empty($ctx['latest_issue_id'])) { + $links[] = [ + 'label' => 'Latest issue #' . (int) $ctx['latest_issue_id'], + 'href' => $issuesBase . '/?view=report&id=' . (int) $ctx['latest_issue_id'], + ]; + } + $graphCount = (int) ($ctx['graph_session_count'] ?? 0); + if ($graphCount > 0) { + $links[] = [ + 'label' => 'Graph sessions (' . $graphCount . ')', + 'href' => rtrim($projectBase, '/') . '/graphs/', + ]; + } + return $links; + } + + private static function uuid4(): string { + $b = random_bytes(16); + $b[6] = chr((ord($b[6]) & 0x0f) | 0x40); + $b[8] = chr((ord($b[8]) & 0x3f) | 0x80); + $h = bin2hex($b); + return substr($h, 0, 8) . '-' . substr($h, 8, 4) . '-' . substr($h, 12, 4) + . '-' . substr($h, 16, 4) . '-' . substr($h, 20, 12); + } +} diff --git a/src/RsshBastionProvisioner.php b/src/RsshBastionProvisioner.php new file mode 100644 index 0000000..8f24a59 --- /dev/null +++ b/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/src/RsshSessionProvisioner.php b/src/RsshSessionProvisioner.php new file mode 100644 index 0000000..b097162 --- /dev/null +++ b/src/RsshSessionProvisioner.php @@ -0,0 +1,50 @@ + 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 $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; + } +} diff --git a/src/WireGuardAddressPool.php b/src/WireGuardAddressPool.php new file mode 100644 index 0000000..8abaeca --- /dev/null +++ b/src/WireGuardAddressPool.php @@ -0,0 +1,25 @@ +/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 */ + 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 $keepPublicKeys + * @return list + */ + 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; + } +} diff --git a/src/WireGuardTrafficStats.php b/src/WireGuardTrafficStats.php new file mode 100644 index 0000000..8cf136f --- /dev/null +++ b/src/WireGuardTrafficStats.php @@ -0,0 +1,73 @@ + */ + 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 */ + 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'; + } +}