my-durable 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +328 -2
- package/ai_docs/durable_pi/announcement.md +860 -0
- package/ai_docs/durable_pi/changelog.md +51 -0
- package/ai_docs/durable_pi/chord-delta-findings.md +362 -0
- package/ai_docs/durable_pi/coding-agent-integration.md +70 -0
- package/ai_docs/durable_pi/pico-v5-chord-usage.md +391 -0
- package/ai_docs/durable_pi/pico-v5-handoff.md +921 -0
- package/ai_docs/durable_pi/readme.md +617 -0
- package/ai_docs/durable_pi/sources.md +22 -0
- package/ai_docs/durable_pi/spec.md +4696 -0
- package/ai_docs/durable_pi/vacation-planner.md +57 -0
- package/ai_docs/pi/chord.md +268 -0
- package/ai_docs/pi/cli-integration.md +110 -0
- package/ai_docs/pi/cli.md +325 -0
- package/ai_docs/pi/codemode.md +211 -0
- package/ai_docs/pi/compaction.md +467 -0
- package/ai_docs/pi/configuration.md +51 -0
- package/ai_docs/pi/containerization.md +187 -0
- package/ai_docs/pi/custom-provider.md +177 -0
- package/ai_docs/pi/environment-variables.md +102 -0
- package/ai_docs/pi/extensions.md +278 -0
- package/ai_docs/pi/how-pi-works.md +53 -0
- package/ai_docs/pi/index.md +43 -0
- package/ai_docs/pi/json.md +230 -0
- package/ai_docs/pi/keybindings.md +198 -0
- package/ai_docs/pi/llama-cpp.md +118 -0
- package/ai_docs/pi/mcp.md +274 -0
- package/ai_docs/pi/message-types.md +265 -0
- package/ai_docs/pi/models.md +209 -0
- package/ai_docs/pi/overview.md +166 -0
- package/ai_docs/pi/packages.md +133 -0
- package/ai_docs/pi/pi-agent-core.md +568 -0
- package/ai_docs/pi/pi-ai.md +1942 -0
- package/ai_docs/pi/prompt-templates.md +63 -0
- package/ai_docs/pi/providers.md +204 -0
- package/ai_docs/pi/quickstart.md +140 -0
- package/ai_docs/pi/readme.md +99 -0
- package/ai_docs/pi/rpc-commands.md +864 -0
- package/ai_docs/pi/rpc-extension-ui.md +204 -0
- package/ai_docs/pi/rpc.md +197 -0
- package/ai_docs/pi/sdk.md +151 -0
- package/ai_docs/pi/security.md +103 -0
- package/ai_docs/pi/session-format.md +297 -0
- package/ai_docs/pi/sessions.md +70 -0
- package/ai_docs/pi/settings.md +173 -0
- package/ai_docs/pi/shell-aliases.md +97 -0
- package/ai_docs/pi/skills.md +97 -0
- package/ai_docs/pi/slash-commands.md +64 -0
- package/ai_docs/pi/sources.md +57 -0
- package/ai_docs/pi/terminal-setup.md +223 -0
- package/ai_docs/pi/termux.md +121 -0
- package/ai_docs/pi/themes.md +140 -0
- package/ai_docs/pi/tmux.md +59 -0
- package/ai_docs/pi/toolkit.md +168 -0
- package/ai_docs/pi/tui.md +129 -0
- package/ai_docs/pi/usage.md +98 -0
- package/ai_docs/pi/virtual-models.md +118 -0
- package/ai_docs/pi/windows.md +71 -0
- package/apps/dpi-backend/.nvmrc +1 -0
- package/apps/dpi-backend/API.md +99 -0
- package/apps/dpi-backend/README.md +119 -0
- package/apps/dpi-backend/deploy/backend.env.example +9 -0
- package/apps/dpi-backend/deploy/dpi-backend.plist +26 -0
- package/apps/dpi-backend/deploy/dpi-backend.service +22 -0
- package/apps/dpi-backend/package.json +33 -0
- package/apps/dpi-backend/prompts/one_sentence.md +3 -0
- package/apps/dpi-backend/prompts/smart_ass_opus_fix.md +169 -0
- package/apps/dpi-backend/scripts/check-contract.mjs +151 -0
- package/apps/dpi-backend/scripts/migrate-playground.ts +66 -0
- package/apps/dpi-backend/scripts/record-nested-fixture.mts +154 -0
- package/apps/dpi-backend/scripts/token.ts +57 -0
- package/apps/dpi-backend/src/addons.ts +139 -0
- package/apps/dpi-backend/src/auth.ts +20 -0
- package/apps/dpi-backend/src/config.ts +54 -0
- package/apps/dpi-backend/src/contract-check.ts +118 -0
- package/apps/dpi-backend/src/errors.ts +50 -0
- package/apps/dpi-backend/src/extensions.ts +510 -0
- package/apps/dpi-backend/src/faux.ts +80 -0
- package/apps/dpi-backend/src/history.ts +165 -0
- package/apps/dpi-backend/src/idempotency.ts +20 -0
- package/apps/dpi-backend/src/jev.ts +43 -0
- package/apps/dpi-backend/src/jobs.ts +790 -0
- package/apps/dpi-backend/src/main.ts +76 -0
- package/apps/dpi-backend/src/model-catalog.ts +180 -0
- package/apps/dpi-backend/src/pairing-files.ts +142 -0
- package/apps/dpi-backend/src/pairing.ts +459 -0
- package/apps/dpi-backend/src/routes.ts +217 -0
- package/apps/dpi-backend/src/shared-state.ts +85 -0
- package/apps/dpi-backend/src/store.ts +1240 -0
- package/apps/dpi-backend/src/streams.ts +256 -0
- package/apps/dpi-backend/src/tunnel.ts +955 -0
- package/apps/dpi-backend/tsconfig.json +13 -0
- package/apps/dpi-playground/README.md +83 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +77 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/anthropic.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/deepseek.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/durable-pi.svg +14 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/gemini.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/grok.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/kimi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/lab.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/meta.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/minimax.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/openai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/qwen.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/xiaomi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/zai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/index.html +15 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/package.json +5 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/anthropic.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/deepseek.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/durable-pi.svg +14 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/gemini.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/grok.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/kimi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/lab.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/meta.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/minimax.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/openai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/qwen.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/xiaomi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/zai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/App.vue +180 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/commands.ts +119 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/composables.ts +129 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/hub.ts +40 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/icon-path.ts +7 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/ids.ts +2 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/jobs.ts +80 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/link-policy.ts +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown-policy.ts +115 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown.ts +60 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-close.ts +23 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +56 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +166 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +323 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/signature.ts +146 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/store.ts +373 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/stream.ts +183 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +342 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Composer.vue +198 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ContextBar.vue +254 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceCredentialsDialog.vue +40 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceIcon.vue +56 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DirBrowser.vue +95 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ExtensionsPanel.vue +96 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/HelpDialog.vue +69 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +425 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/JobCard.vue +221 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/MobileNav.vue +39 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ModelPicker.vue +71 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NetworkSidebar.vue +236 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +114 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/OfflinePanel.vue +128 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +433 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PromptDialog.vue +89 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/StateEditor.vue +179 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TasksPanel.vue +280 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Toast.vue +11 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TokenManager.vue +175 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TopBar.vue +114 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TranscriptItem.vue +152 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +195 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TunnelStatusBar.vue +107 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/extensions.css +105 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/jobs.css +655 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +22 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/network.css +675 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +3203 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/tsconfig.json +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/vite.config.ts +14 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/package.json +5 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +406 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/client-ip.ts +72 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +265 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/guard.ts +43 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/http-errors.ts +156 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/identity.ts +60 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/limiter.ts +165 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +460 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/monitor.ts +320 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/mux.ts +277 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +816 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/proxy.ts +128 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/rotation.ts +583 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/seed.ts +11 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/transport.ts +108 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-token.ts +11 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-transport.ts +560 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +873 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/tsconfig.json +13 -0
- package/apps/dpi-playground/package.json +37 -0
- package/apps/dpi-sdk/README.md +74 -0
- package/apps/dpi-sdk/package.json +20 -0
- package/apps/dpi-sdk/scripts/net.ts +73 -0
- package/apps/dpi-sdk/src/api.ts +631 -0
- package/apps/dpi-sdk/src/app-state.ts +111 -0
- package/apps/dpi-sdk/src/client.ts +330 -0
- package/apps/dpi-sdk/src/errors.ts +16 -0
- package/apps/dpi-sdk/src/jobs.ts +99 -0
- package/apps/dpi-sdk/src/models.ts +472 -0
- package/apps/dpi-sdk/src/node/chunk.ts +488 -0
- package/apps/dpi-sdk/src/node/config.ts +175 -0
- package/apps/dpi-sdk/src/node/drivers.ts +402 -0
- package/apps/dpi-sdk/src/node/ops.ts +114 -0
- package/apps/dpi-sdk/src/node/private-dir.ts +48 -0
- package/apps/dpi-sdk/src/node/registry-migration.ts +549 -0
- package/apps/dpi-sdk/src/node/registry.ts +390 -0
- package/apps/dpi-sdk/src/node/sse-writer.ts +151 -0
- package/apps/dpi-sdk/src/protocol/backoff.ts +31 -0
- package/apps/dpi-sdk/src/protocol/limits.ts +199 -0
- package/apps/dpi-sdk/src/protocol/pairing.ts +311 -0
- package/apps/dpi-sdk/src/protocol/rest-routes.ts +105 -0
- package/apps/dpi-sdk/src/protocol/tunnel.ts +331 -0
- package/apps/dpi-sdk/src/sse.ts +117 -0
- package/apps/dpi-sdk/src/transcript.ts +291 -0
- package/apps/dpi-sdk/tsconfig.json +13 -0
- package/apps/dpi-sdk/w +20 -0
- package/apps/dpi-tui/README.md +54 -0
- package/apps/dpi-tui/bin/dpi-tui.mjs +5 -0
- package/apps/dpi-tui/package.json +20 -0
- package/apps/dpi-tui/src/app.ts +433 -0
- package/apps/dpi-tui/src/commands.ts +151 -0
- package/apps/dpi-tui/src/main.ts +122 -0
- package/apps/dpi-tui/src/pairing.ts +287 -0
- package/apps/dpi-tui/src/pickers.ts +238 -0
- package/apps/dpi-tui/src/render.ts +164 -0
- package/apps/dpi-tui/src/theme.ts +57 -0
- package/apps/dpi-tui/tsconfig.json +14 -0
- package/bin/device.mjs +5 -0
- package/bin/hub.mjs +5 -0
- package/bin/tui.mjs +5 -0
- package/deploy/Caddyfile +120 -0
- package/docs/verification.md +102 -0
- package/dpi-network.example.yaml +62 -0
- package/images/01_durable_agent_network.svg +185 -0
- package/images/02_pi_made_durable.svg +204 -0
- package/images/03_restart_mid_tool.svg +168 -0
- package/images/07_one_api_every_client.svg +123 -0
- package/images/08_device_session_agent.svg +123 -0
- package/images/09_compact_keep_history.svg +143 -0
- package/images/10_shared_app_state.svg +177 -0
- package/images/11_durable_jobs_wait.svg +141 -0
- package/images/12_tools_change_mid_run.svg +231 -0
- package/images/13_hook_blocks_tool.svg +122 -0
- package/images/14_fork_at_any_entry.svg +119 -0
- package/images/15_web_client.png +0 -0
- package/images/16_terminal_client.svg +43 -0
- package/images/17_devices_are_data.svg +182 -0
- package/images/18_should_every_agent_be_durable.svg +118 -0
- package/images/19_sqlite_on_every_device.svg +155 -0
- package/images/20_admission_and_effects.svg +134 -0
- package/justfile +33 -0
- package/package.json +41 -4
- package/scripts/verify.mjs +210 -0
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device tunnel connector — Durable Agent Network P2-13 (spec 04 §4–§6).
|
|
3
|
+
*
|
|
4
|
+
* One connector per daemon, started from the persisted pairing.json written
|
|
5
|
+
* by DevicePairing (P2-11). The connector is the ONLY holder of the outbound
|
|
6
|
+
* WSS connection: readers/subscriptions never open their own tunnel loop.
|
|
7
|
+
*
|
|
8
|
+
* Order of trust: TLS certificate + hostname are verified BEFORE the bearer
|
|
9
|
+
* leaves the device (Node rejects the handshake otherwise — the HTTP upgrade
|
|
10
|
+
* with the token is never transmitted). One exact origin, redirects rejected.
|
|
11
|
+
*
|
|
12
|
+
* Terminal states are narrow on purpose: only `revoked` for the credential
|
|
13
|
+
* tuple that still matches the CURRENT pairing file stops retries (and drops
|
|
14
|
+
* that file); a late 401 for an already-replaced credential is ignored.
|
|
15
|
+
* `token_unknown` and bare 401s stay non-terminal with an actionable re-pair
|
|
16
|
+
* status instead of an invisible infinite retry.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { readFileSync, rmSync } from "node:fs";
|
|
20
|
+
import { Agent as HttpsAgent } from "node:https";
|
|
21
|
+
import WebSocket from "ws";
|
|
22
|
+
import {
|
|
23
|
+
assertDirection,
|
|
24
|
+
checkTunnelMessage,
|
|
25
|
+
validateRestRequest,
|
|
26
|
+
type TunnelFrame,
|
|
27
|
+
} from "../../dpi-sdk/src/protocol/tunnel.js";
|
|
28
|
+
import {
|
|
29
|
+
CONNECTION_BUFFER_BUDGET_BYTES,
|
|
30
|
+
IDLE_TIMEOUT_MS,
|
|
31
|
+
LOGICAL_MESSAGE_MAX_BYTES,
|
|
32
|
+
PING_INTERVAL_MS,
|
|
33
|
+
PING_MISS_LIMIT,
|
|
34
|
+
RECONNECT_BACKOFF_S,
|
|
35
|
+
WS_BACKPRESSURE_BYTES,
|
|
36
|
+
WS_MAX_MESSAGE_BYTES,
|
|
37
|
+
WS_PACE_LOW_WATER_BYTES,
|
|
38
|
+
} from "../../dpi-sdk/src/protocol/limits.js";
|
|
39
|
+
import { Backoff } from "../../dpi-sdk/src/protocol/backoff.js";
|
|
40
|
+
import { loadJson, pairingPath, keypairPath, writeAtomic, type PairingFile, type DeviceKeypair } from "./pairing-files.js";
|
|
41
|
+
import { ByteBudget, SseReader, chunkMessage } from "../../dpi-sdk/src/node/chunk.js";
|
|
42
|
+
import { REST_BODY_MAX_BYTES as LOCAL_BODY_CAP } from "../../dpi-sdk/src/protocol/limits.js";
|
|
43
|
+
|
|
44
|
+
/** F07: the ONLY loopback endpoints a tunnel subscription may read (the
|
|
45
|
+
* three SSE scopes). Anything else the hub asks for is refused silently. */
|
|
46
|
+
const SUBSCRIBE_PATH_OK =
|
|
47
|
+
/^\/v1\/(?:events|sessions\/[^/?#]{1,128}\/events|sessions\/[^/?#]{1,128}\/agents\/[^/?#]{1,128}\/events)(?:\?[^\s?]{0,256})?$/;
|
|
48
|
+
|
|
49
|
+
/** The hub names the stream path, so it is judged by what the loopback server will resolve, not by the regex alone. */
|
|
50
|
+
function isSubscribePathOk(sub: string): boolean {
|
|
51
|
+
if (!SUBSCRIBE_PATH_OK.test(sub) || sub.includes("\\")) return false;
|
|
52
|
+
const path = sub.split("?", 1)[0]!;
|
|
53
|
+
return path.split("/").slice(1).every((segment) => {
|
|
54
|
+
let decoded: string;
|
|
55
|
+
try {
|
|
56
|
+
decoded = decodeURIComponent(segment);
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return segment !== "" && decoded !== "." && decoded !== ".." && !decoded.includes("/") && !decoded.includes("\\");
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Bounded clamp for server-advised 429 delays (04 §6: never trust raw body). */
|
|
65
|
+
const RETRY_AFTER_MIN_MS = 1_000;
|
|
66
|
+
const RETRY_AFTER_MAX_MS = 60_000;
|
|
67
|
+
/** TCP/TLS+upgrade completion timeout (not a CANON protocol number). */
|
|
68
|
+
const HANDSHAKE_TCP_TIMEOUT_MS = 10_000;
|
|
69
|
+
const AUTH_BODY_MAX_BYTES = 4_096;
|
|
70
|
+
/** Loopback REST deadline on the device (matches hub REST_TIMEOUT budget). */
|
|
71
|
+
const REST_LOCAL_TIMEOUT_MS = 320_000;
|
|
72
|
+
/** One WS message above this serialized size travels as chunks (the hub reassembles). */
|
|
73
|
+
const INLINE_FRAME_MAX_BYTES = 512 * 1024;
|
|
74
|
+
/** How often a paced sender re-checks the socket's outbound queue. */
|
|
75
|
+
const DRAIN_POLL_MS = 20;
|
|
76
|
+
/** After close(1011) a hub that is not reading never answers; the socket is torn down after this. */
|
|
77
|
+
const BACKPRESSURE_CLOSE_GRACE_MS = 1_000;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Read a loopback response as text without ever holding more than `cap` bytes: a declared length over the
|
|
81
|
+
* cap is refused on the headers, an undeclared one is cut off as soon as the bytes read pass it, and in both
|
|
82
|
+
* cases the loopback connection is abandoned.
|
|
83
|
+
*/
|
|
84
|
+
async function readTextCapped(res: Response, cap: number): Promise<{ ok: true; text: string } | { ok: false }> {
|
|
85
|
+
const declared = Number(res.headers.get("content-length"));
|
|
86
|
+
if (Number.isFinite(declared) && declared > cap) {
|
|
87
|
+
await res.body?.cancel().catch(() => {});
|
|
88
|
+
return { ok: false };
|
|
89
|
+
}
|
|
90
|
+
if (!res.body) return { ok: true, text: "" };
|
|
91
|
+
const reader = res.body.getReader();
|
|
92
|
+
const parts: Uint8Array[] = [];
|
|
93
|
+
let total = 0;
|
|
94
|
+
for (;;) {
|
|
95
|
+
const { done, value } = await reader.read();
|
|
96
|
+
if (done) break;
|
|
97
|
+
total += value.byteLength;
|
|
98
|
+
if (total > cap) {
|
|
99
|
+
await reader.cancel().catch(() => {});
|
|
100
|
+
return { ok: false };
|
|
101
|
+
}
|
|
102
|
+
parts.push(value);
|
|
103
|
+
}
|
|
104
|
+
return { ok: true, text: new TextDecoder().decode(Buffer.concat(parts, total)) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type ConnectorState =
|
|
108
|
+
| "stopped" // not started / stopped by operator or shutdown
|
|
109
|
+
| "file-error" // pairing.json unreadable at startup; no network attempts
|
|
110
|
+
| "connecting" // trying (with backoff between attempts)
|
|
111
|
+
| "connected" // valid hello received; transport up
|
|
112
|
+
| "reauth" // non-terminal auth trouble: token_unknown / bare 401 — re-pair is the actionable fix
|
|
113
|
+
| "revoked" // terminal: hub rejected the CURRENT credential tuple; pairing file removed
|
|
114
|
+
| "hub-mismatch"; // terminal: TLS-trusted endpoint answered with a different hubId
|
|
115
|
+
|
|
116
|
+
export interface ConnectorStatus {
|
|
117
|
+
state: ConnectorState;
|
|
118
|
+
detail: string;
|
|
119
|
+
attempts: number;
|
|
120
|
+
hellos: number;
|
|
121
|
+
lastConnectedAt: number | null;
|
|
122
|
+
nextAttemptInMs: number | null;
|
|
123
|
+
hubOrigin: string | null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface ConnectorHooks {
|
|
127
|
+
now?(): number;
|
|
128
|
+
sleep?(ms: number): Promise<void>;
|
|
129
|
+
/** Interval overrides are test-only deployment policy, never per-request. */
|
|
130
|
+
intervals?: { pingMs?: number; idleMs?: number; helloMs?: number; drainMs?: number };
|
|
131
|
+
onStatus?(status: ConnectorStatus): void;
|
|
132
|
+
/** The hub revoked the pairing of this epoch and the pairing file is gone. */
|
|
133
|
+
onRevoked?(pairingEpoch: string, reason: string): void;
|
|
134
|
+
/** A rotated credential was persisted and is now the live one. */
|
|
135
|
+
onRotated?(file: PairingFile): void;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type AttemptOutcome =
|
|
139
|
+
| { kind: "closed"; code: number; reason: string }
|
|
140
|
+
| { kind: "auth"; status: number; body: unknown; raw: string }
|
|
141
|
+
| { kind: "error"; message: string }
|
|
142
|
+
| { kind: "hello"; serverTime: number }
|
|
143
|
+
| { kind: "hub-id-mismatch"; sawHubId: string }
|
|
144
|
+
| { kind: "hello-timeout" }
|
|
145
|
+
| { kind: "stopped" };
|
|
146
|
+
|
|
147
|
+
interface Classified {
|
|
148
|
+
kind: "retry" | "reauth" | "revoked-terminal" | "hub-mismatch" | "stopped";
|
|
149
|
+
delayMs?: number;
|
|
150
|
+
detail: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const err = (code: string, message: string) => Object.assign(new Error(message), { code });
|
|
154
|
+
|
|
155
|
+
export class DeviceConnector {
|
|
156
|
+
private file: PairingFile | undefined;
|
|
157
|
+
private keypair: DeviceKeypair | undefined;
|
|
158
|
+
private state: ConnectorState = "stopped";
|
|
159
|
+
private detail = "";
|
|
160
|
+
private attempts = 0;
|
|
161
|
+
private hellos = 0;
|
|
162
|
+
private lastConnectedAt: number | null = null;
|
|
163
|
+
private stopped = true;
|
|
164
|
+
private loopRunning = false;
|
|
165
|
+
/** Resolves when the connect loop fully exits (reload chains start() on it). */
|
|
166
|
+
private loopIdle: Promise<void> = Promise.resolve();
|
|
167
|
+
private readonly backoff = new Backoff(RECONNECT_BACKOFF_S);
|
|
168
|
+
private ws: WebSocket | null = null;
|
|
169
|
+
private wsGeneration = 0;
|
|
170
|
+
private cancelAttempt: (() => void) | undefined;
|
|
171
|
+
private cancelBackoff: (() => void) | undefined;
|
|
172
|
+
private pingTimer: NodeJS.Timeout | null = null;
|
|
173
|
+
private idleTimer: NodeJS.Timeout | null = null;
|
|
174
|
+
private pongMisses = 0;
|
|
175
|
+
private lastByte = 0;
|
|
176
|
+
private readonly agent: HttpsAgent | undefined;
|
|
177
|
+
|
|
178
|
+
private readonly readers = new Map<string, AbortController>();
|
|
179
|
+
/** Loopback REST calls in flight for the current connection; the hub that asked is gone when the connection is. */
|
|
180
|
+
private readonly restCalls = new Set<AbortController>();
|
|
181
|
+
/** Bytes of unfinished loopback SSE events across all readers of this connector (04 §3.3, 32 MiB). */
|
|
182
|
+
private readonly ingress = new ByteBudget("connector-ingress", CONNECTION_BUFFER_BUDGET_BYTES);
|
|
183
|
+
/** Why the connector itself ended the current socket; reported once by `classify`. */
|
|
184
|
+
private closeCause: string | undefined;
|
|
185
|
+
|
|
186
|
+
constructor(
|
|
187
|
+
private readonly home: string,
|
|
188
|
+
caPem?: string,
|
|
189
|
+
private readonly hooks: ConnectorHooks = {},
|
|
190
|
+
private readonly localApi?: { baseUrl: string; token: string },
|
|
191
|
+
) {
|
|
192
|
+
// rejectUnauthorized stays true; a CA bundle narrows trust, it never
|
|
193
|
+
// disables verification.
|
|
194
|
+
if (caPem) this.agent = new HttpsAgent({ ca: caPem, rejectUnauthorized: true });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private get now() {
|
|
198
|
+
return this.hooks.now ?? Date.now;
|
|
199
|
+
}
|
|
200
|
+
private get pingMs() {
|
|
201
|
+
return this.hooks.intervals?.pingMs ?? PING_INTERVAL_MS;
|
|
202
|
+
}
|
|
203
|
+
private get idleMs() {
|
|
204
|
+
return this.hooks.intervals?.idleMs ?? IDLE_TIMEOUT_MS;
|
|
205
|
+
}
|
|
206
|
+
private get helloMs() {
|
|
207
|
+
return this.hooks.intervals?.helloMs ?? 10_000;
|
|
208
|
+
}
|
|
209
|
+
/** How long the outbound queue may sit without draining before the hub counts as not reading. */
|
|
210
|
+
private get drainMs() {
|
|
211
|
+
return this.hooks.intervals?.drainMs ?? IDLE_TIMEOUT_MS;
|
|
212
|
+
}
|
|
213
|
+
private sleep(ms: number): Promise<void> {
|
|
214
|
+
if (this.stopped) return Promise.resolve();
|
|
215
|
+
return new Promise<void>((done, fail) => {
|
|
216
|
+
let settled = false;
|
|
217
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
218
|
+
const finish = (error?: unknown) => {
|
|
219
|
+
if (settled) return;
|
|
220
|
+
settled = true;
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
if (this.cancelBackoff === cancel) this.cancelBackoff = undefined;
|
|
223
|
+
if (error === undefined) done();
|
|
224
|
+
else fail(error);
|
|
225
|
+
};
|
|
226
|
+
const cancel = () => finish();
|
|
227
|
+
this.cancelBackoff = cancel;
|
|
228
|
+
if (this.hooks.sleep) {
|
|
229
|
+
// Keep existing clock hooks compatible, but never let their late
|
|
230
|
+
// completion hold (or mutate) a replacement connector loop.
|
|
231
|
+
try {
|
|
232
|
+
void this.hooks.sleep(ms).then(cancel, finish);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
finish(error);
|
|
235
|
+
}
|
|
236
|
+
} else timer = setTimeout(cancel, ms);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
status(): ConnectorStatus {
|
|
241
|
+
return {
|
|
242
|
+
state: this.state,
|
|
243
|
+
detail: this.detail,
|
|
244
|
+
attempts: this.attempts,
|
|
245
|
+
hellos: this.hellos,
|
|
246
|
+
lastConnectedAt: this.lastConnectedAt,
|
|
247
|
+
nextAttemptInMs: null,
|
|
248
|
+
hubOrigin: this.file?.hubOrigin ?? null,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Load pairing/keypair from disk and run the connect loop. Safe to call at
|
|
254
|
+
* daemon boot; a bad file parks in `file-error` without any network attempt.
|
|
255
|
+
*/
|
|
256
|
+
start(): void {
|
|
257
|
+
if (!this.stopped) return;
|
|
258
|
+
if (this.loopRunning) {
|
|
259
|
+
const generation = this.wsGeneration;
|
|
260
|
+
void this.loopIdle.then(() => {
|
|
261
|
+
if (this.wsGeneration === generation) this.start();
|
|
262
|
+
});
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const pairing = loadJson<PairingFile>(pairingPath(this.home));
|
|
266
|
+
const keypair = loadJson<DeviceKeypair>(keypairPath(this.home));
|
|
267
|
+
if (!pairing?.tunnelToken || !pairing?.hubUrl || !pairing?.deviceUid || !pairing?.deviceId || !pairing?.pairingEpoch || !pairing?.credentialId || typeof pairing?.generation !== "number") {
|
|
268
|
+
this.setState("file-error", "pairing.json missing or malformed; run pairing again");
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (!keypair?.fingerprint || !keypair?.privateKeyPem) {
|
|
272
|
+
this.setState("file-error", "device keypair unreadable; re-pair required");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
this.file = pairing;
|
|
276
|
+
this.keypair = keypair;
|
|
277
|
+
this.stopped = false;
|
|
278
|
+
this.setState("connecting", "starting");
|
|
279
|
+
void this.loop();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Re-pair transition (F05): adopt a pairing file that changed on disk.
|
|
284
|
+
* A running connector must NOT keep the old credential/epoch in RAM — the
|
|
285
|
+
* old loop is stopped to completion, then a fresh loop starts from disk.
|
|
286
|
+
*/
|
|
287
|
+
reload(reason = "pairing file changed"): void {
|
|
288
|
+
if (this.stopped) {
|
|
289
|
+
this.start();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const onDisk = loadJson<PairingFile>(pairingPath(this.home));
|
|
293
|
+
if (!onDisk) {
|
|
294
|
+
this.stop(reason);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const sameTuple =
|
|
298
|
+
this.file &&
|
|
299
|
+
onDisk.pairingEpoch === this.file.pairingEpoch &&
|
|
300
|
+
onDisk.credentialId === this.file.credentialId &&
|
|
301
|
+
onDisk.generation === this.file.generation;
|
|
302
|
+
if (sameTuple) {
|
|
303
|
+
this.file = onDisk; // same credential, newer timestamp — nothing to swap
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
this.stop(`${reason}: swapping pairing`);
|
|
307
|
+
this.start(); // waits for the cancelled loop to exit before taking ownership
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Operator/shutdown stop: clear every timer, drop the socket, no retries. */
|
|
311
|
+
stop(reason = "stopped"): void {
|
|
312
|
+
this.stopped = true;
|
|
313
|
+
this.cancelAttempt?.();
|
|
314
|
+
this.cancelBackoff?.();
|
|
315
|
+
this.clearTimers();
|
|
316
|
+
this.dropSocket("stop");
|
|
317
|
+
this.setState("stopped", reason);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Rotation (P2-19) swaps the live credential without restarting the daemon.
|
|
322
|
+
* A 401 for the OLD tuple arriving after this point is a late response and
|
|
323
|
+
* must never be treated as terminal.
|
|
324
|
+
*/
|
|
325
|
+
updateCredential(file: PairingFile): void {
|
|
326
|
+
this.file = file;
|
|
327
|
+
this.backoff.reset();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// -- connect loop --------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
private async loop(): Promise<void> {
|
|
333
|
+
if (this.loopRunning) return;
|
|
334
|
+
this.loopRunning = true;
|
|
335
|
+
let releaseIdle: () => void;
|
|
336
|
+
this.loopIdle = new Promise<void>((r) => (releaseIdle = r));
|
|
337
|
+
try {
|
|
338
|
+
while (!this.stopped) {
|
|
339
|
+
const file = this.file;
|
|
340
|
+
if (!file) {
|
|
341
|
+
this.setState("file-error", "pairing.json vanished; run pairing again");
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
this.attempts++;
|
|
345
|
+
const attemptTuple = { credentialId: file.credentialId, pairingEpoch: file.pairingEpoch };
|
|
346
|
+
const outcome = await this.attemptOnce(file);
|
|
347
|
+
if (this.stopped) break;
|
|
348
|
+
const verdict = this.classify(outcome, attemptTuple);
|
|
349
|
+
if (verdict.kind === "stopped") break;
|
|
350
|
+
if (verdict.kind === "revoked-terminal") {
|
|
351
|
+
this.stopped = true;
|
|
352
|
+
this.setState("revoked", verdict.detail);
|
|
353
|
+
this.notify(() => this.hooks.onRevoked?.(attemptTuple.pairingEpoch, verdict.detail));
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
if (verdict.kind === "hub-mismatch") {
|
|
357
|
+
this.stopped = true;
|
|
358
|
+
this.setState("hub-mismatch", verdict.detail);
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
if (verdict.kind === "reauth") this.setState("reauth", verdict.detail);
|
|
362
|
+
else this.setState("connecting", verdict.detail);
|
|
363
|
+
// A verdict without its own delay (transport failure, 5xx, re-pair-needed 401) walks the shared curve.
|
|
364
|
+
const delay = verdict.delayMs ?? this.backoff.ms;
|
|
365
|
+
if (verdict.delayMs === undefined) this.backoff.advance();
|
|
366
|
+
await this.sleep(delay);
|
|
367
|
+
}
|
|
368
|
+
} finally {
|
|
369
|
+
this.loopRunning = false;
|
|
370
|
+
releaseIdle!();
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private attemptOnce(file: PairingFile): Promise<AttemptOutcome> {
|
|
375
|
+
return new Promise((resolve) => {
|
|
376
|
+
const generation = ++this.wsGeneration;
|
|
377
|
+
this.closeCause = undefined;
|
|
378
|
+
let settled = false;
|
|
379
|
+
// F06: settle on ANY terminal event, including cancellation. The old
|
|
380
|
+
// generation gate left the promise pending forever after dropSocket(),
|
|
381
|
+
// pinning loopRunning and making the connector un-restartable. Stale
|
|
382
|
+
// events after settle are already blocked by `settled`.
|
|
383
|
+
let socket: WebSocket | undefined;
|
|
384
|
+
const finish = (v: AttemptOutcome) => {
|
|
385
|
+
if (settled) return;
|
|
386
|
+
settled = true;
|
|
387
|
+
if (this.cancelAttempt === cancel) {
|
|
388
|
+
this.cancelAttempt = undefined;
|
|
389
|
+
this.clearTimers();
|
|
390
|
+
}
|
|
391
|
+
// Only a close the peer completed (`closed`) or an operator stop (dropSocket owns that socket) leaves the
|
|
392
|
+
// socket alone; every other verdict means this hub is not one we keep a connection to while backing off.
|
|
393
|
+
if (socket && v.kind !== "closed" && v.kind !== "stopped") this.hangUp(socket);
|
|
394
|
+
resolve(v);
|
|
395
|
+
};
|
|
396
|
+
const cancel = () => finish({ kind: "stopped" });
|
|
397
|
+
this.cancelAttempt = cancel;
|
|
398
|
+
let ws: WebSocket;
|
|
399
|
+
try {
|
|
400
|
+
ws = new WebSocket(file.hubUrl, {
|
|
401
|
+
headers: { authorization: `Bearer ${file.tunnelToken}` },
|
|
402
|
+
maxPayload: WS_MAX_MESSAGE_BYTES,
|
|
403
|
+
perMessageDeflate: false,
|
|
404
|
+
handshakeTimeout: HANDSHAKE_TCP_TIMEOUT_MS,
|
|
405
|
+
...(this.agent ? { agent: this.agent } : {}),
|
|
406
|
+
});
|
|
407
|
+
} catch (e) {
|
|
408
|
+
finish({ kind: "error", message: (e as Error).message });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
socket = ws;
|
|
412
|
+
this.ws = ws;
|
|
413
|
+
this.lastByte = this.now();
|
|
414
|
+
this.pongMisses = 0;
|
|
415
|
+
|
|
416
|
+
ws.on("unexpected-response", (_req, res) => {
|
|
417
|
+
// Redirects are never followed (one exact origin).
|
|
418
|
+
const chunks: Buffer[] = [];
|
|
419
|
+
let size = 0;
|
|
420
|
+
res.on("data", (c: Buffer) => {
|
|
421
|
+
size += c.length;
|
|
422
|
+
if (size <= AUTH_BODY_MAX_BYTES) chunks.push(c);
|
|
423
|
+
});
|
|
424
|
+
res.on("end", () => {
|
|
425
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
426
|
+
let body: unknown = null;
|
|
427
|
+
try {
|
|
428
|
+
body = JSON.parse(raw);
|
|
429
|
+
} catch {
|
|
430
|
+
body = null;
|
|
431
|
+
}
|
|
432
|
+
finish({ kind: "auth", status: res.statusCode ?? 0, body, raw });
|
|
433
|
+
});
|
|
434
|
+
res.on("error", () => finish({ kind: "auth", status: res.statusCode ?? 0, body: null, raw: "" }));
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
ws.on("error", (e) => finish({ kind: "error", message: e.message }));
|
|
438
|
+
ws.on("close", (code, reason) => finish({ kind: "closed", code, reason: Buffer.from(reason).toString("utf8") }));
|
|
439
|
+
|
|
440
|
+
ws.on("open", () => {
|
|
441
|
+
if (settled || this.wsGeneration !== generation) return;
|
|
442
|
+
// Identity handshake with the SAVED tuple; retry counter resets only
|
|
443
|
+
// after a schema-valid hello below.
|
|
444
|
+
const hs: TunnelFrame = {
|
|
445
|
+
type: "pair-handshake",
|
|
446
|
+
v: 1,
|
|
447
|
+
deviceUid: file.deviceUid,
|
|
448
|
+
deviceId: file.deviceId,
|
|
449
|
+
pairingEpoch: file.pairingEpoch,
|
|
450
|
+
credentialId: file.credentialId,
|
|
451
|
+
generation: file.generation,
|
|
452
|
+
apiVersion: 1,
|
|
453
|
+
tunnelProto: 1,
|
|
454
|
+
keyFingerprint: this.keypair?.fingerprint ?? "",
|
|
455
|
+
};
|
|
456
|
+
try {
|
|
457
|
+
ws.send(JSON.stringify(hs));
|
|
458
|
+
} catch (e) {
|
|
459
|
+
finish({ kind: "error", message: (e as Error).message });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
let helloDone = false;
|
|
463
|
+
const helloTimer = setTimeout(() => finish({ kind: "hello-timeout" }), this.helloMs);
|
|
464
|
+
this.idleTimer = helloTimer as NodeJS.Timeout;
|
|
465
|
+
ws.on("message", (data: Buffer) => {
|
|
466
|
+
if (settled || this.wsGeneration !== generation) return;
|
|
467
|
+
this.lastByte = this.now();
|
|
468
|
+
let msg: unknown;
|
|
469
|
+
try {
|
|
470
|
+
msg = JSON.parse(data.toString("utf8"));
|
|
471
|
+
} catch {
|
|
472
|
+
finish({ kind: "error", message: helloDone ? "frame: malformed JSON" : "hello: malformed JSON" });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (!checkTunnelMessage(msg)) {
|
|
476
|
+
finish({ kind: "error", message: helloDone ? "frame: schema violation" : "hello: schema violation" });
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const frame = msg as TunnelFrame;
|
|
480
|
+
if (!helloDone) {
|
|
481
|
+
if (frame.type !== "hello") {
|
|
482
|
+
finish({ kind: "error", message: "hello: expected hello first" });
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (frame.hubId !== file.hubId) {
|
|
486
|
+
finish({ kind: "hub-id-mismatch", sawHubId: frame.hubId });
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
// Connected: heartbeat + idle safety net from here (04 §5).
|
|
490
|
+
helloDone = true;
|
|
491
|
+
clearTimeout(helloTimer);
|
|
492
|
+
this.hellos++;
|
|
493
|
+
this.backoff.reset();
|
|
494
|
+
this.lastConnectedAt = this.now();
|
|
495
|
+
this.setState("connected", `connectionId ${frame.connectionId}`);
|
|
496
|
+
this.lastByte = this.now();
|
|
497
|
+
this.startHeartbeat(ws, generation);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
// Open phase: only hub→device frames; dispatch to REST/SSE readers.
|
|
501
|
+
try {
|
|
502
|
+
assertDirection(frame, "hub->device");
|
|
503
|
+
} catch (e) {
|
|
504
|
+
this.setState("connecting", `protocol violation: ${(e as Error).message}`);
|
|
505
|
+
try {
|
|
506
|
+
ws.close(1008, "direction violation");
|
|
507
|
+
} catch {}
|
|
508
|
+
finish({ kind: "error", message: (e as Error).message });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
this.dispatch(ws, generation, frame);
|
|
512
|
+
});
|
|
513
|
+
ws.on("pong", () => {
|
|
514
|
+
if (settled || this.wsGeneration !== generation) return;
|
|
515
|
+
this.lastByte = this.now();
|
|
516
|
+
this.pongMisses = 0;
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Hub→device dispatch: rest-request → loopback API; control:subscribe →
|
|
524
|
+
* loopback SSE reader tagged with the hub's subscriptionId. The connector
|
|
525
|
+
* injects the LOCAL device token; the hub's caller identity never reaches
|
|
526
|
+
* the device API (P2-16 hardens ownership/caps).
|
|
527
|
+
*/
|
|
528
|
+
private dispatch(ws: WebSocket, generation: number, frame: TunnelFrame): void {
|
|
529
|
+
// Immediate send for small replies and control acks: refused (and the connection cut) when the
|
|
530
|
+
// hub has already left the outbound queue past the cap.
|
|
531
|
+
const send = (payload: unknown) => this.sendNow(ws, generation, payload);
|
|
532
|
+
// Paced send for payload-sized frames: waits, buffering nothing more, while the queue is above the pacing mark.
|
|
533
|
+
const sendPaced = (payload: unknown) => this.sendPaced(ws, generation, payload);
|
|
534
|
+
const api = this.localApi;
|
|
535
|
+
if (!api) return; // no local API wired (unit contexts)
|
|
536
|
+
if (frame.type === "rest-request") {
|
|
537
|
+
void (async () => {
|
|
538
|
+
const check = validateRestRequest({ method: frame.method, path: frame.path });
|
|
539
|
+
if (!check.ok) {
|
|
540
|
+
send({ type: "rest-response", reqId: frame.reqId, status: 404, body: { error: { code: "invalid", message: check.error } } });
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const bodyBytes = frame.body === undefined ? 0 : Buffer.byteLength(JSON.stringify(frame.body), "utf8");
|
|
544
|
+
if (bodyBytes > LOCAL_BODY_CAP) {
|
|
545
|
+
send({ type: "rest-response", reqId: frame.reqId, status: 413, body: { error: { code: "invalid", message: `Request body exceeds ${LOCAL_BODY_CAP} bytes` } } });
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const tooLarge = (message: string) =>
|
|
549
|
+
send({ type: "rest-response", reqId: frame.reqId, status: 502, body: { error: { code: "response_too_large", message } } });
|
|
550
|
+
const call = new AbortController();
|
|
551
|
+
this.restCalls.add(call);
|
|
552
|
+
try {
|
|
553
|
+
const hasBody = !["GET", "HEAD", "DELETE"].includes(frame.method);
|
|
554
|
+
const res = await fetch(api.baseUrl + frame.path, {
|
|
555
|
+
method: frame.method,
|
|
556
|
+
headers: {
|
|
557
|
+
authorization: `Bearer ${api.token}`,
|
|
558
|
+
"x-dpi-token": api.token,
|
|
559
|
+
...(hasBody && frame.body !== undefined ? { "content-type": "application/json" } : {}),
|
|
560
|
+
...(frame.idempotencyKey ? { "idempotency-key": frame.idempotencyKey } : {}),
|
|
561
|
+
},
|
|
562
|
+
body: hasBody && frame.body !== undefined ? JSON.stringify(frame.body) : undefined,
|
|
563
|
+
signal: AbortSignal.any([call.signal, AbortSignal.timeout(REST_LOCAL_TIMEOUT_MS)]),
|
|
564
|
+
redirect: "manual",
|
|
565
|
+
});
|
|
566
|
+
if (res.status >= 300 && res.status < 400) {
|
|
567
|
+
await res.body?.cancel().catch(() => {});
|
|
568
|
+
send({ type: "rest-response", reqId: frame.reqId, status: 502, body: { error: { code: "offline", message: `Device API answered with a redirect (${res.status}); redirects are not followed` } } });
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const read = await readTextCapped(res, LOGICAL_MESSAGE_MAX_BYTES);
|
|
572
|
+
if (!read.ok) {
|
|
573
|
+
tooLarge(`Device response exceeds ${LOGICAL_MESSAGE_MAX_BYTES} bytes`);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
// Small responses ride one frame; large ones chunk (hub reassembles).
|
|
577
|
+
const envelope = { type: "rest-response", reqId: frame.reqId, status: res.status, contentType: res.headers.get("content-type") ?? undefined, body: read.text } as const;
|
|
578
|
+
const serialized = JSON.stringify(envelope);
|
|
579
|
+
if (Buffer.byteLength(serialized, "utf8") <= INLINE_FRAME_MAX_BYTES) {
|
|
580
|
+
await sendPaced(serialized);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const plan = chunkMessage(envelope, { kind: "rest-response", reqId: frame.reqId });
|
|
584
|
+
if (!plan.ok) {
|
|
585
|
+
tooLarge(plan.error);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
for (const chunk of plan.chunks) if (!(await sendPaced(chunk))) return;
|
|
589
|
+
} catch (e) {
|
|
590
|
+
send({ type: "rest-response", reqId: frame.reqId, status: 502, body: { error: { code: "offline", message: (e as Error).message } } });
|
|
591
|
+
} finally {
|
|
592
|
+
this.restCalls.delete(call);
|
|
593
|
+
}
|
|
594
|
+
})();
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
if (frame.type === "control" && frame.action === "subscribe") {
|
|
598
|
+
const existing = this.readers.get(frame.subscriptionId);
|
|
599
|
+
if (existing) return; // idempotent repeat
|
|
600
|
+
// F07: the hub names the STREAM PATH; scope must reach the device API.
|
|
601
|
+
// Allow-list the three SSE endpoints — a hub can never aim the reader
|
|
602
|
+
// at REST-only or pairing routes.
|
|
603
|
+
if (!isSubscribePathOk(frame.sub)) return;
|
|
604
|
+
const ctl = new AbortController();
|
|
605
|
+
this.readers.set(frame.subscriptionId, ctl);
|
|
606
|
+
void (async () => {
|
|
607
|
+
// Raw bytes are bounded before they are framed or parsed; what an unfinished event holds is charged
|
|
608
|
+
// to the connector-wide budget and released when this reader ends.
|
|
609
|
+
const sse = new SseReader(this.ingress);
|
|
610
|
+
// This reader failed, the tunnel did not: say so for this subscription only and stop reading.
|
|
611
|
+
const fault = (message: string) => {
|
|
612
|
+
send({ type: "tunnel-data", frame: { type: "fault", error: { code: "invalid", message } }, subscriptionId: frame.subscriptionId, sub: frame.sub });
|
|
613
|
+
ctl.abort();
|
|
614
|
+
};
|
|
615
|
+
try {
|
|
616
|
+
const res = await fetch(api.baseUrl + frame.sub, {
|
|
617
|
+
headers: { authorization: `Bearer ${api.token}`, "x-dpi-token": api.token, accept: "text/event-stream" },
|
|
618
|
+
signal: ctl.signal,
|
|
619
|
+
});
|
|
620
|
+
if (!res.ok || !res.body) throw new Error(`${frame.sub} answered ${res.status}`);
|
|
621
|
+
const reader = res.body.getReader();
|
|
622
|
+
for (;;) {
|
|
623
|
+
const { done, value } = await reader.read();
|
|
624
|
+
if (done) break;
|
|
625
|
+
const fed = sse.feed(value);
|
|
626
|
+
if (fed.error) return fault(fed.error);
|
|
627
|
+
for (const event of fed.events) {
|
|
628
|
+
if (!event.data) continue;
|
|
629
|
+
let parsed: unknown;
|
|
630
|
+
try {
|
|
631
|
+
parsed = JSON.parse(event.data);
|
|
632
|
+
} catch {
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
const wire = JSON.stringify({ type: "tunnel-data", frame: parsed, subscriptionId: frame.subscriptionId, sub: frame.sub });
|
|
636
|
+
if (Buffer.byteLength(wire, "utf8") <= INLINE_FRAME_MAX_BYTES) {
|
|
637
|
+
if (!(await sendPaced(wire))) return;
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
// One WS message ≤1 MiB: large frames (big trees) chunk, one paced frame at a time.
|
|
641
|
+
const plan = chunkMessage(parsed, { kind: "tunnel-data", subscriptionId: frame.subscriptionId, sub: frame.sub });
|
|
642
|
+
if (!plan.ok) return fault(plan.error);
|
|
643
|
+
for (const chunk of plan.chunks) if (!(await sendPaced(chunk))) return;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
} catch {
|
|
647
|
+
/* aborted or connection lost: the hub will resubscribe on the new connection */
|
|
648
|
+
} finally {
|
|
649
|
+
sse.close();
|
|
650
|
+
ctl.abort();
|
|
651
|
+
if (this.readers.get(frame.subscriptionId) === ctl) this.readers.delete(frame.subscriptionId);
|
|
652
|
+
}
|
|
653
|
+
})();
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (frame.type === "control" && frame.action === "unsubscribe") {
|
|
657
|
+
this.readers.get(frame.subscriptionId)?.abort();
|
|
658
|
+
this.readers.delete(frame.subscriptionId);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (frame.type === "control" && frame.action === "token-rotate") {
|
|
662
|
+
// Rotation (03 §8.2): accept only a STRICTLY NEWER generation of the
|
|
663
|
+
// active pairing (after a hub cache-loss recovery the jump can be >1);
|
|
664
|
+
// persist atomically (fsync chain) BEFORE acking, then live-swap.
|
|
665
|
+
const file = this.file;
|
|
666
|
+
if (
|
|
667
|
+
!file ||
|
|
668
|
+
frame.pairingEpoch !== file.pairingEpoch ||
|
|
669
|
+
frame.generation <= file.generation
|
|
670
|
+
) {
|
|
671
|
+
// Stale/duplicate/foreign rotation: ignore entirely — no ack, no write.
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const next: PairingFile = {
|
|
675
|
+
...file,
|
|
676
|
+
credentialId: frame.credentialId,
|
|
677
|
+
generation: frame.generation,
|
|
678
|
+
tunnelToken: frame.tunnelToken,
|
|
679
|
+
updatedAt: this.now(),
|
|
680
|
+
};
|
|
681
|
+
try {
|
|
682
|
+
writeAtomic(pairingPath(this.home), JSON.stringify(next, null, 2));
|
|
683
|
+
} catch {
|
|
684
|
+
// Persist failed: keep using the old credential; the hub will resend
|
|
685
|
+
// or regenerate on the next reconnect (previous stays previous).
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
this.file = next;
|
|
689
|
+
this.backoff.reset();
|
|
690
|
+
this.notify(() => this.hooks.onRotated?.(next));
|
|
691
|
+
send({
|
|
692
|
+
type: "control",
|
|
693
|
+
action: "token-rotate-ack",
|
|
694
|
+
rotationId: frame.rotationId,
|
|
695
|
+
credentialId: frame.credentialId,
|
|
696
|
+
generation: frame.generation,
|
|
697
|
+
pairingEpoch: frame.pairingEpoch,
|
|
698
|
+
});
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
if (frame.type === "control" && frame.action === "revoked") {
|
|
702
|
+
// Terminal ONLY when the notice matches the CURRENT pairing epoch; a
|
|
703
|
+
// stale notice for a superseded pairing must not touch the new one.
|
|
704
|
+
const file = this.file;
|
|
705
|
+
if (!file || frame.notice.pairingEpoch !== file.pairingEpoch) return;
|
|
706
|
+
// F05 disk-truth: the RAM tuple may be stale — a re-pair may already
|
|
707
|
+
// have replaced the file. Only delete what the notice is actually for.
|
|
708
|
+
const onDisk = loadJson<PairingFile>(pairingPath(this.home));
|
|
709
|
+
if (!onDisk || onDisk.pairingEpoch !== frame.notice.pairingEpoch) {
|
|
710
|
+
// A NEWER pairing is on disk: adopt it and keep serving, never die on
|
|
711
|
+
// a stale-epoch notice and never delete the newer file.
|
|
712
|
+
this.reload("re-pair raced a revoke notice");
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
this.stopped = true;
|
|
716
|
+
this.cancelAttempt?.();
|
|
717
|
+
this.clearTimers();
|
|
718
|
+
this.dropSocket("revoked");
|
|
719
|
+
try {
|
|
720
|
+
rmSync(pairingPath(this.home), { force: true }); // matching epoch: drop, keep keypair
|
|
721
|
+
} catch {
|
|
722
|
+
/* best effort */
|
|
723
|
+
}
|
|
724
|
+
this.setState("revoked", "hub revoked this pairing; pairing removed — re-pair to continue");
|
|
725
|
+
this.notify(() => this.hooks.onRevoked?.(file.pairingEpoch, this.detail));
|
|
726
|
+
// Cancellation settles immediately, even if the peer never completes close.
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
// unpair control frames land with the forced-hub-change flow (P2-21).
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Send now, if this socket is still the live one. A queue already past the cap means the hub stopped
|
|
734
|
+
* reading: the connection is cut (04 §10) instead of queueing behind it.
|
|
735
|
+
*/
|
|
736
|
+
private sendNow(ws: WebSocket, generation: number, payload: unknown): boolean {
|
|
737
|
+
if (this.wsGeneration !== generation || ws.readyState !== ws.OPEN) return false;
|
|
738
|
+
if (ws.bufferedAmount > WS_BACKPRESSURE_BYTES) {
|
|
739
|
+
this.closeForBackpressure(ws, generation, "outbound queue over cap");
|
|
740
|
+
return false;
|
|
741
|
+
}
|
|
742
|
+
try {
|
|
743
|
+
ws.send(typeof payload === "string" ? payload : JSON.stringify(payload));
|
|
744
|
+
return true;
|
|
745
|
+
} catch {
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Send once the outbound queue is at or below the pacing mark, waiting (and buffering nothing more) until
|
|
752
|
+
* it is. Waiting also stops the caller reading its loopback source, so pressure reaches the local API.
|
|
753
|
+
* False when the connection went away, or the queue stopped draining for `drainMs` (the hub is not reading).
|
|
754
|
+
*/
|
|
755
|
+
private async sendPaced(ws: WebSocket, generation: number, payload: unknown): Promise<boolean> {
|
|
756
|
+
let lowest = ws.bufferedAmount;
|
|
757
|
+
let progressAt = this.now();
|
|
758
|
+
while (ws.bufferedAmount > WS_PACE_LOW_WATER_BYTES) {
|
|
759
|
+
if (this.wsGeneration !== generation || ws.readyState !== ws.OPEN) return false;
|
|
760
|
+
if (ws.bufferedAmount < lowest) {
|
|
761
|
+
lowest = ws.bufferedAmount;
|
|
762
|
+
progressAt = this.now();
|
|
763
|
+
} else if (this.now() - progressAt >= this.drainMs) {
|
|
764
|
+
this.closeForBackpressure(ws, generation, `outbound queue did not drain for ${this.drainMs} ms`);
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
767
|
+
await new Promise<void>((resolve) => setTimeout(resolve, DRAIN_POLL_MS));
|
|
768
|
+
}
|
|
769
|
+
return this.sendNow(ws, generation, payload);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** Slow-consumer cut (04 §10): stop the producers, close 1011, and let the connect loop back off and retry. */
|
|
773
|
+
private closeForBackpressure(ws: WebSocket, generation: number, why: string): void {
|
|
774
|
+
if (this.wsGeneration !== generation || ws.readyState !== ws.OPEN) return;
|
|
775
|
+
this.closeCause = "backpressure";
|
|
776
|
+
this.setState("connecting", `backpressure: ${why}`);
|
|
777
|
+
for (const ctl of this.readers.values()) ctl.abort();
|
|
778
|
+
this.readers.clear();
|
|
779
|
+
try {
|
|
780
|
+
ws.close(1011, "backpressure");
|
|
781
|
+
} catch {
|
|
782
|
+
/* already closing */
|
|
783
|
+
}
|
|
784
|
+
// A hub that is not reading never answers the close frame; drop the socket (and its queued bytes) anyway.
|
|
785
|
+
const force = setTimeout(() => {
|
|
786
|
+
try {
|
|
787
|
+
ws.terminate();
|
|
788
|
+
} catch {
|
|
789
|
+
/* already gone */
|
|
790
|
+
}
|
|
791
|
+
}, BACKPRESSURE_CLOSE_GRACE_MS);
|
|
792
|
+
force.unref();
|
|
793
|
+
ws.once("close", () => clearTimeout(force));
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
private startHeartbeat(ws: WebSocket, generation: number): void {
|
|
797
|
+
this.pingTimer = setInterval(() => {
|
|
798
|
+
if (this.wsGeneration !== generation || ws.readyState !== ws.OPEN) return;
|
|
799
|
+
if (this.pongMisses >= PING_MISS_LIMIT) {
|
|
800
|
+
try {
|
|
801
|
+
ws.terminate(); // half-open: no (p)ong for ~2 intervals
|
|
802
|
+
} catch {
|
|
803
|
+
/* already gone */
|
|
804
|
+
}
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
this.pongMisses++;
|
|
808
|
+
try {
|
|
809
|
+
ws.ping();
|
|
810
|
+
} catch {
|
|
811
|
+
/* close event will settle */
|
|
812
|
+
}
|
|
813
|
+
}, this.pingMs);
|
|
814
|
+
this.idleTimer = setInterval(() => {
|
|
815
|
+
if (this.now() - this.lastByte > this.idleMs) {
|
|
816
|
+
try {
|
|
817
|
+
ws.terminate();
|
|
818
|
+
} catch {
|
|
819
|
+
/* already gone */
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}, Math.min(this.idleMs, Math.max(200, Math.floor(this.idleMs / 4))));
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
private classify(
|
|
826
|
+
outcome: AttemptOutcome,
|
|
827
|
+
attemptTuple: { credentialId: string; pairingEpoch: string },
|
|
828
|
+
): Classified {
|
|
829
|
+
if (outcome.kind === "stopped") return { kind: "stopped", detail: "attempt cancelled" };
|
|
830
|
+
if (outcome.kind === "hello") return { kind: "retry", delayMs: 0, detail: "hello" };
|
|
831
|
+
if (outcome.kind === "closed") {
|
|
832
|
+
// A slow-consumer cut may end as a forced terminate (1006); the cause is ours, so say that.
|
|
833
|
+
const cause = this.closeCause;
|
|
834
|
+
this.closeCause = undefined;
|
|
835
|
+
if (cause) return { kind: "retry", detail: `closed 1011 ${cause}` };
|
|
836
|
+
// 1000 "superseded" is the hub's connection swap: reconnect normally.
|
|
837
|
+
return { kind: "retry", detail: `closed ${outcome.code} ${outcome.reason}`.trim() };
|
|
838
|
+
}
|
|
839
|
+
if (outcome.kind === "error") return { kind: "retry", detail: outcome.message };
|
|
840
|
+
if (outcome.kind === "hello-timeout") return { kind: "retry", detail: "no hello within deadline" };
|
|
841
|
+
if (outcome.kind === "hub-id-mismatch")
|
|
842
|
+
return { kind: "hub-mismatch", detail: `hub answered with a different hubId (${outcome.sawHubId}); check the pair URL — this is not the hub you paired with` };
|
|
843
|
+
// auth-shaped outcomes
|
|
844
|
+
const body = outcome.body as { type?: string; code?: string; retryAfterMs?: number } | null;
|
|
845
|
+
const parsed = body?.type === "tunnel.auth-error" ? body : null;
|
|
846
|
+
if (outcome.status === 429 || parsed?.code === "rate_limited") {
|
|
847
|
+
const advised = Number(parsed?.retryAfterMs ?? 0);
|
|
848
|
+
const delay = Number.isFinite(advised) && advised > 0
|
|
849
|
+
? Math.max(RETRY_AFTER_MIN_MS, Math.min(RETRY_AFTER_MAX_MS, advised))
|
|
850
|
+
: RETRY_AFTER_MAX_MS;
|
|
851
|
+
return { kind: "retry", delayMs: delay, detail: "rate limited; honoring bounded retryAfter" };
|
|
852
|
+
}
|
|
853
|
+
if (outcome.status >= 300 && outcome.status < 400)
|
|
854
|
+
return { kind: "retry", detail: `redirect (${outcome.status}) rejected: connector uses one exact origin` };
|
|
855
|
+
if (parsed?.code === "revoked") {
|
|
856
|
+
// Terminal ONLY when this attempt's tuple still matches the CURRENT file.
|
|
857
|
+
const current = this.file;
|
|
858
|
+
if (current && current.credentialId === attemptTuple.credentialId && current.pairingEpoch === attemptTuple.pairingEpoch) {
|
|
859
|
+
// F05 disk-truth: a re-pair may have replaced the file mid-flight.
|
|
860
|
+
const onDisk = loadJson<PairingFile>(pairingPath(this.home));
|
|
861
|
+
if (onDisk && (onDisk.credentialId !== attemptTuple.credentialId || onDisk.pairingEpoch !== attemptTuple.pairingEpoch)) {
|
|
862
|
+
this.file = onDisk; // adopt the newer pairing and keep going
|
|
863
|
+
this.backoff.reset();
|
|
864
|
+
return { kind: "retry", delayMs: 0, detail: "revoked for an old credential; a newer pairing file is on disk" };
|
|
865
|
+
}
|
|
866
|
+
this.removePairingFile();
|
|
867
|
+
return { kind: "revoked-terminal", detail: "hub revoked this credential; pairing removed — re-pair to continue" };
|
|
868
|
+
}
|
|
869
|
+
return { kind: "retry", delayMs: this.backoff.ms, detail: "late revoked response for a replaced credential; ignored" };
|
|
870
|
+
}
|
|
871
|
+
if (parsed?.code === "token_unknown" || outcome.status === 401) {
|
|
872
|
+
// Non-terminal, actionable: the operator can re-pair any time; the
|
|
873
|
+
// connector keeps trying with backoff and never hides that fact.
|
|
874
|
+
return { kind: "reauth", detail: parsed?.code === "token_unknown" ? "hub does not recognize this credential anymore; re-pair when convenient (still retrying)" : "bare 401 without a parsable auth-error body; still retrying" };
|
|
875
|
+
}
|
|
876
|
+
return { kind: "retry", detail: `upgrade failed with HTTP ${outcome.status}` };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
private removePairingFile(): void {
|
|
880
|
+
try {
|
|
881
|
+
rmSync(pairingPath(this.home), { force: true });
|
|
882
|
+
} catch {
|
|
883
|
+
/* best effort; status already says revoked */
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
private clearTimers(): void {
|
|
888
|
+
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
889
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
890
|
+
this.pingTimer = null;
|
|
891
|
+
this.idleTimer = null;
|
|
892
|
+
for (const ctl of this.readers.values()) ctl.abort();
|
|
893
|
+
this.readers.clear();
|
|
894
|
+
for (const call of this.restCalls) call.abort();
|
|
895
|
+
this.restCalls.clear();
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** Drop a socket we are done with. One already in a close handshake (we sent the close frame) gets a short grace to finish it. */
|
|
899
|
+
private hangUp(ws: WebSocket): void {
|
|
900
|
+
if (ws.readyState === ws.CLOSED) return;
|
|
901
|
+
try {
|
|
902
|
+
if (ws.readyState !== ws.CLOSING) {
|
|
903
|
+
ws.terminate();
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
const force = setTimeout(() => ws.terminate(), BACKPRESSURE_CLOSE_GRACE_MS);
|
|
907
|
+
force.unref();
|
|
908
|
+
ws.once("close", () => clearTimeout(force));
|
|
909
|
+
} catch {
|
|
910
|
+
/* already gone */
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
private dropSocket(reason: string): void {
|
|
915
|
+
const ws = this.ws;
|
|
916
|
+
this.ws = null;
|
|
917
|
+
this.wsGeneration++;
|
|
918
|
+
if (ws && (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING)) {
|
|
919
|
+
try {
|
|
920
|
+
if (ws.readyState === ws.OPEN) ws.close(1000, reason.slice(0, 120));
|
|
921
|
+
else ws.terminate();
|
|
922
|
+
} catch {
|
|
923
|
+
/* already gone */
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** Observers never get to fail the connector. */
|
|
929
|
+
private notify(call: () => void): void {
|
|
930
|
+
try {
|
|
931
|
+
call();
|
|
932
|
+
} catch {
|
|
933
|
+
/* best effort */
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
private setState(state: ConnectorState, detail: string): void {
|
|
938
|
+
this.state = state;
|
|
939
|
+
this.detail = detail;
|
|
940
|
+
this.hooks.onStatus?.(this.status());
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** sha256 hex of a token — pairing parity helper (used by tests/tools). */
|
|
945
|
+
export const tokenHash = (token: string) => createHash("sha256").update(token, "utf8").digest("hex");
|
|
946
|
+
|
|
947
|
+
export { err as connectorError };
|
|
948
|
+
export const readCaPem = (path: string | undefined): string | undefined => {
|
|
949
|
+
if (!path) return undefined;
|
|
950
|
+
try {
|
|
951
|
+
return readFileSync(path, "utf8");
|
|
952
|
+
} catch {
|
|
953
|
+
return undefined;
|
|
954
|
+
}
|
|
955
|
+
};
|