mirror of
git://f0xx.org/ac/ac-ms-remote-access
synced 2026-07-29 01:39:35 +03:00
t0.f0xx.org is not delegated; iodined on FE serves iod.airie.me. Co-authored-by: Cursor <cursoragent@cursor.com>
1467 lines
61 KiB
PHP
1467 lines
61 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* On-demand remote access control plane (heartbeat type: ra).
|
|
* v1: WireGuard + reverse SSH (RSSH) opt-in from device (lab / alpha).
|
|
*/
|
|
final class RemoteAccessRepository {
|
|
public const OPT_NONE = 'none';
|
|
public const OPT_WIREGUARD = 'wireguard';
|
|
public const OPT_RSSH = 'rssh';
|
|
public const OPT_IODINE = 'iodine';
|
|
|
|
public const STATUS_PENDING = 'pending';
|
|
public const STATUS_ACTIVE = 'active';
|
|
public const STATUS_INACTIVE = 'inactive';
|
|
public const STATUS_CLOSED = 'closed';
|
|
public const STATUS_CLOSED_TIMEOUT = 'closed_timeout';
|
|
|
|
private static bool $schemaReady = false;
|
|
|
|
public static function ensureSchema(): void {
|
|
if (self::$schemaReady) {
|
|
return;
|
|
}
|
|
$pdo = Database::pdo();
|
|
if (!Database::tableExists($pdo, 'remote_access_devices')) {
|
|
self::createTables($pdo);
|
|
self::$schemaReady = true;
|
|
return;
|
|
}
|
|
self::ensureSessionColumns($pdo);
|
|
self::ensureRsshColumns($pdo);
|
|
self::ensureDeviceMetaColumn($pdo);
|
|
self::ensureLastClientIpColumn($pdo);
|
|
self::$schemaReady = true;
|
|
}
|
|
|
|
private static function addColumnIfMissing(PDO $pdo, string $table, string $column, string $ddl): void {
|
|
if (!Database::tableExists($pdo, $table)) {
|
|
return;
|
|
}
|
|
$names = Database::columnNames($pdo, $table);
|
|
if (in_array($column, $names, true)) {
|
|
return;
|
|
}
|
|
$pdo->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<string> $where @param list<mixed> $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<string, mixed>|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<string, mixed> 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<string, mixed> $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<string> $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;
|
|
}
|
|
if ($c === 'iodine') {
|
|
return self::OPT_IODINE;
|
|
}
|
|
}
|
|
$mode = strtolower(trim((string) ($heartbeat['tunnel_mode'] ?? '')));
|
|
if (in_array($mode, [self::OPT_WIREGUARD, self::OPT_RSSH, self::OPT_IODINE, 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<string, mixed> $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<string, mixed>|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<string, mixed>|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<string, mixed> $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);
|
|
}
|
|
if ($tunnel === 'iodine') {
|
|
return self::buildIodineConnectResponse($session, $deviceId, $clientIp);
|
|
}
|
|
return self::buildWireGuardConnectResponse($session, $deviceId, $clientIp);
|
|
}
|
|
|
|
/** @param array<string, mixed> $session */
|
|
private static function buildIodineConnectResponse(array $session, string $deviceId, string $clientIp): array {
|
|
$sessionId = (string) ($session['session_id'] ?? '');
|
|
$expiresAt = (int) ($session['expires_at'] ?? (time() + 3600));
|
|
$serverHost = (string) cfg('remote_access.iodine.server_host', 'apps.f0xx.org');
|
|
$udpPort = (int) cfg('remote_access.iodine.udp_port', 5350);
|
|
$tunnelDomain = (string) cfg('remote_access.iodine.tunnel_domain', 'iod.airie.me');
|
|
$password = (string) cfg('remote_access.iodine.password', 'zxc123');
|
|
$endpoint = $serverHost . ':' . $udpPort;
|
|
|
|
self::markSessionActive($sessionId);
|
|
self::logEvent($deviceId, $sessionId, null, 'tunnel_connect', 'iodine', null, $clientIp);
|
|
|
|
return self::raEnvelope('connect', [
|
|
'session_id' => $sessionId,
|
|
'endpoint' => $endpoint,
|
|
'tunnel' => 'iodine',
|
|
'credentials' => [
|
|
'mode' => 'iodine_dns',
|
|
'server_host' => $serverHost,
|
|
'udp_port' => $udpPort,
|
|
'tunnel_domain' => $tunnelDomain,
|
|
'password' => $password,
|
|
],
|
|
'expires_at' => $expiresAt,
|
|
]);
|
|
}
|
|
|
|
/** @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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, self::OPT_IODINE], 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 = match ($optIn) {
|
|
self::OPT_RSSH => 'ssh_reverse',
|
|
self::OPT_IODINE => 'iodine',
|
|
default => '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;
|
|
} elseif ($tunnel === 'iodine') {
|
|
$host = (string) cfg('remote_access.iodine.server_host', 'apps.f0xx.org');
|
|
$port = (int) cfg('remote_access.iodine.udp_port', 5350);
|
|
$endpoint = $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<array<string, mixed>> */
|
|
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<array<string, mixed>> */
|
|
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<array<string, mixed>> */
|
|
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<string> */
|
|
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<array{session_id: string, public_key: string, client_address: string}>
|
|
*/
|
|
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<array{session_id: string, public_key: string, client_address: string, ok: bool}>
|
|
*/
|
|
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<array{public_key: string, allowed_ips: string, reason: string}>
|
|
*/
|
|
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<string, mixed> $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<array<string, mixed>>, active_sessions: list<array<string, mixed>>, inactive_sessions: list<array<string, mixed>>, recent_events: list<array<string, mixed>>} */
|
|
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<string, mixed> $row @return array<string, mixed> */
|
|
private static function enrichSessionRow(array $row): array {
|
|
if (($row['tunnel'] ?? '') === 'ssh_reverse') {
|
|
$cmds = RsshBastionProvisioner::operatorCommands($row);
|
|
if ($cmds !== []) {
|
|
$row['rssh_operator'] = $cmds;
|
|
}
|
|
$remote = (int) ($row['rssh_remote_port'] ?? 0);
|
|
if ($remote > 0) {
|
|
$row['rssh_remote_bind'] = '127.0.0.1:' . $remote;
|
|
}
|
|
}
|
|
return $row;
|
|
}
|
|
|
|
/** @return list<array<string, mixed>> */
|
|
public static function listDevicesForDashboard(string $issuesBase, string $projectBase): array {
|
|
$devices = self::listDevices();
|
|
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<string> $deviceIds @return array<string, array<string, mixed>> */
|
|
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<string, mixed> $row @param array<string, mixed>|null $sessionRow @param array<string, array{rx_bytes: int, tx_bytes: int, latest_handshake: int, endpoint: string}>|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<string, mixed> */
|
|
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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed>|null $sessionRow @param array<string, array{rx_bytes: int, tx_bytes: int, latest_handshake: int, endpoint: string}>|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<string, mixed> $storedMeta @return array<string, mixed> */
|
|
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<string, mixed> $ctx @return list<array{label: string, href: string}> */
|
|
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);
|
|
}
|
|
}
|