Files
ac-ms-remote-access/src/RemoteAccessRepository.php
Anton Afanasyeu 0ae19a939d Fix iodine connect default tunnel domain to live iod.airie.me.
t0.f0xx.org is not delegated; iodined on FE serves iod.airie.me.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:46:04 +02:00

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