From e49f13dfbdfdca0db0988c27154e9a05047a6ac8 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Sun, 12 Jul 2026 19:44:37 +0200 Subject: [PATCH] Lab seeds: heartbeat GET compat, iodine RA sessions, UI hints. Sync composed-backend sources for legacy device self-test 405 fix and iodine remote access. Co-authored-by: Cursor --- .../backend/public/api/heartbeat.php | 67 +--------- .../backend/public/assets/js/remote_access.js | 8 +- .../lab-seeds/backend/src/HeartbeatApi.php | 117 ++++++++++++++++++ .../backend/src/RemoteAccessRepository.php | 49 +++++++- 4 files changed, 169 insertions(+), 72 deletions(-) create mode 100644 sim/cluster0/lab-seeds/backend/src/HeartbeatApi.php diff --git a/sim/cluster0/lab-seeds/backend/public/api/heartbeat.php b/sim/cluster0/lab-seeds/backend/public/api/heartbeat.php index 8ccf010..da6e910 100644 --- a/sim/cluster0/lab-seeds/backend/public/api/heartbeat.php +++ b/sim/cluster0/lab-seeds/backend/public/api/heartbeat.php @@ -1,69 +1,6 @@ 'method_not_allowed']); - exit; -} - -$raw = file_get_contents('php://input') ?: ''; -$req = json_decode($raw, true); -if (!is_array($req)) { - http_response_code(400); - echo json_encode(['error' => 'invalid_json']); - exit; -} - -$heartbeat = $req['heartbeat'] ?? []; -if (!is_array($heartbeat)) { - $heartbeat = []; -} - -$type = (string)($heartbeat['type'] ?? 'generic'); -$srvEpoch = time(); -$srvTz = date('O'); -$clientIp = (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? '')); - -$resp = [ - 'heartbeat' => [ - 'type' => $type, - 'server_date' => $srvEpoch, - 'server_tz' => $srvTz, - ], -]; - -if ($type === 'ntp') { - $clientDate = (int)($heartbeat['date'] ?? 0); - $clientTz = (string)($heartbeat['tz'] ?? ''); - $timeSource = trim((string)($heartbeat['time_source'] ?? 'device')); - if ($timeSource === '') { - $timeSource = 'device'; - } - $correction = $clientDate > 0 ? ($srvEpoch - $clientDate) : null; - $resp['heartbeat']['client_date'] = $clientDate; - $resp['heartbeat']['client_tz'] = $clientTz; - $resp['heartbeat']['time_source'] = $timeSource; - $resp['heartbeat']['correction_seconds'] = $correction; - $resp['heartbeat']['ntp_correction_s'] = $correction; - $resp['heartbeat']['enabled'] = true; -} - -if ($type === 'ip') { - $resp['heartbeat']['external_ip'] = $clientIp; -} - -if ($type === 'ra') { - require_once __DIR__ . '/../../src/RemoteAccessRepository.php'; - $ra = RemoteAccessRepository::handleDeviceHeartbeat($heartbeat, $clientIp); - $resp['heartbeat'] = array_merge($resp['heartbeat'], $ra); -} - -if (Auth::user()) { - Auth::touchSession(); -} - -echo json_encode($resp, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); +HeartbeatApi::emit(); diff --git a/sim/cluster0/lab-seeds/backend/public/assets/js/remote_access.js b/sim/cluster0/lab-seeds/backend/public/assets/js/remote_access.js index b4aa098..2870964 100644 --- a/sim/cluster0/lab-seeds/backend/public/assets/js/remote_access.js +++ b/sim/cluster0/lab-seeds/backend/public/assets/js/remote_access.js @@ -519,14 +519,14 @@ const briefId = 'ra-brief-' + rowKey; const wl = Number(d.whitelisted) === 1; const optIn = d.opt_in_mode || 'none'; - const openReady = wl && (optIn === 'wireguard' || optIn === 'rssh'); + const openReady = wl && (optIn === 'wireguard' || optIn === 'rssh' || optIn === 'iodine'); const openHint = !canOperate() ? 'Need remote_access_operate permission' : !wl ? 'Whitelist device first' : openReady ? 'Open session — device connects on next poll (≤7 min)' - : 'Phone must poll with RSSH/WG enabled (dev settings on device; wait ≤7 min)'; + : 'Phone must poll with WireGuard/RSSH/Iodine enabled (dev settings on device; wait ≤7 min)'; const openDisabled = canOperate() ? '' : ' disabled'; const wlDisabled = canAdmin() ? '' : ' disabled'; const isOpen = lastExpanded.has(String(d.device_id || '')); @@ -708,11 +708,11 @@ setStatus('Whitelist device ' + openId + ' first', true); return; } - if (optIn !== 'wireguard' && optIn !== 'rssh') { + if (optIn !== 'wireguard' && optIn !== 'rssh' && optIn !== 'iodine') { setStatus( 'Device opt-in is "' + optIn + - '". On phone: dev settings → Remote access → RSSH, then wait for poll (≤7 min).', + '". On phone: dev settings → Remote access → WireGuard, RSSH, or Iodine, then wait for poll (≤7 min).', true ); return; diff --git a/sim/cluster0/lab-seeds/backend/src/HeartbeatApi.php b/sim/cluster0/lab-seeds/backend/src/HeartbeatApi.php new file mode 100644 index 0000000..d3cbe49 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/HeartbeatApi.php @@ -0,0 +1,117 @@ + */ + public static function parseRequest(): array + { + $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); + if ($method === 'POST') { + $raw = file_get_contents('php://input') ?: ''; + $req = json_decode($raw, true); + if (!is_array($req)) { + return ['error' => 'invalid_json', 'code' => 400]; + } + $heartbeat = $req['heartbeat'] ?? []; + return ['heartbeat' => is_array($heartbeat) ? $heartbeat : [], 'code' => 0]; + } + if ($method === 'GET' || $method === 'HEAD') { + $type = (string) ($_GET['type'] ?? $_GET['heartbeat_type'] ?? 'ping'); + $heartbeat = ['type' => $type]; + if ($type === 'ntp') { + $heartbeat['date'] = (int) ($_GET['date'] ?? $_GET['client_date'] ?? 0); + $heartbeat['tz'] = (string) ($_GET['tz'] ?? $_GET['client_tz'] ?? ''); + $heartbeat['time_source'] = (string) ($_GET['time_source'] ?? 'device'); + } + return ['heartbeat' => $heartbeat, 'code' => 0]; + } + return ['error' => 'method_not_allowed', 'code' => 405]; + } + + /** @param array $heartbeat */ + public static function buildResponse(array $heartbeat, string $clientIp): array + { + $type = (string) ($heartbeat['type'] ?? 'generic'); + $srvEpoch = time(); + $srvTz = date('O'); + + $resp = [ + 'heartbeat' => [ + 'type' => $type, + 'server_date' => $srvEpoch, + 'server_tz' => $srvTz, + ], + ]; + + if ($type === 'ntp') { + $clientDate = (int) ($heartbeat['date'] ?? 0); + $clientTz = (string) ($heartbeat['tz'] ?? ''); + $timeSource = trim((string) ($heartbeat['time_source'] ?? 'device')); + if ($timeSource === '') { + $timeSource = 'device'; + } + $correction = $clientDate > 0 ? ($srvEpoch - $clientDate) : null; + $resp['heartbeat']['client_date'] = $clientDate; + $resp['heartbeat']['client_tz'] = $clientTz; + $resp['heartbeat']['time_source'] = $timeSource; + $resp['heartbeat']['correction_seconds'] = $correction; + $resp['heartbeat']['ntp_correction_s'] = $correction; + $resp['heartbeat']['enabled'] = true; + } + + if ($type === 'ip') { + $resp['heartbeat']['external_ip'] = $clientIp; + } + + if ($type === 'ra') { + if (!class_exists('RemoteAccessRepository', false)) { + return ['error' => 'ra_unavailable', 'code' => 503]; + } + $ra = RemoteAccessRepository::handleDeviceHeartbeat($heartbeat, $clientIp); + $resp['heartbeat'] = array_merge($resp['heartbeat'], $ra); + } + + return ['response' => $resp, 'code' => 200]; + } + + public static function emit(): void + { + header('Content-Type: application/json; charset=utf-8'); + + $parsed = self::parseRequest(); + if (($parsed['code'] ?? 0) === 405) { + http_response_code(405); + echo json_encode(['error' => 'method_not_allowed']); + return; + } + if (($parsed['code'] ?? 0) === 400) { + http_response_code(400); + echo json_encode(['error' => 'invalid_json']); + return; + } + + $clientIp = (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? '')); + if (str_contains($clientIp, ',')) { + $clientIp = trim(explode(',', $clientIp)[0]); + } + + $built = self::buildResponse((array) ($parsed['heartbeat'] ?? []), $clientIp); + if (($built['code'] ?? 200) === 503) { + http_response_code(503); + echo json_encode(['error' => 'ra_unavailable']); + return; + } + + if (class_exists('Auth', false) && Auth::user()) { + Auth::touchSession(); + } + + http_response_code(200); + echo json_encode($built['response'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/RemoteAccessRepository.php b/sim/cluster0/lab-seeds/backend/src/RemoteAccessRepository.php index 6739920..f8fd309 100644 --- a/sim/cluster0/lab-seeds/backend/src/RemoteAccessRepository.php +++ b/sim/cluster0/lab-seeds/backend/src/RemoteAccessRepository.php @@ -9,6 +9,7 @@ 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'; @@ -243,9 +244,12 @@ final class RemoteAccessRepository { 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_NONE], true)) { + if (in_array($mode, [self::OPT_WIREGUARD, self::OPT_RSSH, self::OPT_IODINE, self::OPT_NONE], true)) { return $mode; } return self::OPT_WIREGUARD; @@ -382,9 +386,40 @@ final class RemoteAccessRepository { 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', 't0.f0xx.org'); + $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'] ?? ''); @@ -614,17 +649,25 @@ final class RemoteAccessRepository { 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], true)) { + 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 = $optIn === self::OPT_RSSH ? 'ssh_reverse' : 'wireguard'; + $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(); }