1
0
mirror of git://f0xx.org/ac/ac-deploy synced 2026-07-29 03:38:07 +03:00

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 <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-07-12 19:44:37 +02:00
parent ab9b4ecae2
commit e49f13dfbd
4 changed files with 169 additions and 72 deletions

View File

@@ -1,69 +1,6 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
require_once __DIR__ . '/../../src/HeartbeatApi.php';
header('Content-Type: application/json; charset=utf-8');
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['error' => '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();

View File

@@ -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;

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
/**
* Device heartbeat API (ping / ntp / ip / ra).
* Accepts POST JSON and legacy GET query params for older app builds.
*/
final class HeartbeatApi
{
/** @return array<string, mixed> */
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<string, mixed> $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);
}
}

View File

@@ -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<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', '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<string, mixed> $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();
}