my-durable 0.0.6 → 0.0.8
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/README.md +6 -0
- package/apps/dpi-backend/src/config.ts +10 -3
- package/apps/dpi-backend/src/daemon.ts +422 -0
- package/apps/dpi-backend/src/main.ts +148 -7
- package/apps/dpi-backend/src/pair-page.ts +111 -0
- package/apps/dpi-backend/src/pairing-files.ts +39 -9
- package/apps/dpi-backend/src/pairing.ts +368 -59
- package/apps/dpi-backend/src/routes.ts +14 -1
- package/apps/dpi-backend/src/store.ts +1 -0
- package/apps/dpi-backend/src/tunnel.ts +294 -55
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Cg3vey95.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CoYMgU-2.js +77 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +17 -4
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +29 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +107 -18
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +1 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceHandoff.vue +132 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +36 -6
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +107 -5
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +3 -1
- package/apps/dpi-playground/apps/dpi-playground-hub/src/admin-auth.ts +19 -17
- package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +1 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +4 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/hub-identity.ts +98 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +31 -3
- package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +172 -25
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +24 -0
- package/apps/dpi-sdk/src/api.ts +2 -0
- package/apps/dpi-sdk/src/node/private-file.ts +140 -0
- package/apps/dpi-sdk/src/node/registry-migration.ts +9 -0
- package/apps/dpi-sdk/src/node/registry.ts +6 -0
- package/apps/dpi-sdk/src/protocol/pairing.ts +137 -16
- package/apps/dpi-sdk/src/protocol/tunnel.ts +162 -38
- package/apps/dpi-tui/src/app.ts +889 -187
- package/apps/dpi-tui/src/hub-feed.ts +71 -0
- package/apps/dpi-tui/src/hub-mux.ts +168 -0
- package/apps/dpi-tui/src/main.ts +5 -1
- package/apps/dpi-tui/src/theme.ts +14 -0
- package/apps/dpi-tui/src/views.ts +311 -0
- package/bin/dpi.mjs +8 -3
- package/package.json +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index--aoZ5Tj0.css +0 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Dq0CZYr7.js +0 -77
|
@@ -276,6 +276,7 @@ export class Store {
|
|
|
276
276
|
startDirs: this.startDirs,
|
|
277
277
|
models: this.modelList,
|
|
278
278
|
defaultModel: this.defaultModel(),
|
|
279
|
+
provider: this.options.provider,
|
|
279
280
|
storage: { dataDir: this.options.dataDir, catalogPath: this.catalogPath, appStatePath: this.sharedStatePath },
|
|
280
281
|
codeDir: CODE_DIR,
|
|
281
282
|
capabilities: ["events", "docs", "demo", "jobs"],
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* `token_unknown` and bare 401s stay non-terminal with an actionable re-pair
|
|
16
16
|
* status instead of an invisible infinite retry.
|
|
17
17
|
*/
|
|
18
|
-
import { createHash } from "node:crypto";
|
|
18
|
+
import { createHash, createPublicKey, randomBytes, verify as edVerify } from "node:crypto";
|
|
19
19
|
import { readFileSync, rmSync } from "node:fs";
|
|
20
20
|
import { Agent as HttpsAgent } from "node:https";
|
|
21
21
|
import WebSocket from "ws";
|
|
@@ -37,7 +37,15 @@ import {
|
|
|
37
37
|
WS_PACE_LOW_WATER_BYTES,
|
|
38
38
|
} from "../../dpi-sdk/src/protocol/limits.js";
|
|
39
39
|
import { Backoff } from "../../dpi-sdk/src/protocol/backoff.js";
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
loadJson,
|
|
42
|
+
pairingPath,
|
|
43
|
+
keypairPath,
|
|
44
|
+
writeAtomic,
|
|
45
|
+
type PairingFile,
|
|
46
|
+
type DeviceKeypair,
|
|
47
|
+
} from "./pairing-files.js";
|
|
48
|
+
import { hubProofSigningPayload } from "../../dpi-sdk/src/protocol/pairing.js";
|
|
41
49
|
import { ByteBudget, SseReader, chunkMessage } from "../../dpi-sdk/src/node/chunk.js";
|
|
42
50
|
import { REST_BODY_MAX_BYTES as LOCAL_BODY_CAP } from "../../dpi-sdk/src/protocol/limits.js";
|
|
43
51
|
|
|
@@ -50,15 +58,24 @@ const SUBSCRIBE_PATH_OK =
|
|
|
50
58
|
function isSubscribePathOk(sub: string): boolean {
|
|
51
59
|
if (!SUBSCRIBE_PATH_OK.test(sub) || sub.includes("\\")) return false;
|
|
52
60
|
const path = sub.split("?", 1)[0]!;
|
|
53
|
-
return path
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
return path
|
|
62
|
+
.split("/")
|
|
63
|
+
.slice(1)
|
|
64
|
+
.every((segment) => {
|
|
65
|
+
let decoded: string;
|
|
66
|
+
try {
|
|
67
|
+
decoded = decodeURIComponent(segment);
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
return (
|
|
72
|
+
segment !== "" &&
|
|
73
|
+
decoded !== "." &&
|
|
74
|
+
decoded !== ".." &&
|
|
75
|
+
!decoded.includes("/") &&
|
|
76
|
+
!decoded.includes("\\")
|
|
77
|
+
);
|
|
78
|
+
});
|
|
62
79
|
}
|
|
63
80
|
|
|
64
81
|
/** Bounded clamp for server-advised 429 delays (04 §6: never trust raw body). */
|
|
@@ -81,7 +98,10 @@ const BACKPRESSURE_CLOSE_GRACE_MS = 1_000;
|
|
|
81
98
|
* cap is refused on the headers, an undeclared one is cut off as soon as the bytes read pass it, and in both
|
|
82
99
|
* cases the loopback connection is abandoned.
|
|
83
100
|
*/
|
|
84
|
-
async function readTextCapped(
|
|
101
|
+
async function readTextCapped(
|
|
102
|
+
res: Response,
|
|
103
|
+
cap: number,
|
|
104
|
+
): Promise<{ ok: true; text: string } | { ok: false }> {
|
|
85
105
|
const declared = Number(res.headers.get("content-length"));
|
|
86
106
|
if (Number.isFinite(declared) && declared > cap) {
|
|
87
107
|
await res.body?.cancel().catch(() => {});
|
|
@@ -111,7 +131,8 @@ export type ConnectorState =
|
|
|
111
131
|
| "connected" // valid hello received; transport up
|
|
112
132
|
| "reauth" // non-terminal auth trouble: token_unknown / bare 401 — re-pair is the actionable fix
|
|
113
133
|
| "revoked" // terminal: hub rejected the CURRENT credential tuple; pairing file removed
|
|
114
|
-
| "hub-mismatch"
|
|
134
|
+
| "hub-mismatch" // terminal: TLS-trusted endpoint answered with a different hubId
|
|
135
|
+
| "hub-key-mismatch"; // terminal (pin): the endpoint cannot prove the pinned hub identity key
|
|
115
136
|
|
|
116
137
|
export interface ConnectorStatus {
|
|
117
138
|
state: ConnectorState;
|
|
@@ -141,17 +162,71 @@ type AttemptOutcome =
|
|
|
141
162
|
| { kind: "error"; message: string }
|
|
142
163
|
| { kind: "hello"; serverTime: number }
|
|
143
164
|
| { kind: "hub-id-mismatch"; sawHubId: string }
|
|
165
|
+
| { kind: "hub-key-mismatch"; message: string }
|
|
144
166
|
| { kind: "hello-timeout" }
|
|
145
167
|
| { kind: "stopped" };
|
|
146
168
|
|
|
147
169
|
interface Classified {
|
|
148
|
-
kind: "retry" | "reauth" | "revoked-terminal" | "hub-mismatch" | "stopped";
|
|
170
|
+
kind: "retry" | "reauth" | "revoked-terminal" | "hub-mismatch" | "hub-key-mismatch" | "stopped";
|
|
149
171
|
delayMs?: number;
|
|
150
172
|
detail: string;
|
|
151
173
|
}
|
|
152
174
|
|
|
153
175
|
const err = (code: string, message: string) => Object.assign(new Error(message), { code });
|
|
154
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Gap #6 — verify the hub identity proof inside a tunnel `hello` against the
|
|
179
|
+
* pinned fingerprint. Returns undefined (pinned + proven), "adopt" (legacy
|
|
180
|
+
* pairing with NO pin: the proof verifies and the fingerprint may be pinned —
|
|
181
|
+
* safe because this point is only reachable after the hub accepted the dpt_
|
|
182
|
+
* credential, which only the real hub issued), or a refusal string.
|
|
183
|
+
*/
|
|
184
|
+
function verifyHubHelloProof(
|
|
185
|
+
file: PairingFile,
|
|
186
|
+
hello: Extract<TunnelFrame, { type: "hello" }>,
|
|
187
|
+
nonce: string,
|
|
188
|
+
): undefined | "adopt" | "skip-legacy-hub" | string {
|
|
189
|
+
if (!hello.hubPublicKey || !hello.hubSignature) {
|
|
190
|
+
// An OLD hub (pre-v3) cannot prove anything. A pinned pairing must refuse;
|
|
191
|
+
// a legacy one just proceeds un-pinned until the hub is upgraded.
|
|
192
|
+
return file.hubKeyFingerprint
|
|
193
|
+
? "the hub hello carried no identity proof (it may predate offer v3); update the hub or re-pair"
|
|
194
|
+
: "skip-legacy-hub";
|
|
195
|
+
}
|
|
196
|
+
let fingerprint: string;
|
|
197
|
+
let verified: boolean;
|
|
198
|
+
try {
|
|
199
|
+
const pub = createPublicKey(hello.hubPublicKey);
|
|
200
|
+
if (pub.asymmetricKeyType !== "ed25519") return "the hub identity key is not Ed25519";
|
|
201
|
+
const der = pub.export({ format: "der", type: "spki" }) as Buffer;
|
|
202
|
+
fingerprint = `sha256:${createHash("sha256").update(der).digest("hex")}`;
|
|
203
|
+
if (hello.hubKeyFingerprint !== undefined && hello.hubKeyFingerprint !== fingerprint)
|
|
204
|
+
return "the hub's declared fingerprint does not match the key it sent";
|
|
205
|
+
verified = edVerify(
|
|
206
|
+
null,
|
|
207
|
+
Buffer.from(
|
|
208
|
+
hubProofSigningPayload({
|
|
209
|
+
hubOrigin: file.hubOrigin,
|
|
210
|
+
hubId: file.hubId,
|
|
211
|
+
subject: `tunnel:${file.deviceId}:${file.pairingEpoch}`,
|
|
212
|
+
nonce,
|
|
213
|
+
}),
|
|
214
|
+
),
|
|
215
|
+
pub,
|
|
216
|
+
Buffer.from(hello.hubSignature, "base64url"),
|
|
217
|
+
);
|
|
218
|
+
} catch {
|
|
219
|
+
return "the hub identity proof is malformed";
|
|
220
|
+
}
|
|
221
|
+
if (!verified) return "the hub signature does not verify under the key it sent";
|
|
222
|
+
if (file.hubKeyFingerprint) {
|
|
223
|
+
return file.hubKeyFingerprint === fingerprint
|
|
224
|
+
? undefined
|
|
225
|
+
: `hub key fingerprint ${fingerprint.slice(0, 19)}… differs from the pinned ${file.hubKeyFingerprint.slice(0, 19)}…`;
|
|
226
|
+
}
|
|
227
|
+
return "adopt";
|
|
228
|
+
}
|
|
229
|
+
|
|
155
230
|
export class DeviceConnector {
|
|
156
231
|
private file: PairingFile | undefined;
|
|
157
232
|
private keypair: DeviceKeypair | undefined;
|
|
@@ -264,7 +339,15 @@ export class DeviceConnector {
|
|
|
264
339
|
}
|
|
265
340
|
const pairing = loadJson<PairingFile>(pairingPath(this.home));
|
|
266
341
|
const keypair = loadJson<DeviceKeypair>(keypairPath(this.home));
|
|
267
|
-
if (
|
|
342
|
+
if (
|
|
343
|
+
!pairing?.tunnelToken ||
|
|
344
|
+
!pairing?.hubUrl ||
|
|
345
|
+
!pairing?.deviceUid ||
|
|
346
|
+
!pairing?.deviceId ||
|
|
347
|
+
!pairing?.pairingEpoch ||
|
|
348
|
+
!pairing?.credentialId ||
|
|
349
|
+
typeof pairing?.generation !== "number"
|
|
350
|
+
) {
|
|
268
351
|
this.setState("file-error", "pairing.json missing or malformed; run pairing again");
|
|
269
352
|
return;
|
|
270
353
|
}
|
|
@@ -358,6 +441,11 @@ export class DeviceConnector {
|
|
|
358
441
|
this.setState("hub-mismatch", verdict.detail);
|
|
359
442
|
break;
|
|
360
443
|
}
|
|
444
|
+
if (verdict.kind === "hub-key-mismatch") {
|
|
445
|
+
this.stopped = true;
|
|
446
|
+
this.setState("hub-key-mismatch", verdict.detail);
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
361
449
|
if (verdict.kind === "reauth") this.setState("reauth", verdict.detail);
|
|
362
450
|
else this.setState("connecting", verdict.detail);
|
|
363
451
|
// A verdict without its own delay (transport failure, 5xx, re-pair-needed 401) walks the shared curve.
|
|
@@ -431,16 +519,23 @@ export class DeviceConnector {
|
|
|
431
519
|
}
|
|
432
520
|
finish({ kind: "auth", status: res.statusCode ?? 0, body, raw });
|
|
433
521
|
});
|
|
434
|
-
res.on("error", () =>
|
|
522
|
+
res.on("error", () =>
|
|
523
|
+
finish({ kind: "auth", status: res.statusCode ?? 0, body: null, raw: "" }),
|
|
524
|
+
);
|
|
435
525
|
});
|
|
436
526
|
|
|
437
527
|
ws.on("error", (e) => finish({ kind: "error", message: e.message }));
|
|
438
|
-
ws.on("close", (code, reason) =>
|
|
528
|
+
ws.on("close", (code, reason) =>
|
|
529
|
+
finish({ kind: "closed", code, reason: Buffer.from(reason).toString("utf8") }),
|
|
530
|
+
);
|
|
439
531
|
|
|
440
532
|
ws.on("open", () => {
|
|
441
533
|
if (settled || this.wsGeneration !== generation) return;
|
|
442
534
|
// Identity handshake with the SAVED tuple; retry counter resets only
|
|
443
|
-
// after a schema-valid hello below.
|
|
535
|
+
// after a schema-valid hello below. Every connect challenges the hub to
|
|
536
|
+
// prove its identity key: a PINNED pairing must see it match; a legacy
|
|
537
|
+
// pairing (no pin yet) adopts it through this authenticated channel.
|
|
538
|
+
const proofNonce = randomBytes(32).toString("base64url");
|
|
444
539
|
const hs: TunnelFrame = {
|
|
445
540
|
type: "pair-handshake",
|
|
446
541
|
v: 1,
|
|
@@ -452,6 +547,7 @@ export class DeviceConnector {
|
|
|
452
547
|
apiVersion: 1,
|
|
453
548
|
tunnelProto: 1,
|
|
454
549
|
keyFingerprint: this.keypair?.fingerprint ?? "",
|
|
550
|
+
...(proofNonce ? { proofNonce } : {}),
|
|
455
551
|
};
|
|
456
552
|
try {
|
|
457
553
|
ws.send(JSON.stringify(hs));
|
|
@@ -469,11 +565,17 @@ export class DeviceConnector {
|
|
|
469
565
|
try {
|
|
470
566
|
msg = JSON.parse(data.toString("utf8"));
|
|
471
567
|
} catch {
|
|
472
|
-
finish({
|
|
568
|
+
finish({
|
|
569
|
+
kind: "error",
|
|
570
|
+
message: helloDone ? "frame: malformed JSON" : "hello: malformed JSON",
|
|
571
|
+
});
|
|
473
572
|
return;
|
|
474
573
|
}
|
|
475
574
|
if (!checkTunnelMessage(msg)) {
|
|
476
|
-
finish({
|
|
575
|
+
finish({
|
|
576
|
+
kind: "error",
|
|
577
|
+
message: helloDone ? "frame: schema violation" : "hello: schema violation",
|
|
578
|
+
});
|
|
477
579
|
return;
|
|
478
580
|
}
|
|
479
581
|
const frame = msg as TunnelFrame;
|
|
@@ -486,6 +588,21 @@ export class DeviceConnector {
|
|
|
486
588
|
finish({ kind: "hub-id-mismatch", sawHubId: frame.hubId });
|
|
487
589
|
return;
|
|
488
590
|
}
|
|
591
|
+
// Gap #6 pin enforcement: a pinned pairing demands a valid identity
|
|
592
|
+
// proof in the very hello; anything else is a WRONG HUB, not a glitch.
|
|
593
|
+
const pinProblem = verifyHubHelloProof(file, frame, proofNonce);
|
|
594
|
+
if (
|
|
595
|
+
pinProblem !== undefined &&
|
|
596
|
+
pinProblem !== "adopt" &&
|
|
597
|
+
pinProblem !== "skip-legacy-hub"
|
|
598
|
+
) {
|
|
599
|
+
try {
|
|
600
|
+
ws.close(1011, "hub identity proof failed");
|
|
601
|
+
} catch {}
|
|
602
|
+
finish({ kind: "hub-key-mismatch", message: pinProblem });
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (pinProblem === "adopt") this.adoptPin(file, frame.hubKeyFingerprint!);
|
|
489
606
|
// Connected: heartbeat + idle safety net from here (04 §5).
|
|
490
607
|
helloDone = true;
|
|
491
608
|
clearTimeout(helloTimer);
|
|
@@ -537,16 +654,34 @@ export class DeviceConnector {
|
|
|
537
654
|
void (async () => {
|
|
538
655
|
const check = validateRestRequest({ method: frame.method, path: frame.path });
|
|
539
656
|
if (!check.ok) {
|
|
540
|
-
send({
|
|
657
|
+
send({
|
|
658
|
+
type: "rest-response",
|
|
659
|
+
reqId: frame.reqId,
|
|
660
|
+
status: 404,
|
|
661
|
+
body: { error: { code: "invalid", message: check.error } },
|
|
662
|
+
});
|
|
541
663
|
return;
|
|
542
664
|
}
|
|
543
|
-
const bodyBytes =
|
|
665
|
+
const bodyBytes =
|
|
666
|
+
frame.body === undefined ? 0 : Buffer.byteLength(JSON.stringify(frame.body), "utf8");
|
|
544
667
|
if (bodyBytes > LOCAL_BODY_CAP) {
|
|
545
|
-
send({
|
|
668
|
+
send({
|
|
669
|
+
type: "rest-response",
|
|
670
|
+
reqId: frame.reqId,
|
|
671
|
+
status: 413,
|
|
672
|
+
body: {
|
|
673
|
+
error: { code: "invalid", message: `Request body exceeds ${LOCAL_BODY_CAP} bytes` },
|
|
674
|
+
},
|
|
675
|
+
});
|
|
546
676
|
return;
|
|
547
677
|
}
|
|
548
678
|
const tooLarge = (message: string) =>
|
|
549
|
-
send({
|
|
679
|
+
send({
|
|
680
|
+
type: "rest-response",
|
|
681
|
+
reqId: frame.reqId,
|
|
682
|
+
status: 502,
|
|
683
|
+
body: { error: { code: "response_too_large", message } },
|
|
684
|
+
});
|
|
550
685
|
const call = new AbortController();
|
|
551
686
|
this.restCalls.add(call);
|
|
552
687
|
try {
|
|
@@ -556,7 +691,9 @@ export class DeviceConnector {
|
|
|
556
691
|
headers: {
|
|
557
692
|
authorization: `Bearer ${api.token}`,
|
|
558
693
|
"x-dpi-token": api.token,
|
|
559
|
-
...(hasBody && frame.body !== undefined
|
|
694
|
+
...(hasBody && frame.body !== undefined
|
|
695
|
+
? { "content-type": "application/json" }
|
|
696
|
+
: {}),
|
|
560
697
|
...(frame.idempotencyKey ? { "idempotency-key": frame.idempotencyKey } : {}),
|
|
561
698
|
},
|
|
562
699
|
body: hasBody && frame.body !== undefined ? JSON.stringify(frame.body) : undefined,
|
|
@@ -565,7 +702,17 @@ export class DeviceConnector {
|
|
|
565
702
|
});
|
|
566
703
|
if (res.status >= 300 && res.status < 400) {
|
|
567
704
|
await res.body?.cancel().catch(() => {});
|
|
568
|
-
send({
|
|
705
|
+
send({
|
|
706
|
+
type: "rest-response",
|
|
707
|
+
reqId: frame.reqId,
|
|
708
|
+
status: 502,
|
|
709
|
+
body: {
|
|
710
|
+
error: {
|
|
711
|
+
code: "offline",
|
|
712
|
+
message: `Device API answered with a redirect (${res.status}); redirects are not followed`,
|
|
713
|
+
},
|
|
714
|
+
},
|
|
715
|
+
});
|
|
569
716
|
return;
|
|
570
717
|
}
|
|
571
718
|
const read = await readTextCapped(res, LOGICAL_MESSAGE_MAX_BYTES);
|
|
@@ -574,7 +721,13 @@ export class DeviceConnector {
|
|
|
574
721
|
return;
|
|
575
722
|
}
|
|
576
723
|
// Small responses ride one frame; large ones chunk (hub reassembles).
|
|
577
|
-
const envelope = {
|
|
724
|
+
const envelope = {
|
|
725
|
+
type: "rest-response",
|
|
726
|
+
reqId: frame.reqId,
|
|
727
|
+
status: res.status,
|
|
728
|
+
contentType: res.headers.get("content-type") ?? undefined,
|
|
729
|
+
body: read.text,
|
|
730
|
+
} as const;
|
|
578
731
|
const serialized = JSON.stringify(envelope);
|
|
579
732
|
if (Buffer.byteLength(serialized, "utf8") <= INLINE_FRAME_MAX_BYTES) {
|
|
580
733
|
await sendPaced(serialized);
|
|
@@ -587,7 +740,12 @@ export class DeviceConnector {
|
|
|
587
740
|
}
|
|
588
741
|
for (const chunk of plan.chunks) if (!(await sendPaced(chunk))) return;
|
|
589
742
|
} catch (e) {
|
|
590
|
-
send({
|
|
743
|
+
send({
|
|
744
|
+
type: "rest-response",
|
|
745
|
+
reqId: frame.reqId,
|
|
746
|
+
status: 502,
|
|
747
|
+
body: { error: { code: "offline", message: (e as Error).message } },
|
|
748
|
+
});
|
|
591
749
|
} finally {
|
|
592
750
|
this.restCalls.delete(call);
|
|
593
751
|
}
|
|
@@ -609,12 +767,21 @@ export class DeviceConnector {
|
|
|
609
767
|
const sse = new SseReader(this.ingress);
|
|
610
768
|
// This reader failed, the tunnel did not: say so for this subscription only and stop reading.
|
|
611
769
|
const fault = (message: string) => {
|
|
612
|
-
send({
|
|
770
|
+
send({
|
|
771
|
+
type: "tunnel-data",
|
|
772
|
+
frame: { type: "fault", error: { code: "invalid", message } },
|
|
773
|
+
subscriptionId: frame.subscriptionId,
|
|
774
|
+
sub: frame.sub,
|
|
775
|
+
});
|
|
613
776
|
ctl.abort();
|
|
614
777
|
};
|
|
615
778
|
try {
|
|
616
779
|
const res = await fetch(api.baseUrl + frame.sub, {
|
|
617
|
-
headers: {
|
|
780
|
+
headers: {
|
|
781
|
+
authorization: `Bearer ${api.token}`,
|
|
782
|
+
"x-dpi-token": api.token,
|
|
783
|
+
accept: "text/event-stream",
|
|
784
|
+
},
|
|
618
785
|
signal: ctl.signal,
|
|
619
786
|
});
|
|
620
787
|
if (!res.ok || !res.body) throw new Error(`${frame.sub} answered ${res.status}`);
|
|
@@ -632,13 +799,22 @@ export class DeviceConnector {
|
|
|
632
799
|
} catch {
|
|
633
800
|
continue;
|
|
634
801
|
}
|
|
635
|
-
const wire = JSON.stringify({
|
|
802
|
+
const wire = JSON.stringify({
|
|
803
|
+
type: "tunnel-data",
|
|
804
|
+
frame: parsed,
|
|
805
|
+
subscriptionId: frame.subscriptionId,
|
|
806
|
+
sub: frame.sub,
|
|
807
|
+
});
|
|
636
808
|
if (Buffer.byteLength(wire, "utf8") <= INLINE_FRAME_MAX_BYTES) {
|
|
637
809
|
if (!(await sendPaced(wire))) return;
|
|
638
810
|
continue;
|
|
639
811
|
}
|
|
640
812
|
// One WS message ≤1 MiB: large frames (big trees) chunk, one paced frame at a time.
|
|
641
|
-
const plan = chunkMessage(parsed, {
|
|
813
|
+
const plan = chunkMessage(parsed, {
|
|
814
|
+
kind: "tunnel-data",
|
|
815
|
+
subscriptionId: frame.subscriptionId,
|
|
816
|
+
sub: frame.sub,
|
|
817
|
+
});
|
|
642
818
|
if (!plan.ok) return fault(plan.error);
|
|
643
819
|
for (const chunk of plan.chunks) if (!(await sendPaced(chunk))) return;
|
|
644
820
|
}
|
|
@@ -648,7 +824,8 @@ export class DeviceConnector {
|
|
|
648
824
|
} finally {
|
|
649
825
|
sse.close();
|
|
650
826
|
ctl.abort();
|
|
651
|
-
if (this.readers.get(frame.subscriptionId) === ctl)
|
|
827
|
+
if (this.readers.get(frame.subscriptionId) === ctl)
|
|
828
|
+
this.readers.delete(frame.subscriptionId);
|
|
652
829
|
}
|
|
653
830
|
})();
|
|
654
831
|
return;
|
|
@@ -761,7 +938,11 @@ export class DeviceConnector {
|
|
|
761
938
|
lowest = ws.bufferedAmount;
|
|
762
939
|
progressAt = this.now();
|
|
763
940
|
} else if (this.now() - progressAt >= this.drainMs) {
|
|
764
|
-
this.closeForBackpressure(
|
|
941
|
+
this.closeForBackpressure(
|
|
942
|
+
ws,
|
|
943
|
+
generation,
|
|
944
|
+
`outbound queue did not drain for ${this.drainMs} ms`,
|
|
945
|
+
);
|
|
765
946
|
return false;
|
|
766
947
|
}
|
|
767
948
|
await new Promise<void>((resolve) => setTimeout(resolve, DRAIN_POLL_MS));
|
|
@@ -793,6 +974,22 @@ export class DeviceConnector {
|
|
|
793
974
|
ws.once("close", () => clearTimeout(force));
|
|
794
975
|
}
|
|
795
976
|
|
|
977
|
+
/**
|
|
978
|
+
* Authenticated upgrade (gap #6 migration): a legacy pairing adopted the
|
|
979
|
+
* hub's identity fingerprint through the just-verified, dpt_-authenticated
|
|
980
|
+
* tunnel. Best effort — a failed write just means the pin is adopted on a
|
|
981
|
+
* later connect.
|
|
982
|
+
*/
|
|
983
|
+
private adoptPin(file: PairingFile, fingerprint: string): void {
|
|
984
|
+
const next: PairingFile = { ...file, hubKeyFingerprint: fingerprint, updatedAt: this.now() };
|
|
985
|
+
try {
|
|
986
|
+
writeAtomic(pairingPath(this.home), JSON.stringify(next, null, 2));
|
|
987
|
+
} catch {
|
|
988
|
+
return; // pin adopted next connect
|
|
989
|
+
}
|
|
990
|
+
if (this.file?.operationId === file.operationId) this.file = next;
|
|
991
|
+
}
|
|
992
|
+
|
|
796
993
|
private startHeartbeat(ws: WebSocket, generation: number): void {
|
|
797
994
|
this.pingTimer = setInterval(() => {
|
|
798
995
|
if (this.wsGeneration !== generation || ws.readyState !== ws.OPEN) return;
|
|
@@ -811,15 +1008,18 @@ export class DeviceConnector {
|
|
|
811
1008
|
/* close event will settle */
|
|
812
1009
|
}
|
|
813
1010
|
}, this.pingMs);
|
|
814
|
-
this.idleTimer = setInterval(
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
1011
|
+
this.idleTimer = setInterval(
|
|
1012
|
+
() => {
|
|
1013
|
+
if (this.now() - this.lastByte > this.idleMs) {
|
|
1014
|
+
try {
|
|
1015
|
+
ws.terminate();
|
|
1016
|
+
} catch {
|
|
1017
|
+
/* already gone */
|
|
1018
|
+
}
|
|
820
1019
|
}
|
|
821
|
-
}
|
|
822
|
-
|
|
1020
|
+
},
|
|
1021
|
+
Math.min(this.idleMs, Math.max(200, Math.floor(this.idleMs / 4))),
|
|
1022
|
+
);
|
|
823
1023
|
}
|
|
824
1024
|
|
|
825
1025
|
private classify(
|
|
@@ -837,41 +1037,79 @@ export class DeviceConnector {
|
|
|
837
1037
|
return { kind: "retry", detail: `closed ${outcome.code} ${outcome.reason}`.trim() };
|
|
838
1038
|
}
|
|
839
1039
|
if (outcome.kind === "error") return { kind: "retry", detail: outcome.message };
|
|
840
|
-
if (outcome.kind === "hello-timeout")
|
|
1040
|
+
if (outcome.kind === "hello-timeout")
|
|
1041
|
+
return { kind: "retry", detail: "no hello within deadline" };
|
|
841
1042
|
if (outcome.kind === "hub-id-mismatch")
|
|
842
|
-
return {
|
|
1043
|
+
return {
|
|
1044
|
+
kind: "hub-mismatch",
|
|
1045
|
+
detail: `hub answered with a different hubId (${outcome.sawHubId}); check the pair URL — this is not the hub you paired with`,
|
|
1046
|
+
};
|
|
1047
|
+
if (outcome.kind === "hub-key-mismatch")
|
|
1048
|
+
return {
|
|
1049
|
+
kind: "hub-key-mismatch",
|
|
1050
|
+
detail: `${outcome.message}; REFUSING to continue — the pinned hub identity (hubKeyFingerprint) does not match. Re-pair with a fresh offer link from the hub you trust`,
|
|
1051
|
+
};
|
|
843
1052
|
// auth-shaped outcomes
|
|
844
1053
|
const body = outcome.body as { type?: string; code?: string; retryAfterMs?: number } | null;
|
|
845
1054
|
const parsed = body?.type === "tunnel.auth-error" ? body : null;
|
|
846
1055
|
if (outcome.status === 429 || parsed?.code === "rate_limited") {
|
|
847
1056
|
const advised = Number(parsed?.retryAfterMs ?? 0);
|
|
848
|
-
const delay =
|
|
849
|
-
|
|
850
|
-
|
|
1057
|
+
const delay =
|
|
1058
|
+
Number.isFinite(advised) && advised > 0
|
|
1059
|
+
? Math.max(RETRY_AFTER_MIN_MS, Math.min(RETRY_AFTER_MAX_MS, advised))
|
|
1060
|
+
: RETRY_AFTER_MAX_MS;
|
|
851
1061
|
return { kind: "retry", delayMs: delay, detail: "rate limited; honoring bounded retryAfter" };
|
|
852
1062
|
}
|
|
853
1063
|
if (outcome.status >= 300 && outcome.status < 400)
|
|
854
|
-
return {
|
|
1064
|
+
return {
|
|
1065
|
+
kind: "retry",
|
|
1066
|
+
detail: `redirect (${outcome.status}) rejected: connector uses one exact origin`,
|
|
1067
|
+
};
|
|
855
1068
|
if (parsed?.code === "revoked") {
|
|
856
1069
|
// Terminal ONLY when this attempt's tuple still matches the CURRENT file.
|
|
857
1070
|
const current = this.file;
|
|
858
|
-
if (
|
|
1071
|
+
if (
|
|
1072
|
+
current &&
|
|
1073
|
+
current.credentialId === attemptTuple.credentialId &&
|
|
1074
|
+
current.pairingEpoch === attemptTuple.pairingEpoch
|
|
1075
|
+
) {
|
|
859
1076
|
// F05 disk-truth: a re-pair may have replaced the file mid-flight.
|
|
860
1077
|
const onDisk = loadJson<PairingFile>(pairingPath(this.home));
|
|
861
|
-
if (
|
|
1078
|
+
if (
|
|
1079
|
+
onDisk &&
|
|
1080
|
+
(onDisk.credentialId !== attemptTuple.credentialId ||
|
|
1081
|
+
onDisk.pairingEpoch !== attemptTuple.pairingEpoch)
|
|
1082
|
+
) {
|
|
862
1083
|
this.file = onDisk; // adopt the newer pairing and keep going
|
|
863
1084
|
this.backoff.reset();
|
|
864
|
-
return {
|
|
1085
|
+
return {
|
|
1086
|
+
kind: "retry",
|
|
1087
|
+
delayMs: 0,
|
|
1088
|
+
detail: "revoked for an old credential; a newer pairing file is on disk",
|
|
1089
|
+
};
|
|
865
1090
|
}
|
|
866
1091
|
this.removePairingFile();
|
|
867
|
-
return {
|
|
1092
|
+
return {
|
|
1093
|
+
kind: "revoked-terminal",
|
|
1094
|
+
detail: "hub revoked this credential; pairing removed — re-pair to continue",
|
|
1095
|
+
};
|
|
868
1096
|
}
|
|
869
|
-
return {
|
|
1097
|
+
return {
|
|
1098
|
+
kind: "retry",
|
|
1099
|
+
delayMs: this.backoff.ms,
|
|
1100
|
+
detail: "late revoked response for a replaced credential; ignored",
|
|
1101
|
+
};
|
|
870
1102
|
}
|
|
871
1103
|
if (parsed?.code === "token_unknown" || outcome.status === 401) {
|
|
872
1104
|
// Non-terminal, actionable: the operator can re-pair any time; the
|
|
873
1105
|
// connector keeps trying with backoff and never hides that fact.
|
|
874
|
-
return {
|
|
1106
|
+
return {
|
|
1107
|
+
kind: "reauth",
|
|
1108
|
+
detail:
|
|
1109
|
+
parsed?.code === "token_unknown"
|
|
1110
|
+
? "hub does not recognize this credential anymore; re-pair when convenient (still retrying)"
|
|
1111
|
+
: "bare 401 without a parsable auth-error body; still retrying",
|
|
1112
|
+
};
|
|
875
1113
|
}
|
|
876
1114
|
return { kind: "retry", detail: `upgrade failed with HTTP ${outcome.status}` };
|
|
877
1115
|
}
|
|
@@ -942,7 +1180,8 @@ export class DeviceConnector {
|
|
|
942
1180
|
}
|
|
943
1181
|
|
|
944
1182
|
/** sha256 hex of a token — pairing parity helper (used by tests/tools). */
|
|
945
|
-
export const tokenHash = (token: string) =>
|
|
1183
|
+
export const tokenHash = (token: string) =>
|
|
1184
|
+
createHash("sha256").update(token, "utf8").digest("hex");
|
|
946
1185
|
|
|
947
1186
|
export { err as connectorError };
|
|
948
1187
|
export const readCaPem = (path: string | undefined): string | undefined => {
|