mirror of
git://f0xx.org/ac/ac-ms-remote-access
synced 2026-07-29 00:58:12 +03:00
initial
This commit is contained in:
3
README.md
Normal file
3
README.md
Normal 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
25
composer.json
Normal 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/"
|
||||
]
|
||||
}
|
||||
}
|
||||
2
config/config.example.php
Normal file
2
config/config.example.php
Normal file
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
return ['db' => ['driver' => 'sqlite']];
|
||||
42
examples/rssh/linux-sim/README.md
Normal file
42
examples/rssh/linux-sim/README.md
Normal 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).
|
||||
75
examples/rssh/linux-sim/ra_device_sim.sh
Executable file
75
examples/rssh/linux-sim/ra_device_sim.sh
Executable 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"
|
||||
25
examples/rssh/linux-sim/ra_operator_connect.sh
Executable file
25
examples/rssh/linux-sim/ra_operator_connect.sh
Executable 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
|
||||
9
examples/wireguard/BE_alpine/README.txt
Normal file
9
examples/wireguard/BE_alpine/README.txt
Normal 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
|
||||
1
examples/wireguard/BE_alpine/server.privatekey
Normal file
1
examples/wireguard/BE_alpine/server.privatekey
Normal file
@@ -0,0 +1 @@
|
||||
uIhZdcs0U6W09PVytrMD7qChzzvAgBAnZUI47V/axX4=
|
||||
1
examples/wireguard/BE_alpine/server.publickey
Normal file
1
examples/wireguard/BE_alpine/server.publickey
Normal file
@@ -0,0 +1 @@
|
||||
EV2nnwHH7xe3jA5J46Rjtl8ao+ybZinibKg2Hy83GBw=
|
||||
12
examples/wireguard/BE_alpine/wg0.conf
Normal file
12
examples/wireguard/BE_alpine/wg0.conf
Normal 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
|
||||
43
examples/wireguard/FE_wireguard_dnat.sh
Executable file
43
examples/wireguard/FE_wireguard_dnat.sh
Executable 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
|
||||
8
examples/wireguard/alpine-be-sudoers-wg
Normal file
8
examples/wireguard/alpine-be-sudoers-wg
Normal 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
|
||||
47
examples/wireguard/install_be_remote_access.sh
Executable file
47
examples/wireguard/install_be_remote_access.sh
Executable 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"
|
||||
44
examples/wireguard/router_wireguard_dnat.sh
Normal file
44
examples/wireguard/router_wireguard_dnat.sh
Normal 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
|
||||
119
public/api/remote_access.php
Normal file
119
public/api/remote_access.php
Normal 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
2
public/index.php
Normal file
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
// API entry — wire nginx to public/
|
||||
56
sql/migrations/007_remote_access.sql
Normal file
56
sql/migrations/007_remote_access.sql
Normal 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;
|
||||
50
sql/migrations/007_remote_access.sqlite.sql
Normal file
50
sql/migrations/007_remote_access.sqlite.sql
Normal 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);
|
||||
8
sql/migrations/009_rssh_sessions.sql
Normal file
8
sql/migrations/009_rssh_sessions.sql
Normal 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;
|
||||
1
sql/migrations/009_rssh_sessions.sqlite.sql
Normal file
1
sql/migrations/009_rssh_sessions.sqlite.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- RSSH columns for SQLite (applied via Database::ensureSchema).
|
||||
1423
src/RemoteAccessRepository.php
Normal file
1423
src/RemoteAccessRepository.php
Normal file
File diff suppressed because it is too large
Load Diff
89
src/RsshBastionProvisioner.php
Normal file
89
src/RsshBastionProvisioner.php
Normal 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;
|
||||
}
|
||||
}
|
||||
50
src/RsshSessionProvisioner.php
Normal file
50
src/RsshSessionProvisioner.php
Normal 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;
|
||||
}
|
||||
}
|
||||
25
src/WireGuardAddressPool.php
Normal file
25
src/WireGuardAddressPool.php
Normal 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';
|
||||
}
|
||||
}
|
||||
142
src/WireGuardPeerProvisioner.php
Normal file
142
src/WireGuardPeerProvisioner.php
Normal 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;
|
||||
}
|
||||
}
|
||||
73
src/WireGuardTrafficStats.php
Normal file
73
src/WireGuardTrafficStats.php
Normal 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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user