<?php if (!defined('DASHBOARD_ROOT')) { http_response_code(403); exit; } class DeadManSwitch { private const HEARTBEAT_INTERVAL = 15; private const STALE_THRESHOLD = 45; private const COMPROMISED_CODE = 'COMPROMISED'; private const ALIVE_CODE = 'ALIVE'; public static function generateDaySecret(string $dnsKey, string $originKey): string { $dayOfYear = (int)date('z'); $hour = (int)date('G'); $material = $dnsKey . $originKey . $dayOfYear . $hour; return hash_hmac('sha256', $material, $originKey); } public static function generateResponseSecret(string $daySecret, string $originPrivate): string { $hour = (int)date('G'); return hash_hmac('sha256', $daySecret . $hour, $originPrivate); } public static function verify(string $expectedResponse, string $actualResponse): string { if ($actualResponse === '') { return 'down'; } if (hash_equals($expectedResponse, $actualResponse)) { return 'alive'; } return 'compromised'; } public static function recordHeartbeat(string $nodeName, string $status, int $latencyMs = 0): void { try { Database::execute( "UPDATE nodes SET status = :s, last_heartbeat = NOW(), latency_ms = :l WHERE node_name = :n", [':s' => $status, ':l' => $latencyMs, ':n' => $nodeName] ); } catch (\Throwable $e) {} if ($status === self::COMPROMISED_CODE) { Events::record(Events::CAT_SECURITY, 'deadman.compromised', 'fleet', null, [ 'node' => $nodeName, 'action' => 'BLACKHOLE -- node responded with wrong secret', ]); AuditLogger::log('deadman.compromised', 'fleet', null, [ 'node' => $nodeName, 'resolution' => 'DNS moved to BLACKHOLE, all traffic dropped', ]); } } public static function isStale(): bool { try { $row = Database::fetchOne( "SELECT TIMESTAMPDIFF(SECOND, last_heartbeat, NOW()) as age FROM nodes WHERE node_name = 'origin' LIMIT 1" ); return $row && (int)$row['age'] > self::STALE_THRESHOLD; } catch (\Throwable $e) { return false; } } public static function status(): array { try { $nodes = Database::fetchAll( "SELECT node_name, status, last_heartbeat, latency_ms,                         TIMESTAMPDIFF(SECOND, last_heartbeat, NOW()) as age_seconds                  FROM nodes WHERE node_name IN ('origin', 'dns') ORDER BY node_name" ); } catch (\Throwable $e) { $nodes = []; } $originAge = 0; $dnsAge = 0; foreach ($nodes as $n) { if ($n['node_name'] === 'origin') $originAge = (int)$n['age_seconds']; if ($n['node_name'] === 'dns') $dnsAge = (int)$n['age_seconds']; } return [ 'nodes' => $nodes, 'origin_stale' => $originAge > self::STALE_THRESHOLD, 'dns_stale' => $dnsAge > self::STALE_THRESHOLD, 'interval' => self::HEARTBEAT_INTERVAL, 'stale_threshold' => self::STALE_THRESHOLD, ]; } } 