my-durable 0.0.4 → 0.0.5
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/apps/dpi-backend/src/config.ts +20 -3
- package/apps/dpi-backend/src/main.ts +2 -0
- package/apps/dpi-backend/src/pairing.ts +42 -11
- package/apps/dpi-backend/src/routes.ts +11 -3
- package/apps/dpi-playground/README.md +7 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index--aoZ5Tj0.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Dq0CZYr7.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/App.vue +3 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/auth.ts +108 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +18 -3
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/LoginCard.vue +59 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +32 -4
- package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +4 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +24 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/admin-auth.ts +168 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +14 -2
- package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +79 -6
- package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +42 -7
- package/apps/dpi-sdk/src/protocol/limits.ts +17 -0
- package/apps/dpi-sdk/src/protocol/pairing.ts +201 -1
- package/apps/dpi-tui/src/app.ts +25 -1
- package/apps/dpi-tui/src/main.ts +95 -51
- package/apps/dpi-tui/src/pickers.ts +98 -0
- package/apps/dpi-tui/src/welcome.ts +394 -0
- package/bin/dpi.mjs +14 -9
- package/package.json +1 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +0 -77
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +0 -1
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
<meta name="referrer" content="no-referrer" />
|
|
8
8
|
<link rel="icon" type="image/svg+xml" href="/icons/durable-pi.svg" />
|
|
9
9
|
<title>Durable Pi | Network</title>
|
|
10
|
-
<script type="module" crossorigin src="/assets/index-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-Dq0CZYr7.js"></script>
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/assets/index--aoZ5Tj0.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
14
|
<div id="app"></div>
|
|
@@ -13,6 +13,7 @@ import DeviceCredentialsDialog from "./components/DeviceCredentialsDialog.vue";
|
|
|
13
13
|
import PromptDialog from "./components/PromptDialog.vue";
|
|
14
14
|
import ModelPicker from "./components/ModelPicker.vue";
|
|
15
15
|
import HelpDialog from "./components/HelpDialog.vue";
|
|
16
|
+
import LoginCard from "./components/LoginCard.vue";
|
|
16
17
|
import Toast from "./components/Toast.vue";
|
|
17
18
|
import {
|
|
18
19
|
boot,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
toggleTree,
|
|
33
34
|
toggleInspector,
|
|
34
35
|
} from "./client/store";
|
|
36
|
+
import { auth } from "./client/auth";
|
|
35
37
|
|
|
36
38
|
const onlineCount = () => network.devices.value.filter((d) => d.status === "online").length;
|
|
37
39
|
const pairWizard = ref<{ close(): Promise<void> }>();
|
|
@@ -175,6 +177,7 @@ onBeforeUnmount(() => {
|
|
|
175
177
|
</section>
|
|
176
178
|
</div>
|
|
177
179
|
<HelpDialog v-else-if="ui.dialog?.kind === 'help'" />
|
|
180
|
+
<LoginCard v-if="auth.needed" />
|
|
178
181
|
<Toast />
|
|
179
182
|
</div>
|
|
180
183
|
</template>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Admin session for the browser. The ONLY browser credential is the hub's
|
|
2
|
+
// `dpi_admin` cookie (HttpOnly — script can never read it); no token is kept in
|
|
3
|
+
// localStorage, sessionStorage or the URL.
|
|
4
|
+
//
|
|
5
|
+
// `installAdminLoginFetch` wraps window.fetch once at startup: any 401 from an
|
|
6
|
+
// admin call raises the login card (LoginCard.vue), waits for a successful
|
|
7
|
+
// login, then retries the failed call exactly once and the app continues where
|
|
8
|
+
// it was. The login call itself is never intercepted.
|
|
9
|
+
import { reactive } from "vue";
|
|
10
|
+
|
|
11
|
+
export const auth = reactive({
|
|
12
|
+
/** True while a 401 is waiting for the operator to sign in. */
|
|
13
|
+
needed: false,
|
|
14
|
+
busy: false,
|
|
15
|
+
error: "",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
type Waiter = (ok: boolean) => void;
|
|
19
|
+
let waiters = new Set<Waiter>();
|
|
20
|
+
|
|
21
|
+
const release = (ok: boolean) => {
|
|
22
|
+
const pending = [...waiters];
|
|
23
|
+
waiters = new Set();
|
|
24
|
+
for (const w of pending) w(ok);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const waitForLogin = () => new Promise<boolean>((done) => waiters.add(done));
|
|
28
|
+
|
|
29
|
+
/** POST /api/session with the typed token; on success every waiting call retries. */
|
|
30
|
+
export async function login(token: string): Promise<boolean> {
|
|
31
|
+
if (auth.busy || !token) return false;
|
|
32
|
+
auth.busy = true;
|
|
33
|
+
auth.error = "";
|
|
34
|
+
try {
|
|
35
|
+
const r = await fetch("/api/session", {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "content-type": "application/json" },
|
|
38
|
+
body: JSON.stringify({ token }),
|
|
39
|
+
});
|
|
40
|
+
// Read the body in full: an unconsumed fetch body is cancelled once its Response is
|
|
41
|
+
// garbage-collected, and harnesses that record response bodies must still read it.
|
|
42
|
+
const body = await r.text().catch(() => "");
|
|
43
|
+
if (r.ok) {
|
|
44
|
+
auth.needed = false;
|
|
45
|
+
release(true);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
auth.error =
|
|
49
|
+
r.status === 401
|
|
50
|
+
? "That token was not accepted."
|
|
51
|
+
: r.status === 429
|
|
52
|
+
? "Too many attempts. Wait a minute and try again."
|
|
53
|
+
: `Login failed (${r.status}).`;
|
|
54
|
+
void body;
|
|
55
|
+
return false;
|
|
56
|
+
} catch {
|
|
57
|
+
auth.error = "Cannot reach the hub.";
|
|
58
|
+
return false;
|
|
59
|
+
} finally {
|
|
60
|
+
auth.busy = false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Dismiss the card: waiting calls get their original 401 back. */
|
|
65
|
+
export function cancelLogin(): void {
|
|
66
|
+
auth.needed = false;
|
|
67
|
+
auth.error = "";
|
|
68
|
+
release(false);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const isSessionCall = (url: string) => {
|
|
72
|
+
try {
|
|
73
|
+
return new URL(url, location.href).pathname === "/api/session";
|
|
74
|
+
} catch {
|
|
75
|
+
return url === "/api/session";
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Wrap `fetch`: a 401 on anything except the login call itself raises the login
|
|
81
|
+
* card, and after a successful login the SAME request is retried once. A stream
|
|
82
|
+
* body cannot be replayed, so those are left alone.
|
|
83
|
+
*/
|
|
84
|
+
export function withAdminLogin(native: typeof fetch): typeof fetch {
|
|
85
|
+
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
86
|
+
const call = () => native(input, init);
|
|
87
|
+
const response = await call();
|
|
88
|
+
if (response.status !== 401) return response;
|
|
89
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
90
|
+
if (isSessionCall(url) || init?.body instanceof ReadableStream) return response;
|
|
91
|
+
auth.needed = true;
|
|
92
|
+
// Drain the refused body now: an unconsumed fetch body is cancelled when its Response
|
|
93
|
+
// is garbage-collected, and harnesses that record response bodies (tests/pairing-ui.mjs)
|
|
94
|
+
// must still be able to read it afterwards. A cancelled login hands the caller an
|
|
95
|
+
// equivalent Response so its own r.json() keeps working.
|
|
96
|
+
const text = await response.text().catch(() => "");
|
|
97
|
+
if (!(await waitForLogin()))
|
|
98
|
+
return new Response(text, { status: response.status, headers: response.headers });
|
|
99
|
+
return call();
|
|
100
|
+
}) as typeof fetch;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Patch window.fetch with the 401 retry; install once, before the first call. */
|
|
104
|
+
export function installAdminLoginFetch(): void {
|
|
105
|
+
if (typeof window === "undefined" || (window as { __adminFetch?: boolean }).__adminFetch) return;
|
|
106
|
+
(window as { __adminFetch?: boolean }).__adminFetch = true;
|
|
107
|
+
window.fetch = withAdminLogin(window.fetch.bind(window));
|
|
108
|
+
}
|
|
@@ -1,18 +1,33 @@
|
|
|
1
1
|
/** Pair links stay in RAM and may only refer to the browser's own hub. */
|
|
2
|
+
import { decodePairOffer, normalizePairCode, parsePairOfferUrl } from "../../../../../dpi-sdk/src/protocol/pairing";
|
|
3
|
+
|
|
2
4
|
export type PairHandoff = { code: string; origin: string } | { error: string };
|
|
3
5
|
|
|
4
6
|
export function readPairHandoff(url: URL): PairHandoff | undefined {
|
|
5
7
|
if (!/^\/pair\/?$/.test(url.pathname)) return undefined;
|
|
8
|
+
// v2 FIRST: the whole handoff rides the #offer= fragment, decoded client-side —
|
|
9
|
+
// the code never travels in a query, so it never reaches any server or CDN log.
|
|
10
|
+
if (url.hash.startsWith("#offer=")) {
|
|
11
|
+
const parsed = parsePairOfferUrl(url.href);
|
|
12
|
+
if (parsed) return { code: parsed.offer.code, origin: url.origin };
|
|
13
|
+
const offer = decodePairOffer(url.hash.slice("#offer=".length));
|
|
14
|
+
if (offer && offer.hub !== url.origin)
|
|
15
|
+
return {
|
|
16
|
+
error: "This pairing link does not belong to this hub. Open the original hub link instead.",
|
|
17
|
+
};
|
|
18
|
+
return { error: "Invalid pairing link. Use a complete link from the hub wizard." };
|
|
19
|
+
}
|
|
6
20
|
const params = url.searchParams;
|
|
7
21
|
if (!params.has("code") && !params.has("origin") && !params.has("v")) return undefined;
|
|
8
22
|
const code = params.get("code");
|
|
23
|
+
// The SDK owns the code shape: canonicalise a grouped/lower-case value, refuse anything else.
|
|
24
|
+
const canonical = code === null ? undefined : normalizePairCode(code);
|
|
9
25
|
if (
|
|
10
26
|
url.username ||
|
|
11
27
|
url.password ||
|
|
12
28
|
url.hash ||
|
|
13
29
|
params.getAll("code").length !== 1 ||
|
|
14
|
-
!
|
|
15
|
-
!/^[A-Z2-7]{8}$/.test(code) ||
|
|
30
|
+
!canonical ||
|
|
16
31
|
params.getAll("v").length !== 1 ||
|
|
17
32
|
params.get("v") !== "1"
|
|
18
33
|
) {
|
|
@@ -37,7 +52,7 @@ export function readPairHandoff(url: URL): PairHandoff | undefined {
|
|
|
37
52
|
};
|
|
38
53
|
}
|
|
39
54
|
}
|
|
40
|
-
return { code, origin: url.origin };
|
|
55
|
+
return { code: canonical, origin: url.origin };
|
|
41
56
|
}
|
|
42
57
|
|
|
43
58
|
/**
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The admin gate: one centered card with one password-type input. Shown when any
|
|
3
|
+
// admin call answered 401 (client/auth.ts); a successful login retries the call
|
|
4
|
+
// that was interrupted. The token itself lives only in the HttpOnly cookie — it
|
|
5
|
+
// is never stored or echoed anywhere the page can read.
|
|
6
|
+
import { nextTick, onMounted, ref } from "vue";
|
|
7
|
+
import { auth, login } from "../client/auth";
|
|
8
|
+
|
|
9
|
+
const token = ref("");
|
|
10
|
+
const input = ref<HTMLInputElement>();
|
|
11
|
+
|
|
12
|
+
async function submit() {
|
|
13
|
+
if (auth.busy || !token.value.trim()) return;
|
|
14
|
+
const ok = await login(token.value.trim());
|
|
15
|
+
token.value = "";
|
|
16
|
+
if (ok) input.value?.blur();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
onMounted(() => void nextTick(() => input.value?.focus()));
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<div class="login-veil" data-testid="login-card">
|
|
24
|
+
<form
|
|
25
|
+
class="modal login-card"
|
|
26
|
+
role="dialog"
|
|
27
|
+
aria-modal="true"
|
|
28
|
+
aria-labelledby="login-title"
|
|
29
|
+
@submit.prevent="submit"
|
|
30
|
+
>
|
|
31
|
+
<header>
|
|
32
|
+
<h2 id="login-title">Admin token</h2>
|
|
33
|
+
</header>
|
|
34
|
+
<p class="hint">
|
|
35
|
+
This hub is locked. Sign in with the token from its <code>admin.token</code> file.
|
|
36
|
+
</p>
|
|
37
|
+
<label for="admin-token">Admin token</label>
|
|
38
|
+
<input
|
|
39
|
+
id="admin-token"
|
|
40
|
+
ref="input"
|
|
41
|
+
v-model="token"
|
|
42
|
+
type="password"
|
|
43
|
+
name="admin-token"
|
|
44
|
+
autocomplete="off"
|
|
45
|
+
spellcheck="false"
|
|
46
|
+
placeholder="adm_…"
|
|
47
|
+
aria-label="Admin token"
|
|
48
|
+
:disabled="auth.busy"
|
|
49
|
+
@keydown.esc="auth.error = ''"
|
|
50
|
+
/>
|
|
51
|
+
<p v-if="auth.error" class="message-error" role="alert">{{ auth.error }}</p>
|
|
52
|
+
<footer>
|
|
53
|
+
<button class="primary" type="submit" :disabled="auth.busy || !token.trim()">
|
|
54
|
+
{{ auth.busy ? "Checking…" : "Sign in" }}
|
|
55
|
+
</button>
|
|
56
|
+
</footer>
|
|
57
|
+
</form>
|
|
58
|
+
</div>
|
|
59
|
+
</template>
|
|
@@ -22,6 +22,7 @@ import { createPairingSession, pairingAdmin, pollCode, type PairingSession } fro
|
|
|
22
22
|
import { qrSvg } from "../client/qrcode";
|
|
23
23
|
import type { PairCodeIssue, PairCodeState } from "@sdk/api";
|
|
24
24
|
import { DpiError } from "@sdk/errors";
|
|
25
|
+
import { isPairCode } from "@sdk/protocol/pairing";
|
|
25
26
|
import { settledPairPath, type PairHandoff } from "../client/pair-handoff";
|
|
26
27
|
import { CANCEL_TIMEOUT_MS, closeWizard, type CancelResult } from "../client/pair-close";
|
|
27
28
|
import { notify, network } from "../client/store";
|
|
@@ -41,11 +42,16 @@ const closing = ref(false);
|
|
|
41
42
|
const confirmed = ref(false);
|
|
42
43
|
const replaceKey = ref(false);
|
|
43
44
|
const copied = ref(false);
|
|
45
|
+
const linkCopied = ref(false);
|
|
44
46
|
const watcher = ref<(() => void) | undefined>();
|
|
45
47
|
|
|
46
48
|
const grouped = computed(() => {
|
|
47
49
|
const code = issue.value?.code ?? "";
|
|
48
|
-
return code
|
|
50
|
+
if (!isPairCode(code)) return code; // never invent separators around an unsupported value
|
|
51
|
+
// A minted code is a UUID v4: its hyphens are structure, so it is shown as it is.
|
|
52
|
+
if (code.includes("-")) return code;
|
|
53
|
+
// Legacy base32 (12 | 8 chars) reads better in blocks of four: `ABCD-2345-EF67`.
|
|
54
|
+
return (code.match(/.{4}/g) ?? []).join("-");
|
|
49
55
|
});
|
|
50
56
|
const qrData = computed(() => {
|
|
51
57
|
if (!issue.value) return "";
|
|
@@ -205,6 +211,18 @@ async function copyCode(): Promise<void> {
|
|
|
205
211
|
}
|
|
206
212
|
}
|
|
207
213
|
|
|
214
|
+
/** Copies the v2 offer link (code never in a query); falls back to the v1 pair URL. */
|
|
215
|
+
async function copyLink(): Promise<void> {
|
|
216
|
+
if (!issue.value) return;
|
|
217
|
+
try {
|
|
218
|
+
await navigator.clipboard.writeText(issue.value.offerUrl ?? issue.value.pairUrl);
|
|
219
|
+
linkCopied.value = true;
|
|
220
|
+
setTimeout(() => (linkCopied.value = false), 1500);
|
|
221
|
+
} catch {
|
|
222
|
+
error.value = "Clipboard unavailable; copy the link manually.";
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
208
226
|
async function close(): Promise<void> {
|
|
209
227
|
if (closing.value) return;
|
|
210
228
|
closing.value = true;
|
|
@@ -275,9 +293,14 @@ onBeforeUnmount(() => {
|
|
|
275
293
|
<div class="pair-grid">
|
|
276
294
|
<div class="pair-code-box">
|
|
277
295
|
<div class="pair-code" aria-label="Pairing code">{{ grouped }}</div>
|
|
278
|
-
<
|
|
279
|
-
<
|
|
280
|
-
|
|
296
|
+
<div class="pair-copy-actions">
|
|
297
|
+
<button type="button" class="small" @click="copyCode">
|
|
298
|
+
<Copy :size="14" /> {{ copied ? "Copied" : "Copy code" }}
|
|
299
|
+
</button>
|
|
300
|
+
<button type="button" class="small" @click="copyLink" title="Self-contained link: hub, code and expiry all in the fragment — it never reaches server logs">
|
|
301
|
+
<Copy :size="14" /> {{ linkCopied ? "Copied link" : "Copy link" }}
|
|
302
|
+
</button>
|
|
303
|
+
</div>
|
|
281
304
|
<p class="hint">
|
|
282
305
|
Run the device pairing command and scan, or enter this code on the device. Expires in
|
|
283
306
|
5 minutes.
|
|
@@ -395,6 +418,11 @@ onBeforeUnmount(() => {
|
|
|
395
418
|
font-family: var(--mono, monospace);
|
|
396
419
|
margin-bottom: 0.35rem;
|
|
397
420
|
}
|
|
421
|
+
.pair-wizard .pair-copy-actions {
|
|
422
|
+
display: flex;
|
|
423
|
+
flex-wrap: wrap;
|
|
424
|
+
gap: 0.4rem;
|
|
425
|
+
}
|
|
398
426
|
.pair-wizard .pair-qr svg {
|
|
399
427
|
border: 1px solid var(--border, #333);
|
|
400
428
|
border-radius: 6px;
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { createApp } from "vue";
|
|
2
2
|
import App from "./App.vue";
|
|
3
3
|
import { ui } from "./client/store";
|
|
4
|
+
import { installAdminLoginFetch } from "./client/auth";
|
|
4
5
|
import { barePairHome, readPairHandoff } from "./client/pair-handoff";
|
|
5
6
|
import "./style.css";
|
|
6
7
|
import "./network.css";
|
|
7
8
|
import "./jobs.css";
|
|
8
9
|
import "./extensions.css";
|
|
10
|
+
// Before anything can call out: a 401 from an admin route raises the login card
|
|
11
|
+
// and the failed call is retried after a successful login (client/auth.ts).
|
|
12
|
+
installAdminLoginFetch();
|
|
9
13
|
const url = new URL(window.location.href);
|
|
10
14
|
if (/^\/pair\/?$/.test(url.pathname)) {
|
|
11
15
|
const handoff = readPairHandoff(url);
|
|
@@ -1753,6 +1753,30 @@ svg {
|
|
|
1753
1753
|
flex-direction: column;
|
|
1754
1754
|
overflow: hidden;
|
|
1755
1755
|
}
|
|
1756
|
+
/* The admin gate: one centered card over a dark veil while a 401 waits for the
|
|
1757
|
+
operator's token. Same panel/border/radius language as the dialogs. */
|
|
1758
|
+
.login-veil {
|
|
1759
|
+
position: fixed;
|
|
1760
|
+
inset: 0;
|
|
1761
|
+
z-index: 100;
|
|
1762
|
+
background: #0d0808e0;
|
|
1763
|
+
backdrop-filter: blur(7px);
|
|
1764
|
+
display: flex;
|
|
1765
|
+
align-items: center;
|
|
1766
|
+
justify-content: center;
|
|
1767
|
+
padding: 24px;
|
|
1768
|
+
}
|
|
1769
|
+
.login-card {
|
|
1770
|
+
width: 400px;
|
|
1771
|
+
}
|
|
1772
|
+
.login-card .hint {
|
|
1773
|
+
color: var(--fg);
|
|
1774
|
+
font-size: 15px;
|
|
1775
|
+
line-height: 1.55;
|
|
1776
|
+
}
|
|
1777
|
+
.login-card footer {
|
|
1778
|
+
justify-content: flex-end;
|
|
1779
|
+
}
|
|
1756
1780
|
.model-list {
|
|
1757
1781
|
overflow: auto;
|
|
1758
1782
|
margin-top: 12px;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub admin authentication (the operator's own credential — devices use pairing
|
|
3
|
+
* credentials and never see any of this).
|
|
4
|
+
*
|
|
5
|
+
* Token bootstrap (mirrors the device-side DPI_TOKEN bootstrap):
|
|
6
|
+
* - `DPI_ADMIN_TOKEN` env wins outright; anything shorter than 32 characters is a
|
|
7
|
+
* configuration mistake and REFUSES to boot — it is never silently replaced.
|
|
8
|
+
* - Otherwise the token is minted at first boot (`adm_` + 32 random bytes,
|
|
9
|
+
* base64url) into `<DPI_HOME>/admin.token` (or `DPI_ADMIN_TOKEN_FILE`),
|
|
10
|
+
* owner-only 0600, and logged once.
|
|
11
|
+
*
|
|
12
|
+
* Presentation:
|
|
13
|
+
* - Browsers: `POST /api/session {token}` sets a stateless cookie `dpi_admin` =
|
|
14
|
+
* base64url(payload) + "." + base64url(HMAC-SHA256(payload, adminToken)) with
|
|
15
|
+
* payload `{"exp":<ms>}`. HttpOnly, SameSite=Strict, Path=/, 4 h, `Secure` when
|
|
16
|
+
* the request arrived via the TLS proxy. Forging it requires the token itself;
|
|
17
|
+
* it survives a hub restart because it carries no server-side state.
|
|
18
|
+
* - Non-browser clients (TUI, tests, curl): the raw token as
|
|
19
|
+
* `Authorization: Bearer <adm_…>` or `X-Admin-Token`.
|
|
20
|
+
*
|
|
21
|
+
* Every comparison hashes both sides with SHA-256 first and uses timingSafeEqual
|
|
22
|
+
* (the device-side bearerAuth pattern), so the cookie HMAC and the raw token are
|
|
23
|
+
* both compared in constant time.
|
|
24
|
+
*/
|
|
25
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
26
|
+
import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { dirname, join } from "node:path";
|
|
28
|
+
import type { RequestHandler } from "express";
|
|
29
|
+
import { homedir } from "node:os";
|
|
30
|
+
|
|
31
|
+
export const ADMIN_COOKIE = "dpi_admin";
|
|
32
|
+
/** How long a login cookie is worth. */
|
|
33
|
+
export const SESSION_TTL_MS = 4 * 60 * 60 * 1000;
|
|
34
|
+
/** A set-but-short env token is a configuration mistake, never replaced silently. */
|
|
35
|
+
export const MIN_TOKEN_CHARS = 32;
|
|
36
|
+
/** Login attempts per client address: 5 per minute, burst 5. */
|
|
37
|
+
export const LOGIN_RATE = { capacity: 5, perMinute: 5 } as const;
|
|
38
|
+
|
|
39
|
+
const sha256 = (value: string) => createHash("sha256").update(value, "utf8").digest();
|
|
40
|
+
|
|
41
|
+
/** Constant-time string equality (hash first so lengths cannot leak). */
|
|
42
|
+
export function tokenEquals(presented: string, expected: string): boolean {
|
|
43
|
+
const a = sha256(presented);
|
|
44
|
+
const b = sha256(expected);
|
|
45
|
+
return timingSafeEqual(a, b);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AdminTokenInfo {
|
|
49
|
+
token: string;
|
|
50
|
+
/** Where the token file lives (even when the env override is in force). */
|
|
51
|
+
path: string;
|
|
52
|
+
/** True when this boot minted the token file. */
|
|
53
|
+
created: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the admin token at boot: env override (>= 32 chars or throw), the
|
|
58
|
+
* existing token file, or a freshly minted one written 0600.
|
|
59
|
+
*/
|
|
60
|
+
export function bootstrapAdminToken(
|
|
61
|
+
home = process.env.DPI_HOME ?? join(homedir(), ".dpi"),
|
|
62
|
+
): AdminTokenInfo {
|
|
63
|
+
const path = process.env.DPI_ADMIN_TOKEN_FILE ?? join(home, "admin.token");
|
|
64
|
+
const env = process.env.DPI_ADMIN_TOKEN;
|
|
65
|
+
if (env !== undefined && env !== "") {
|
|
66
|
+
if (env.length < MIN_TOKEN_CHARS)
|
|
67
|
+
throw Error(
|
|
68
|
+
`DPI_ADMIN_TOKEN is ${env.length} characters; an admin token must be at least ${MIN_TOKEN_CHARS}. ` +
|
|
69
|
+
"A short token is a configuration mistake — fix it or unset the variable; the hub will not replace it.",
|
|
70
|
+
);
|
|
71
|
+
return { token: env, path, created: false };
|
|
72
|
+
}
|
|
73
|
+
let existing = "";
|
|
74
|
+
try {
|
|
75
|
+
existing = readFileSync(path, "utf8").trim();
|
|
76
|
+
} catch {
|
|
77
|
+
/* first run (or an unreadable file): mint below */
|
|
78
|
+
}
|
|
79
|
+
if (existing) {
|
|
80
|
+
// The token file must stay 0600 — tighten quietly if a copy restored looser bits
|
|
81
|
+
// (the same quiet re-tightening Audit applies to the registry file).
|
|
82
|
+
try {
|
|
83
|
+
if ((statSync(path).mode & 0o777) !== 0o600) chmodSync(path, 0o600);
|
|
84
|
+
} catch {
|
|
85
|
+
/* best effort */
|
|
86
|
+
}
|
|
87
|
+
return { token: existing, path, created: false };
|
|
88
|
+
}
|
|
89
|
+
const token = `adm_${randomBytes(32).toString("base64url")}`;
|
|
90
|
+
// DPI_ADMIN_TOKEN_FILE may name a directory that does not exist yet — create it
|
|
91
|
+
// owner-only rather than refusing the operator's explicit choice of location.
|
|
92
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
93
|
+
writeFileSync(path, `${token}\n`, { mode: 0o600 });
|
|
94
|
+
chmodSync(path, 0o600); // a pre-existing (e.g. empty) file may carry wider bits
|
|
95
|
+
console.log(`first run: created admin token in ${path}`);
|
|
96
|
+
return { token, path, created: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The cookie value for a session expiring at `exp` (epoch ms). */
|
|
100
|
+
export function signSession(adminToken: string, exp: number): string {
|
|
101
|
+
const payload = Buffer.from(JSON.stringify({ exp }), "utf8").toString("base64url");
|
|
102
|
+
const mac = createHmac("sha256", adminToken).update(payload).digest("base64url");
|
|
103
|
+
return `${payload}.${mac}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** True when `cookie` is a well-formed, correctly signed, unexpired session. */
|
|
107
|
+
export function verifySession(
|
|
108
|
+
adminToken: string,
|
|
109
|
+
cookie: string | undefined,
|
|
110
|
+
now = Date.now(),
|
|
111
|
+
): boolean {
|
|
112
|
+
if (!cookie) return false;
|
|
113
|
+
const dot = cookie.indexOf(".");
|
|
114
|
+
if (dot <= 0) return false;
|
|
115
|
+
const payload = cookie.slice(0, dot);
|
|
116
|
+
const given = cookie.slice(dot + 1);
|
|
117
|
+
const expected = createHmac("sha256", adminToken).update(payload).digest("base64url");
|
|
118
|
+
const a = Buffer.from(given);
|
|
119
|
+
const b = Buffer.from(expected);
|
|
120
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) return false;
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
|
|
123
|
+
exp?: unknown;
|
|
124
|
+
};
|
|
125
|
+
return typeof parsed.exp === "number" && Number.isFinite(parsed.exp) && parsed.exp > now;
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The `dpi_admin` value from a raw Cookie header, or undefined. */
|
|
132
|
+
export function adminCookie(header: string | string[] | undefined): string | undefined {
|
|
133
|
+
const cookies = Array.isArray(header) ? header.join("; ") : header;
|
|
134
|
+
if (!cookies) return undefined;
|
|
135
|
+
for (const part of cookies.split(";")) {
|
|
136
|
+
const eq = part.indexOf("=");
|
|
137
|
+
if (eq <= 0) continue;
|
|
138
|
+
if (part.slice(0, eq).trim() === ADMIN_COOKIE) return part.slice(eq + 1).trim();
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The presented raw token: `Authorization: Bearer <t>` or `X-Admin-Token: <t>`. */
|
|
144
|
+
export function bearerToken(req: { headers: Record<string, unknown> }): string {
|
|
145
|
+
const auth = req.headers.authorization;
|
|
146
|
+
const header = typeof auth === "string" ? auth : "";
|
|
147
|
+
if (header.startsWith("Bearer ")) return header.slice(7).trim();
|
|
148
|
+
const alt = req.headers["x-admin-token"];
|
|
149
|
+
return typeof alt === "string" ? alt.trim() : "";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Require the admin credential (cookie or bearer) on a route. Answers
|
|
154
|
+
* `{error:{code:"unauthorized"}}` with 401 otherwise.
|
|
155
|
+
*/
|
|
156
|
+
export function requireAdmin(adminToken: string): RequestHandler {
|
|
157
|
+
return (req, res, next) => {
|
|
158
|
+
const bearer = bearerToken(req);
|
|
159
|
+
if (
|
|
160
|
+
(bearer && tokenEquals(bearer, adminToken)) ||
|
|
161
|
+
verifySession(adminToken, adminCookie(req.headers.cookie))
|
|
162
|
+
)
|
|
163
|
+
return void next();
|
|
164
|
+
res
|
|
165
|
+
.status(401)
|
|
166
|
+
.json({ error: { code: "unauthorized", message: "Admin authentication required" } });
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -38,6 +38,8 @@ export const AUDIT_ACTIONS = [
|
|
|
38
38
|
"token.rotate_regenerated",
|
|
39
39
|
"tunnel.connect",
|
|
40
40
|
"tunnel.disconnect",
|
|
41
|
+
"admin.token_created",
|
|
42
|
+
"admin.login",
|
|
41
43
|
"auth.denied",
|
|
42
44
|
"rate_limited",
|
|
43
45
|
"tunnel.listener_error",
|
|
@@ -90,6 +92,8 @@ export function scrubSecrets(value: string): string {
|
|
|
90
92
|
let out = value;
|
|
91
93
|
out = out.replace(/dpt_[A-Za-z0-9_-]{1,64}/g, "dpt_<redacted>");
|
|
92
94
|
out = out.replace(/dpi_[A-Za-z0-9_-]{1,64}/g, "dpi_<redacted>");
|
|
95
|
+
// The hub's own admin token (adm_ + 32 random bytes, base64url).
|
|
96
|
+
out = out.replace(/adm_[A-Za-z0-9_-]{32,}/g, "adm_<redacted>");
|
|
93
97
|
out = out.replace(
|
|
94
98
|
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----|$)/g,
|
|
95
99
|
"<private-key-redacted>",
|
|
@@ -97,10 +101,18 @@ export function scrubSecrets(value: string): string {
|
|
|
97
101
|
out = out.replace(/(authorization\s*[:=]\s*)(\S+)/gi, "$1<redacted>");
|
|
98
102
|
out = out.replace(/(bearer\s+)([A-Za-z0-9._-]{8,})/gi, "$1<redacted>");
|
|
99
103
|
out = out.replace(/([?&]code=)[A-Za-z0-9_-]+/g, "$1<redacted>");
|
|
104
|
+
// v2 offer links: the secret IS the #offer= fragment payload — redact it wherever a URL shows up.
|
|
105
|
+
out = out.replace(/(#offer=)[A-Za-z0-9_-]+/g, "$1<redacted>");
|
|
100
106
|
// Credential material without its prefix (slices, echo): 43-char base64url runs.
|
|
101
107
|
out = out.replace(/(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g, "<redacted>");
|
|
102
|
-
// Pairing codes as standalone values ("code":"AB2C7DEF") but not ordinary words.
|
|
103
|
-
out = out.replace(/(["'?=:,\s])([A-Z2-7]{8})(?=["'?\s:,}]|$)/g, "$1<code-redacted>");
|
|
108
|
+
// Pairing codes as standalone values ("code":"AB2C7DEF" / "ABCD2345EF67") but not ordinary words.
|
|
109
|
+
out = out.replace(/(["'?=:,\s])([A-Z2-7]{12}|[A-Z2-7]{8})(?=["'?\s:,}]|$)/g, "$1<code-redacted>");
|
|
110
|
+
// A MINTED code is a canonical UUID v4. Only a value LABELLED a code is a secret here: an audit
|
|
111
|
+
// row's own codeId/enrollmentId are correlation ids and must stay readable.
|
|
112
|
+
out = out.replace(
|
|
113
|
+
/(\bcode["']?\s*[:=]\s*["']?)([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})(?=["'?\s:,}&]|$)/gi,
|
|
114
|
+
"$1<code-redacted>",
|
|
115
|
+
);
|
|
104
116
|
return out;
|
|
105
117
|
}
|
|
106
118
|
|