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