/keys.json * DSH_RELAY_DATA 状态目录,默认 <本目录>/data * DSH_RELAY_ADMIN_KEY 管理接口密钥;设置后管理接口需要 x-admin-key * DSH_RELAY_POLL_MS 单次长轮询上限毫秒,默认 cli-server 400 / 其他 15000 * DSH_RELAY_EVENT_LIMIT 每台机器保留的最近事件条数,默认 300 * * 说明:PHP 每次请求都是独立进程,没有跨请求内存,所以中转状态写在 * data/state.json 里(仅"最近事件环 + 待发帧 + 待回请求",不含会话正文以外的 * 任何东西),并用 flock 保证并发安全。这与 Node 版参考实现的"纯内存"等价, * 只是把内存换成了本地临时文件;真正的生产后端应把状态放在内存/Redis 里。 */ // ─────────────────────────────── 配置 ─────────────────────────────── const PROTOCOL_VERSION = 1; $IS_CLI_SERVER = (PHP_SAPI === 'cli-server'); $BASE_PATH = rtrim((string)(getenv('DSH_RELAY_BASE') ?: '/dsh-api'), '/'); $DATA_DIR = (string)(getenv('DSH_RELAY_DATA') ?: (__DIR__ . DIRECTORY_SEPARATOR . 'data')); $KEYS_FILE = (string)(getenv('DSH_RELAY_KEYS') ?: (__DIR__ . DIRECTORY_SEPARATOR . 'keys.json')); $ADMIN_KEY = (string)(getenv('DSH_RELAY_ADMIN_KEY') ?: ''); $POLL_MAX_MS = (int)(getenv('DSH_RELAY_POLL_MS') ?: ($IS_CLI_SERVER ? 400 : 15000)); $EVENT_LIMIT = (int)(getenv('DSH_RELAY_EVENT_LIMIT') ?: 300); $STATE_FILE = $DATA_DIR . DIRECTORY_SEPARATOR . 'state.json'; $LOCK_FILE = $DATA_DIR . DIRECTORY_SEPARATOR . 'state.lock'; @mkdir($DATA_DIR, 0700, true); set_time_limit(0); ignore_user_abort(false); // ─────────────────────────── 基础工具 ─────────────────────────── /** 输出 JSON 并结束请求。 */ function jsonOut(int $status, $payload): void { http_response_code($status); header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store'); echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } /** 读取并解析 JSON 请求体(空体返回空数组)。 */ function readJsonBody(): array { $raw = file_get_contents('php://input'); if ($raw === false || $raw === '') { return []; } $parsed = json_decode($raw, true); return is_array($parsed) ? $parsed : []; } /** key 的非敏感展示形式,与插件端保持同一规则。 */ function fingerprint(string $key): string { if ($key === '') { return ''; } if (mb_strlen($key) <= 16) { return mb_substr($key, 0, 4) . '…'; } return mb_substr($key, 0, 12) . '…' . mb_substr($key, -4); } /** 恒定时间比较,避免用 === 比较机密。 */ function secretEquals(string $a, string $b): bool { if ($a === '' || $b === '') { return false; } return hash_equals($a, $b); } /** 从各种可能的来源取出实例 key。 */ function extractKey(?array $helloFrame, ?array $body): string { $header = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; if (is_string($header) && stripos($header, 'Bearer ') === 0) { return substr($header, 7); } $query = $_GET['key'] ?? ''; if (is_string($query) && $query !== '') { return $query; } if (is_array($body) && isset($body['key']) && is_string($body['key'])) { return $body['key']; } if (is_array($helloFrame) && isset($helloFrame['auth']['key']) && is_string($helloFrame['auth']['key'])) { return $helloFrame['auth']['key']; } return ''; } /** 管理接口鉴权:配置了 DSH_RELAY_ADMIN_KEY 就必须带 x-admin-key。 */ function requireAdmin(): void { global $ADMIN_KEY; if ($ADMIN_KEY === '') { return; // 本地测试默认不设防;生产部署务必设置 } $provided = $_SERVER['HTTP_X_ADMIN_KEY'] ?? ($_GET['adminKey'] ?? ''); if (!is_string($provided) || !secretEquals($ADMIN_KEY, $provided)) { jsonOut(401, ['error' => ['code' => 'unauthorized', 'message' => '管理接口需要 x-admin-key']]); } } // ─────────────────────────── key 白名单 ─────────────────────────── function loadKeys(): array { global $KEYS_FILE; if (!is_file($KEYS_FILE)) { return []; } $raw = file_get_contents($KEYS_FILE); $parsed = $raw === false ? null : json_decode($raw, true); $list = is_array($parsed) ? ($parsed['keys'] ?? $parsed) : []; $out = []; foreach ((is_array($list) ? $list : []) as $entry) { if (is_string($entry)) { $entry = ['key' => $entry]; } if (is_array($entry) && isset($entry['key']) && is_string($entry['key']) && $entry['key'] !== '') { $out[] = $entry; } } return $out; } function saveKeys(array $keys): void { global $KEYS_FILE; $payload = json_encode(['keys' => array_values($keys)], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); file_put_contents($KEYS_FILE, $payload . "\n", LOCK_EX); @chmod($KEYS_FILE, 0600); } function authorizeKey(string $key): ?array { if ($key === '') { return null; } foreach (loadKeys() as $entry) { if (secretEquals((string)$entry['key'], $key)) { return $entry; } } return null; } // ─────────────────────────── 状态存取 ─────────────────────────── function defaultState(): array { return [ 'instances' => [], 'pendingPairings' => [], 'autoAccept' => false, 'updatedAt' => time(), ]; } function readStateUnlocked(): array { global $STATE_FILE; if (!is_file($STATE_FILE)) { return defaultState(); } $raw = file_get_contents($STATE_FILE); $parsed = $raw === false ? null : json_decode($raw, true); if (!is_array($parsed)) { return defaultState(); } return array_merge(defaultState(), $parsed); } function writeStateUnlocked(array $state): void { global $STATE_FILE; $state['updatedAt'] = time(); $tmp = $STATE_FILE . '.' . getmypid() . '.tmp'; file_put_contents($tmp, json_encode($state, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); @rename($tmp, $STATE_FILE); } /** * 在文件锁保护下读改写状态。 * * @param callable $fn 形如 function (array &$state) { ... return $x; } * @param bool $write 是否写回 */ function withState(callable $fn, bool $write = true) { global $LOCK_FILE; $fp = fopen($LOCK_FILE, 'c+b'); if ($fp === false) { jsonOut(500, ['error' => ['code' => 'internal', 'message' => '无法打开状态锁文件']]); } flock($fp, $write ? LOCK_EX : LOCK_SH); try { $state = readStateUnlocked(); $result = $fn($state); if ($write) { writeStateUnlocked($state); } return $result; } finally { flock($fp, LOCK_UN); fclose($fp); } } /** * 确保某台机器在状态里有槽位(不存在则创建),并可选刷新 key 信息。 * * 注意:返回 void —— PHP 不允许对函数调用结果取引用,所以调用方拿到的是 * `$state['instances'][$instanceId]` 这个真实数组元素。 */ function ensureInstance(array &$state, string $instanceId, ?array $keyEntry = null): void { if (!isset($state['instances'][$instanceId])) { $state['instances'][$instanceId] = [ 'instanceId' => $instanceId, 'keyFingerprint' => $keyEntry ? fingerprint((string)$keyEntry['key']) : null, 'label' => $keyEntry['label'] ?? null, 'transport' => 'http', 'tls' => false, 'connectedAt' => time(), 'lastSeenAt' => time(), 'hello' => null, 'capabilities' => [], 'subscriptions' => ['topics' => [], 'sessions' => [], 'assistantStreams' => []], 'events' => [], 'inbox' => [], 'responses' => [], 'pendingRequests' => [], 'lastSeq' => 0, 'cursor' => 0, 'rejectedAt' => null, 'disconnectedAt' => null, ]; } if ($keyEntry) { $state['instances'][$instanceId]['keyFingerprint'] = fingerprint((string)$keyEntry['key']); $state['instances'][$instanceId]['label'] = $keyEntry['label'] ?? null; } } /** 把一帧放进某台机器的待发队列。 */ function queueFrame(array &$state, string $instanceId, array $frame): void { ensureInstance($state, $instanceId); $instance = &$state['instances'][$instanceId]; $instance['inbox'][] = $frame; // 队列上限,避免对端长期不在时无限增长 if (count($instance['inbox']) > 500) { $instance['inbox'] = array_slice($instance['inbox'], -500); } $instance['cursor'] = (int)($frame['seq'] ?? $instance['cursor']); } /** 记录一次被拒绝的连接,供 UI 一键登记。 */ function recordPendingPairing(string $key, string $instanceId, string $reason): void { withState(function (array &$state) use ($key, $instanceId, $reason) { if ($state['autoAccept']) { $keys = loadKeys(); $keys[] = ['key' => $key, 'label' => $instanceId !== '' ? $instanceId : 'auto-accepted', 'addedAt' => date('c')]; saveKeys($keys); return; } foreach ($state['pendingPairings'] as $pairing) { if (($pairing['key'] ?? '') === $key) { return; } } $state['pendingPairings'][] = [ 'key' => $key, 'fingerprint' => fingerprint($key), 'instanceId' => $instanceId, 'reason' => $reason, 'at' => time(), ]; if (count($state['pendingPairings']) > 20) { $state['pendingPairings'] = array_slice($state['pendingPairings'], -20); } }); } // ─────────────────────────── 路由 ─────────────────────────── $requestPath = (string)(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'); $requestMethod = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')); $route = null; if ($BASE_PATH !== '' && str_starts_with($requestPath, $BASE_PATH)) { $route = substr($requestPath, strlen($BASE_PATH)); if ($route === false) { $route = ''; } if ($route !== '' && $route[0] !== '/') { $route = null; // 例如 /dsh-apixxx 不应被当成 API } } $route = $route === null ? null : (rtrim($route, '/') === '' ? '/' : rtrim($route, '/')); // 非 API 路径 → 提供测试 UI if ($route === null) { if ($requestMethod !== 'GET') { jsonOut(405, ['error' => ['code' => 'bad_request', 'message' => '只支持 GET']]); } renderUi($BASE_PATH, $POLL_MAX_MS, $IS_CLI_SERVER, $ADMIN_KEY !== ''); exit; } // ─────────────────────────── API 路由 ─────────────────────────── // GET {base}/ —— 协议信息 if ($route === '/' || $route === '') { jsonOut(200, [ 'name' => 'dsh2server PHP relay', 'protocol' => PROTOCOL_VERSION, 'basePath' => $BASE_PATH, 'carriers' => ['http-long-poll' => true, 'websocket' => false], 'webSocketNote' => 'PHP 无法把 HTTP 请求升级为 WebSocket;插件会自动回退到 HTTP 长轮询。', 'endpoints' => [ 'events' => $BASE_PATH . '/events', 'inbox' => $BASE_PATH . '/inbox', 'instances' => $BASE_PATH . '/instances', 'keys' => $BASE_PATH . '/keys', ], 'pollMaxMs' => $POLL_MAX_MS, 'cliServer' => $IS_CLI_SERVER, 'keys' => count(loadKeys()), 'instances' => count(withState(fn (array &$s) => array_keys($s['instances']), false)), ]); } // POST {base}/events —— 插件上行 if ($route === '/events' && $requestMethod === 'POST') { $body = readJsonBody(); $frames = is_array($body['frames'] ?? null) ? $body['frames'] : []; $hello = null; foreach ($frames as $frame) { if (is_array($frame) && ($frame['type'] ?? '') === 'hello') { $hello = $frame; break; } } $key = extractKey($hello, $body); $entry = authorizeKey($key); if ($entry === null) { recordPendingPairing($key, (string)($hello['instanceId'] ?? ($body['instanceId'] ?? '')), 'unknown instance key'); jsonOut(401, ['error' => [ 'code' => 'unauthorized', 'message' => '未登记的实例 key:请在测试 UI 的「配对」面板里登记这台机器', ]]); } $instanceId = (string)($body['instanceId'] ?? ($hello['instanceId'] ?? ($entry['instanceId'] ?? ''))); if ($instanceId === '') { jsonOut(400, ['error' => ['code' => 'bad_request', 'message' => '缺少 instanceId']]); } $accepted = withState(function (array &$state) use ($frames, $instanceId, $entry, $hello) { ensureInstance($state, $instanceId, $entry); $instance = &$state['instances'][$instanceId]; $instance['transport'] = 'http'; $instance['tls'] = (($_SERVER['HTTPS'] ?? '') === 'on'); $instance['lastSeenAt'] = time(); $instance['rejectedAt'] = null; $instance['disconnectedAt'] = null; if ($hello !== null) { $instance['hello'] = [ 'v' => $hello['v'] ?? null, 'instance' => $hello['instance'] ?? null, 'lastSeq' => $hello['lastSeq'] ?? 0, 'resumeFromSeq' => $hello['resumeFromSeq'] ?? 0, 'subscriptions' => $hello['subscriptions'] ?? null, ]; $instance['capabilities'] = $hello['capabilities'] ?? []; if (isset($hello['instance']['displayName'])) { $instance['label'] = $instance['label'] ?: $hello['instance']['displayName']; } } $maxSeq = (int)$instance['lastSeq']; foreach ($frames as $frame) { if (!is_array($frame)) { continue; } switch ($frame['type'] ?? '') { case 'hello': $resumeFrom = 0; foreach ($instance['events'] as $event) { $resumeFrom = max($resumeFrom, (int)($event['seq'] ?? 0)); } queueFrame($state, $instanceId, [ 'v' => PROTOCOL_VERSION, 'type' => 'hello.ack', 'instanceId' => $instanceId, 'serverTime' => (int)(microtime(true) * 1000), 'heartbeatMs' => 30000, 'resumeFromSeq' => $resumeFrom, 'serverSeq' => (int)$instance['cursor'], 'pollWaitMs' => $GLOBALS['POLL_MAX_MS'], ]); // 默认订阅:全局主题;逐会话订阅由 UI 或后端按需发起 queueFrame($state, $instanceId, [ 'v' => PROTOCOL_VERSION, 'type' => 'subscribe', 'id' => 'sub-init', 'topics' => ['instance', 'sessions', 'jobs', 'approvals'], 'snapshot' => true, ]); break; case 'event': $seq = (int)($frame['seq'] ?? 0); $duplicate = false; foreach ($instance['events'] as $existing) { if ((int)($existing['seq'] ?? 0) === $seq) { $duplicate = true; break; } } if (!$duplicate) { $instance['events'][] = $frame; if (count($instance['events']) > $GLOBALS['EVENT_LIMIT']) { $instance['events'] = array_slice($instance['events'], -$GLOBALS['EVENT_LIMIT']); } } $instance['lastSeq'] = max((int)$instance['lastSeq'], $seq); $maxSeq = max($maxSeq, $seq); break; case 'response': $id = (string)($frame['id'] ?? ''); if ($id !== '') { $instance['responses'][$id] = $frame; unset($instance['pendingRequests'][$id]); } // 订阅响应顺带刷新订阅视图 if ($id === 'sub-init' || str_starts_with($id, 'sub-')) { $result = $frame['result'] ?? null; if (is_array($result)) { $instance['subscriptions'] = [ 'topics' => $result['topics'] ?? [], 'sessions' => $result['sessions'] ?? [], 'assistantStreams' => $result['assistantStreams'] ?? [], ]; } } break; case 'ping': queueFrame($state, $instanceId, [ 'v' => PROTOCOL_VERSION, 'type' => 'pong', 'ts' => (int)(microtime(true) * 1000), ]); break; case 'ack': $instance['cursor'] = max((int)$instance['cursor'], (int)($frame['seq'] ?? 0)); break; case 'bye': // HTTP 载体没有"连接关闭"事件,卸载信号只能来自这一帧。 // 标记断开而不是删除,避免与紧接其后的重连抢跑。 $instance['disconnectedAt'] = time(); $instance['lastSeenAt'] = time(); break; } } return $maxSeq; }); jsonOut(200, ['accepted' => $accepted]); } // GET {base}/inbox —— 插件下行(长轮询) if ($route === '/inbox' && $requestMethod === 'GET') { $key = extractKey(null, null); $entry = authorizeKey($key); if ($entry === null) { jsonOut(401, ['error' => ['code' => 'unauthorized', 'message' => '未登记的实例 key']]); } $instanceId = (string)($_GET['instanceId'] ?? ($entry['instanceId'] ?? '')); if ($instanceId === '') { jsonOut(400, ['error' => ['code' => 'bad_request', 'message' => '缺少 instanceId']]); } $waitMs = (int)($_GET['waitMs'] ?? $POLL_MAX_MS); $waitMs = max(0, min($waitMs, $POLL_MAX_MS)); $deadline = microtime(true) + ($waitMs / 1000); do { $frames = withState(function (array &$state) use ($instanceId) { if (!isset($state['instances'][$instanceId])) { return null; } $instance = &$state['instances'][$instanceId]; $instance['lastSeenAt'] = time(); $frames = $instance['inbox']; $instance['inbox'] = []; return $frames; }); if ($frames === null) { jsonOut(404, ['error' => ['code' => 'not_found', 'message' => '未知实例:请先完成 hello']]); } if (count($frames) > 0) { $cursor = withState(fn (array &$s) => (int)($s['instances'][$instanceId]['cursor'] ?? 0), false); jsonOut(200, ['frames' => $frames, 'cursor' => $cursor, 'waitMs' => $POLL_MAX_MS]); } if (microtime(true) >= $deadline) { break; } usleep(120000); // 120ms:兼顾响应速度与 CPU } while (true); $cursor = withState(fn (array &$s) => (int)($s['instances'][$instanceId]['cursor'] ?? 0), false); jsonOut(200, ['frames' => [], 'cursor' => $cursor, 'waitMs' => $POLL_MAX_MS]); } // GET {base}/ws —— 明确告知:PHP 无法升级 WebSocket if ($route === '/ws') { jsonOut(426, [ 'error' => [ 'code' => 'websocket_unsupported', 'message' => '本 PHP 中转不支持 WebSocket。插件会(在 transport=auto 时)自动回退到 HTTP 长轮询;' . '也可以把插件配置里的 transport 直接设为 http。', ], ]); } // ── 管理接口 ────────────────────────────────────────────────────── // GET {base}/keys if ($route === '/keys' && $requestMethod === 'GET') { requireAdmin(); $rows = []; foreach (loadKeys() as $entry) { $rows[] = [ 'fingerprint' => fingerprint((string)$entry['key']), 'label' => $entry['label'] ?? null, 'instanceId' => $entry['instanceId'] ?? null, 'addedAt' => $entry['addedAt'] ?? null, ]; } jsonOut(200, ['keys' => $rows, 'keysFile' => $KEYS_FILE]); } // POST {base}/keys { key, label? } if ($route === '/keys' && $requestMethod === 'POST') { requireAdmin(); $body = readJsonBody(); $key = trim((string)($body['key'] ?? '')); if (strlen($key) < 16) { jsonOut(400, ['error' => ['code' => 'invalid', 'message' => 'key 至少 16 个字符']]); } if (authorizeKey($key) !== null) { jsonOut(409, ['error' => ['code' => 'exists', 'message' => '这个 key 已经登记过了']]); } $keys = loadKeys(); $entry = ['key' => $key, 'label' => $body['label'] ?? null, 'addedAt' => date('c')]; $keys[] = $entry; saveKeys($keys); // 从待配对列表里移除 withState(function (array &$state) use ($key) { $state['pendingPairings'] = array_values(array_filter( $state['pendingPairings'], fn ($p) => ($p['key'] ?? '') !== $key )); }); jsonOut(200, ['added' => ['fingerprint' => fingerprint($key), 'label' => $entry['label']]]); } // POST {base}/keys/remove { key | fingerprint } if ($route === '/keys/remove' && $requestMethod === 'POST') { requireAdmin(); $body = readJsonBody(); $selector = (string)($body['key'] ?? ($body['fingerprint'] ?? '')); $keys = loadKeys(); $remaining = []; $removed = false; foreach ($keys as $entry) { if ((string)$entry['key'] === $selector || fingerprint((string)$entry['key']) === $selector) { $removed = true; continue; } $remaining[] = $entry; } if ($removed) { saveKeys($remaining); } jsonOut($removed ? 200 : 404, ['removed' => $removed]); } // GET {base}/pending if ($route === '/pending' && $requestMethod === 'GET') { requireAdmin(); $data = withState(function (array &$state) { $rows = []; foreach ($state['pendingPairings'] as $pairing) { $rows[] = [ 'fingerprint' => $pairing['fingerprint'] ?? fingerprint((string)($pairing['key'] ?? '')), 'instanceId' => $pairing['instanceId'] ?? '', 'reason' => $pairing['reason'] ?? '', 'at' => $pairing['at'] ?? time(), 'key' => $pairing['key'] ?? '', ]; } return ['pending' => $rows, 'autoAccept' => (bool)$state['autoAccept']]; }, false); jsonOut(200, $data); } // POST {base}/pending/allow { key } —— 一键登记 if ($route === '/pending/allow' && $requestMethod === 'POST') { requireAdmin(); $body = readJsonBody(); $key = (string)($body['key'] ?? ''); if ($key === '') { jsonOut(400, ['error' => ['code' => 'invalid', 'message' => '缺少 key']]); } if (authorizeKey($key) === null) { $keys = loadKeys(); $keys[] = ['key' => $key, 'label' => $body['label'] ?? null, 'addedAt' => date('c')]; saveKeys($keys); } withState(function (array &$state) use ($key) { $state['pendingPairings'] = array_values(array_filter( $state['pendingPairings'], fn ($p) => ($p['key'] ?? '') !== $key )); }); jsonOut(200, ['allowed' => fingerprint($key)]); } // POST {base}/pending/auto { enabled } if ($route === '/pending/auto' && $requestMethod === 'POST') { requireAdmin(); $body = readJsonBody(); $enabled = (bool)($body['enabled'] ?? false); withState(function (array &$state) use ($enabled) { $state['autoAccept'] = $enabled; }); jsonOut(200, ['autoAccept' => $enabled]); } // GET {base}/instances if ($route === '/instances' && $requestMethod === 'GET') { requireAdmin(); $rows = withState(function (array &$state) { $rows = []; foreach ($state['instances'] as $id => $instance) { $rows[] = [ 'instanceId' => $id, 'label' => $instance['label'] ?? null, 'keyFingerprint' => $instance['keyFingerprint'] ?? null, 'transport' => $instance['transport'] ?? 'http', 'tls' => (bool)($instance['tls'] ?? false), 'connectedAt' => $instance['connectedAt'] ?? null, 'lastSeenAt' => $instance['lastSeenAt'] ?? null, 'disconnectedAt' => $instance['disconnectedAt'] ?? null, 'lastSeq' => $instance['lastSeq'] ?? 0, 'eventCount' => count($instance['events'] ?? []), 'capabilities' => $instance['capabilities'] ?? [], 'subscriptions' => $instance['subscriptions'] ?? [], 'pendingRequests' => count($instance['pendingRequests'] ?? []), 'lastEventKind' => empty($instance['events']) ? null : ($instance['events'][count($instance['events']) - 1]['kind'] ?? null), 'pluginVersion' => $instance['hello']['instance']['pluginVersion'] ?? null, 'hostname' => $instance['hello']['instance']['hostname'] ?? null, 'platform' => $instance['hello']['instance']['platform'] ?? null, ]; } return $rows; }, false); jsonOut(200, ['instances' => $rows]); } // GET {base}/instances/{id}/events?since=&limit= if ($requestMethod === 'GET' && preg_match('#^/instances/([^/]+)/events$#', (string)$route, $m)) { requireAdmin(); $instanceId = urldecode($m[1]); $since = (int)($_GET['since'] ?? 0); $limit = max(1, min((int)($_GET['limit'] ?? 200), 1000)); $data = withState(function (array &$state) use ($instanceId, $since, $limit) { if (!isset($state['instances'][$instanceId])) { return null; } $events = []; foreach ($state['instances'][$instanceId]['events'] as $event) { if ((int)($event['seq'] ?? 0) > $since) { $events[] = $event; } } $events = array_slice($events, -$limit); return ['events' => $events, 'lastSeq' => (int)$state['instances'][$instanceId]['lastSeq']]; }, false); if ($data === null) { jsonOut(404, ['error' => ['code' => 'not_found', 'message' => '未知实例']]); } jsonOut(200, $data); } // POST {base}/instances/{id}/request { method, params } if ($requestMethod === 'POST' && preg_match('#^/instances/([^/]+)/request$#', (string)$route, $m)) { requireAdmin(); $instanceId = urldecode($m[1]); $body = readJsonBody(); $method = (string)($body['method'] ?? ''); if ($method === '') { jsonOut(400, ['error' => ['code' => 'invalid', 'message' => '缺少 method']]); } $id = 'req-' . bin2hex(random_bytes(6)); $frame = [ 'v' => PROTOCOL_VERSION, 'type' => 'request', 'id' => $id, 'method' => $method, 'params' => is_array($body['params'] ?? null) ? $body['params'] : new stdClass(), ]; $ok = withState(function (array &$state) use ($instanceId, $id, $frame, $method) { if (!isset($state['instances'][$instanceId])) { return false; } queueFrame($state, $instanceId, $frame); $state['instances'][$instanceId]['pendingRequests'][$id] = ['method' => $method, 'at' => time()]; return true; }); if (!$ok) { jsonOut(404, ['error' => ['code' => 'not_found', 'message' => '未知实例(该机器还没连上或已离线)']]); } // 立即返回 id:UI 用 /response?id= 轮询结果。 // (不能在这里阻塞等待:php -S 是单进程,阻塞会卡住插件拉取队列,形成死锁。) jsonOut(200, ['queued' => true, 'id' => $id, 'method' => $method]); } // GET {base}/instances/{id}/response?id= if ($requestMethod === 'GET' && preg_match('#^/instances/([^/]+)/response$#', (string)$route, $m)) { requireAdmin(); $instanceId = urldecode($m[1]); $id = (string)($_GET['id'] ?? ''); if ($id === '') { jsonOut(400, ['error' => ['code' => 'invalid', 'message' => '缺少 id']]); } $frame = withState(function (array &$state) use ($instanceId, $id) { return $state['instances'][$instanceId]['responses'][$id] ?? null; }, false); if ($frame === null) { jsonOut(200, ['ready' => false]); } jsonOut(200, ['ready' => true, 'frame' => $frame]); } // POST {base}/instances/{id}/forget —— 忘记这台机器(清掉它的内存状态) if ($requestMethod === 'POST' && preg_match('#^/instances/([^/]+)/forget$#', (string)$route, $m)) { requireAdmin(); $instanceId = urldecode($m[1]); $removed = withState(function (array &$state) use ($instanceId) { if (!isset($state['instances'][$instanceId])) { return false; } unset($state['instances'][$instanceId]); return true; }); jsonOut($removed ? 200 : 404, ['forgotten' => $removed]); } // POST {base}/admin/clear —— 清空全部中转状态(不动 key 白名单) if ($route === '/admin/clear' && $requestMethod === 'POST') { requireAdmin(); withState(function (array &$state) { $state['instances'] = []; $state['pendingPairings'] = []; }); jsonOut(200, ['cleared' => true]); } jsonOut(404, ['error' => ['code' => 'not_found', 'message' => '未知路由:' . $requestMethod . ' ' . $requestPath]]); // ─────────────────────────── 测试 UI ─────────────────────────── /** * 输出内置 HTML 调试台。 * * 页面本身不依赖任何外部资源(离线可用),所有数据都来自上面的 API。 */ function renderUi(string $basePath, int $pollMaxMs, bool $cliServer, bool $adminRequired): void { $html = <<<'HTML' dsh2server 测试台

dsh2server 测试台

连接中…

① 配对 / Key 白名单 0

指纹备注登记时间
还没有登记任何机器。

② 在线机器

instanceId备注传输最后活动事件
还没有机器连上来。启动 dsh 后稍等片刻(最多 60 秒重连一次)。

③ 事件流


      

④ 操作面板 未选择机器

⑤ 审批 / 提问 0

需要插件配置 forwardApprovals: true(可选 forwardQuestions: true)才会把本机的审批/提问转发到这里。
暂无待决请求。

⑥ 自检结果

点右上角「一键自检」依次验证:握手 → 能力 → 会话列表 → 工作目录 → 命令下发 → 中断。
HTML; $html = str_replace( ['__BASE__', '__POLL__', '__CLI__', '__ADMIN__'], [ json_encode($basePath, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), (string)$pollMaxMs, $cliServer ? 'true' : 'false', $adminRequired ? 'true' : 'false', ], $html ); header('Content-Type: text/html; charset=utf-8'); header('Cache-Control: no-store'); echo $html; }