mirror of
git://f0xx.org/ac/ac-ms-remote-access
synced 2026-07-29 02:17:46 +03:00
initial
This commit is contained in:
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