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
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /pair — the DEVICE-side enrollment page (gap #4).
|
|
3
|
+
*
|
|
4
|
+
* A zero-dependency static page served by the device backend: it reads the
|
|
5
|
+
* pairing offer from ITS OWN #offer= fragment (pasted link or the hub page's
|
|
6
|
+
* handoff link), drives the local enroll API, and shows the live ceremony —
|
|
7
|
+
* hub origin, device name, full key fingerprint, approval status. The offer
|
|
8
|
+
* never leaves the machine: the fragment is not sent to any hub, and the
|
|
9
|
+
* enroll API is a narrow, CSRF-guarded, unpaired-only surface.
|
|
10
|
+
*/
|
|
11
|
+
export const PAIR_PAGE_HTML = `<!doctype html>
|
|
12
|
+
<html lang="en">
|
|
13
|
+
<head>
|
|
14
|
+
<meta charset="utf-8">
|
|
15
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
16
|
+
<title>Pair this device — dpi</title>
|
|
17
|
+
<style>
|
|
18
|
+
:root { color-scheme: light dark; }
|
|
19
|
+
* { box-sizing: border-box; }
|
|
20
|
+
body { margin: 0; font: 15px/1.55 system-ui, sans-serif; background: #10141a; color: #e6ebf2;
|
|
21
|
+
display: flex; min-height: 100vh; align-items: center; justify-content: center; padding: 24px; }
|
|
22
|
+
.card { max-width: 560px; width: 100%; background: #171d26; border: 1px solid #2a3442;
|
|
23
|
+
border-radius: 14px; padding: 28px 30px; }
|
|
24
|
+
h1 { font-size: 19px; margin: 0 0 4px; }
|
|
25
|
+
.sub { color: #8fa0b5; margin: 0 0 20px; font-size: 13.5px; }
|
|
26
|
+
dl { margin: 0 0 18px; display: grid; grid-template-columns: 130px 1fr; gap: 7px 12px; font-size: 13.5px; }
|
|
27
|
+
dt { color: #8fa0b5; }
|
|
28
|
+
dd { margin: 0; word-break: break-all; font-family: ui-monospace, monospace; }
|
|
29
|
+
.fp { color: #7ee2a8; }
|
|
30
|
+
.hub { color: #8ab4f8; }
|
|
31
|
+
input { width: 100%; padding: 10px 12px; border-radius: 9px; border: 1px solid #2a3442;
|
|
32
|
+
background: #10141a; color: #e6ebf2; font: inherit; margin-bottom: 12px; }
|
|
33
|
+
button { width: 100%; padding: 11px 14px; border-radius: 9px; border: 0; cursor: pointer;
|
|
34
|
+
font: 600 14.5px/1 system-ui; background: #3d7dffa0; }
|
|
35
|
+
button:disabled { opacity: .45; cursor: default; }
|
|
36
|
+
.status { margin-top: 16px; font-size: 13.5px; }
|
|
37
|
+
.ok { color: #7ee2a8; } .warn { color: #f5c26b; } .err { color: #f28b82; }
|
|
38
|
+
.spin { display: inline-block; animation: s 1s linear infinite; }
|
|
39
|
+
@keyframes s { to { transform: rotate(360deg); } }
|
|
40
|
+
.note { margin-top: 14px; color: #67788e; font-size: 12.5px; }
|
|
41
|
+
</style>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
<div class="card">
|
|
45
|
+
<h1>Pair this device</h1>
|
|
46
|
+
<p class="sub">This machine will hold the credential and stay connected — the browser only starts it.</p>
|
|
47
|
+
<dl id="meta" hidden>
|
|
48
|
+
<dt>hub</dt><dd class="hub" id="hub"></dd>
|
|
49
|
+
<dt>device</dt><dd id="device"></dd>
|
|
50
|
+
<dt>key fingerprint</dt><dd class="fp" id="fp"></dd>
|
|
51
|
+
</dl>
|
|
52
|
+
<label for="offer" style="display:block;font-size:13px;color:#8fa0b5;margin-bottom:6px">Offer link (starts with the hub's https://…/pair#offer=…)</label>
|
|
53
|
+
<input id="offer" placeholder="https://hub.example.com/pair#offer=…" spellcheck="false">
|
|
54
|
+
<button id="go">Pair this device</button>
|
|
55
|
+
<div class="status" id="status"></div>
|
|
56
|
+
<p class="note">Compare the fingerprint with the hub wizard before approving there. Credential and keys live in this machine's dpi home, never in the browser.</p>
|
|
57
|
+
</div>
|
|
58
|
+
<script>
|
|
59
|
+
(function () {
|
|
60
|
+
var offerEl = document.getElementById("offer");
|
|
61
|
+
var go = document.getElementById("go");
|
|
62
|
+
var statusEl = document.getElementById("status");
|
|
63
|
+
var meta = document.getElementById("meta");
|
|
64
|
+
var H = { "content-type": "application/json", "x-dpi-pair": "1" };
|
|
65
|
+
function say(text, cls) { statusEl.innerHTML = text ? '<span class="' + (cls || "") + '">' + text + "</span>" : ""; }
|
|
66
|
+
// The hub handoff link put the offer in THIS page's fragment.
|
|
67
|
+
if (location.hash.indexOf("#offer=") === 0) offerEl.value = location.href;
|
|
68
|
+
function poll() {
|
|
69
|
+
fetch("/v1/pair-enroll", { headers: H })
|
|
70
|
+
.then(function (r) { return r.json(); })
|
|
71
|
+
.then(function (s) {
|
|
72
|
+
render(s);
|
|
73
|
+
if (s.state === "pairing") { say('<span class="spin">◠</span> waiting for approval on the hub…', "warn"); setTimeout(poll, 2000); }
|
|
74
|
+
else if (s.state === "paired") say("paired — credential saved. You can close this page and open <b>dpi tui</b>.", "ok");
|
|
75
|
+
})
|
|
76
|
+
.catch(function () { setTimeout(poll, 3000); });
|
|
77
|
+
}
|
|
78
|
+
function render(s) {
|
|
79
|
+
meta.hidden = false;
|
|
80
|
+
document.getElementById("device").textContent = s.deviceName || "";
|
|
81
|
+
if (s.fingerprint) document.getElementById("fp").textContent = s.fingerprint;
|
|
82
|
+
if (s.hubOrigin || (s.pending && s.pending.hubOrigin)) document.getElementById("hub").textContent = s.hubOrigin || s.pending.hubOrigin;
|
|
83
|
+
}
|
|
84
|
+
function load() {
|
|
85
|
+
fetch("/v1/pair-enroll", { headers: H })
|
|
86
|
+
.then(function (r) { return r.json(); })
|
|
87
|
+
.then(function (s) {
|
|
88
|
+
render(s);
|
|
89
|
+
if (s.state === "pairing") poll();
|
|
90
|
+
if (s.lastError) say(s.lastError, "err");
|
|
91
|
+
}).catch(function () {});
|
|
92
|
+
}
|
|
93
|
+
go.addEventListener("click", function () {
|
|
94
|
+
var pairUrl = offerEl.value.trim();
|
|
95
|
+
if (!pairUrl) { say("paste the offer link first", "err"); return; }
|
|
96
|
+
go.disabled = true;
|
|
97
|
+
say("starting…", "");
|
|
98
|
+
fetch("/v1/pair-enroll", { method: "POST", headers: H, body: JSON.stringify({ pairUrl: pairUrl }) })
|
|
99
|
+
.then(function (r) { return r.json().then(function (b) { return { ok: r.ok, b: b }; }); })
|
|
100
|
+
.then(function (o) {
|
|
101
|
+
if (!o.ok) { say((o.b && o.b.error && o.b.error.message) || "refused", "err"); go.disabled = false; return; }
|
|
102
|
+
poll();
|
|
103
|
+
})
|
|
104
|
+
.catch(function () { say("cannot reach the local backend — is <b>dpi device</b> running?", "err"); go.disabled = false; });
|
|
105
|
+
});
|
|
106
|
+
load();
|
|
107
|
+
})();
|
|
108
|
+
</script>
|
|
109
|
+
</body>
|
|
110
|
+
</html>
|
|
111
|
+
`;
|
|
@@ -7,10 +7,23 @@
|
|
|
7
7
|
* resolves, and async latecomers must re-check the active operation before
|
|
8
8
|
* committing (see pairing.ts).
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
chmodSync,
|
|
12
|
+
closeSync,
|
|
13
|
+
fsyncSync,
|
|
14
|
+
openSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
readdirSync,
|
|
17
|
+
renameSync,
|
|
18
|
+
statSync,
|
|
19
|
+
unlinkSync,
|
|
20
|
+
writeSync,
|
|
21
|
+
} from "node:fs";
|
|
11
22
|
import { dirname, join } from "node:path";
|
|
12
23
|
import { randomBytes } from "node:crypto";
|
|
13
24
|
import { ensurePrivateDir } from "../../dpi-sdk/src/node/private-dir.js";
|
|
25
|
+
import { restrictToOwner } from "../../dpi-sdk/src/node/private-file.js";
|
|
26
|
+
import { fsyncReadOnlySupported } from "../../dpi-sdk/src/node/registry-migration.js";
|
|
14
27
|
|
|
15
28
|
export { ensurePrivateDir };
|
|
16
29
|
|
|
@@ -32,12 +45,18 @@ export function replaceFileAtomic(path: string, contents: string): void {
|
|
|
32
45
|
closeSync(fd);
|
|
33
46
|
fd = undefined;
|
|
34
47
|
renameSync(temp, path);
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
48
|
+
// Windows ignores 0600: install the owner-only DACL after every publish
|
|
49
|
+
// (posix: chmod is a no-op on the already-0600 file).
|
|
50
|
+
void restrictToOwner(path);
|
|
51
|
+
// fsync the directory so the rename itself is durable (POSIX only: Windows
|
|
52
|
+
// directory fds cannot be fsynced — EPERM; NTFS journaling carries it there).
|
|
53
|
+
if (fsyncReadOnlySupported) {
|
|
54
|
+
const dirFd = openSync(dir, "r");
|
|
55
|
+
try {
|
|
56
|
+
fsyncSync(dirFd);
|
|
57
|
+
} finally {
|
|
58
|
+
closeSync(dirFd);
|
|
59
|
+
}
|
|
41
60
|
}
|
|
42
61
|
} catch (e) {
|
|
43
62
|
if (fd !== undefined) {
|
|
@@ -101,12 +120,15 @@ export function readKeypairFile(path: string): DeviceKeypair | undefined {
|
|
|
101
120
|
} catch {
|
|
102
121
|
throw keypairUnusable(path, "not valid JSON");
|
|
103
122
|
}
|
|
104
|
-
if (!parsed?.privateKeyPem || !parsed.publicKeyPem || !parsed.fingerprint)
|
|
123
|
+
if (!parsed?.privateKeyPem || !parsed.publicKeyPem || !parsed.fingerprint)
|
|
124
|
+
throw keypairUnusable(path, "key fields are missing");
|
|
105
125
|
return parsed as DeviceKeypair;
|
|
106
126
|
}
|
|
107
127
|
|
|
108
128
|
const keypairUnusable = (path: string, why: string) =>
|
|
109
|
-
new Error(
|
|
129
|
+
new Error(
|
|
130
|
+
`device keypair ${path} is unusable (${why}); refusing to replace it. Restore it from a backup, or move it away to enroll this device again.`,
|
|
131
|
+
);
|
|
110
132
|
|
|
111
133
|
/** Tighten a file that must hold secrets if a copy/restore loosened it. */
|
|
112
134
|
export function ensureFileMode0600(path: string): void {
|
|
@@ -136,6 +158,14 @@ export interface PairingFile {
|
|
|
136
158
|
tunnelToken: string;
|
|
137
159
|
updatedAt: number;
|
|
138
160
|
operationId: string;
|
|
161
|
+
/**
|
|
162
|
+
* Offer-v3 pin: `sha256:<64 hex>` of the hub identity key this device
|
|
163
|
+
* paired against. Verified on every tunnel connect (the hub proves
|
|
164
|
+
* possession in its `hello`); a mismatch is a hard abort. Absent on legacy
|
|
165
|
+
* pairings — adopted at the next successful connect through the already-
|
|
166
|
+
* authenticated tunnel, never from an unauthenticated response.
|
|
167
|
+
*/
|
|
168
|
+
hubKeyFingerprint?: string;
|
|
139
169
|
}
|
|
140
170
|
|
|
141
171
|
export const keypairPath = (home: string) => join(home, "device-keypair.json");
|