my-durable 0.0.7 → 0.0.9
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 +29 -3
- 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.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 +21 -4
- 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,22 @@
|
|
|
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";
|
|
14
26
|
import { fsyncReadOnlySupported } from "../../dpi-sdk/src/node/registry-migration.js";
|
|
15
27
|
|
|
16
28
|
export { ensurePrivateDir };
|
|
@@ -33,6 +45,9 @@ export function replaceFileAtomic(path: string, contents: string): void {
|
|
|
33
45
|
closeSync(fd);
|
|
34
46
|
fd = undefined;
|
|
35
47
|
renameSync(temp, path);
|
|
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);
|
|
36
51
|
// fsync the directory so the rename itself is durable (POSIX only: Windows
|
|
37
52
|
// directory fds cannot be fsynced — EPERM; NTFS journaling carries it there).
|
|
38
53
|
if (fsyncReadOnlySupported) {
|
|
@@ -105,12 +120,15 @@ export function readKeypairFile(path: string): DeviceKeypair | undefined {
|
|
|
105
120
|
} catch {
|
|
106
121
|
throw keypairUnusable(path, "not valid JSON");
|
|
107
122
|
}
|
|
108
|
-
if (!parsed?.privateKeyPem || !parsed.publicKeyPem || !parsed.fingerprint)
|
|
123
|
+
if (!parsed?.privateKeyPem || !parsed.publicKeyPem || !parsed.fingerprint)
|
|
124
|
+
throw keypairUnusable(path, "key fields are missing");
|
|
109
125
|
return parsed as DeviceKeypair;
|
|
110
126
|
}
|
|
111
127
|
|
|
112
128
|
const keypairUnusable = (path: string, why: string) =>
|
|
113
|
-
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
|
+
);
|
|
114
132
|
|
|
115
133
|
/** Tighten a file that must hold secrets if a copy/restore loosened it. */
|
|
116
134
|
export function ensureFileMode0600(path: string): void {
|
|
@@ -140,6 +158,14 @@ export interface PairingFile {
|
|
|
140
158
|
tunnelToken: string;
|
|
141
159
|
updatedAt: number;
|
|
142
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;
|
|
143
169
|
}
|
|
144
170
|
|
|
145
171
|
export const keypairPath = (home: string) => join(home, "device-keypair.json");
|