workerdeck 0.0.0 → 0.6.0
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/LICENSE +21 -0
- package/README.md +98 -9
- package/build/cli.d.mts +1 -0
- package/build/cli.mjs +122 -0
- package/build/cli.mjs.map +1 -0
- package/build/guard-D3VN855w.mjs +241 -0
- package/build/guard-D3VN855w.mjs.map +1 -0
- package/build/index.d.mts +286 -0
- package/build/index.mjs +3 -0
- package/build/instance-kupxU5UD.mjs +1019 -0
- package/build/instance-kupxU5UD.mjs.map +1 -0
- package/package.json +55 -12
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
import { createReadStream, existsSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
3
|
+
import { createFileSessionStore, createWorkerServer } from "@workerdeck/server";
|
|
4
|
+
import { dashboardDir } from "@workerdeck/web";
|
|
5
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
6
|
+
import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
//#region src/auth-key.ts
|
|
10
|
+
/** 48 hex chars — far past `createCliAuth`'s 12-char floor, and header-safe. */
|
|
11
|
+
const generateKey = () => randomBytes(24).toString("hex");
|
|
12
|
+
/** A stored key must be one printable-ASCII line long enough to be a secret:
|
|
13
|
+
* both transports (HTTP header, login form) choke on anything else, and a
|
|
14
|
+
* truncated or garbage file should regenerate, not crash or half-work. */
|
|
15
|
+
const usableStoredKey = (raw) => {
|
|
16
|
+
const line = raw.split("\n", 1)[0]?.trim() ?? "";
|
|
17
|
+
return line.length >= 12 && /^[\x21-\x7e]+$/.test(line) ? line : null;
|
|
18
|
+
};
|
|
19
|
+
async function materializeAuthKey(stateDir, options = {}) {
|
|
20
|
+
const warn = options.warn ?? ((message) => process.stderr.write(`[workerdeck] ${message}\n`));
|
|
21
|
+
if (stateDir === null) return {
|
|
22
|
+
key: generateKey(),
|
|
23
|
+
source: "ephemeral",
|
|
24
|
+
path: null
|
|
25
|
+
};
|
|
26
|
+
const path = join(stateDir, "auth-key");
|
|
27
|
+
let raw = null;
|
|
28
|
+
try {
|
|
29
|
+
raw = await readFile(path, "utf8");
|
|
30
|
+
} catch {
|
|
31
|
+
raw = null;
|
|
32
|
+
}
|
|
33
|
+
if (raw !== null) {
|
|
34
|
+
const key = usableStoredKey(raw);
|
|
35
|
+
if (key !== null) {
|
|
36
|
+
try {
|
|
37
|
+
const { mode } = await stat(path);
|
|
38
|
+
if ((mode & 63) !== 0) warn(`auth key file ${path} is readable by other users (mode ${(mode & 511).toString(8)}) — run: chmod 600 ${path}`);
|
|
39
|
+
} catch {}
|
|
40
|
+
return {
|
|
41
|
+
key,
|
|
42
|
+
source: "stored",
|
|
43
|
+
path
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const key = generateKey();
|
|
48
|
+
await mkdir(stateDir, {
|
|
49
|
+
recursive: true,
|
|
50
|
+
mode: 448
|
|
51
|
+
});
|
|
52
|
+
await writeFile(path, `${key}\n`, { mode: 384 });
|
|
53
|
+
await chmod(path, 384);
|
|
54
|
+
return {
|
|
55
|
+
key,
|
|
56
|
+
source: "created",
|
|
57
|
+
path
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/auth.ts
|
|
62
|
+
const MIN_SECRET_LENGTH = 12;
|
|
63
|
+
const DEFAULT_COOKIE_NAME = "workerdeck_session";
|
|
64
|
+
const DEFAULT_TTL_MS = 10080 * 60 * 1e3;
|
|
65
|
+
const DEFAULT_THROTTLE_WINDOW_MS = 900 * 1e3;
|
|
66
|
+
const DEFAULT_MAX_FAILURES_PER_IP = 10;
|
|
67
|
+
const DEFAULT_MAX_FAILURES_GLOBAL = 100;
|
|
68
|
+
/** Only successful logins insert, so this cap only fences the secret-holder's
|
|
69
|
+
* own memory use (every login within the ttl is a live entry). Oldest goes. */
|
|
70
|
+
const MAX_SESSIONS = 100;
|
|
71
|
+
const MAX_LOGIN_BODY_BYTES = 4096;
|
|
72
|
+
const SAFE_METHODS = new Set([
|
|
73
|
+
"GET",
|
|
74
|
+
"HEAD",
|
|
75
|
+
"OPTIONS"
|
|
76
|
+
]);
|
|
77
|
+
const sha256 = (value) => createHash("sha256").update(value).digest();
|
|
78
|
+
function createCliAuth(options = {}) {
|
|
79
|
+
const { secret } = options;
|
|
80
|
+
const enabled = secret !== void 0;
|
|
81
|
+
if (secret !== void 0 && secret.length < MIN_SECRET_LENGTH) throw new Error(`createCliAuth: secret must be at least ${MIN_SECRET_LENGTH} characters — use a long random value, or leave it unset to run without auth on loopback`);
|
|
82
|
+
const cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;
|
|
83
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
84
|
+
if (!(ttlMs > 0)) throw new Error("createCliAuth: ttlMs must be positive");
|
|
85
|
+
const trustProxy = options.trustProxy === true;
|
|
86
|
+
const windowMs = options.throttle?.windowMs ?? DEFAULT_THROTTLE_WINDOW_MS;
|
|
87
|
+
const maxFailuresPerIp = options.throttle?.maxFailuresPerIp ?? DEFAULT_MAX_FAILURES_PER_IP;
|
|
88
|
+
const maxFailuresGlobal = options.throttle?.maxFailuresGlobal ?? DEFAULT_MAX_FAILURES_GLOBAL;
|
|
89
|
+
const allowedOrigins = new Set((options.allowedOrigins ?? []).map((entry) => {
|
|
90
|
+
try {
|
|
91
|
+
return new URL(entry).origin;
|
|
92
|
+
} catch {
|
|
93
|
+
throw new Error(`createCliAuth: allowedOrigins entry is not a valid origin: ${JSON.stringify(entry)}`);
|
|
94
|
+
}
|
|
95
|
+
}));
|
|
96
|
+
/** Both the raw secret and session tokens are compared as fixed-length SHA-256
|
|
97
|
+
* digests via timingSafeEqual / digest-keyed lookup, so no code path compares
|
|
98
|
+
* secret material byte-by-byte with early exit — and unequal input lengths
|
|
99
|
+
* leak nothing either. */
|
|
100
|
+
const secretDigest = secret === void 0 ? void 0 : sha256(secret);
|
|
101
|
+
const secretMatches = (candidate) => secretDigest !== void 0 && timingSafeEqual(sha256(candidate), secretDigest);
|
|
102
|
+
/**
|
|
103
|
+
* Browser sessions are a server-side table, not signed tokens: logout must
|
|
104
|
+
* actually invalidate, and a stateless HMAC token stays valid until expiry no
|
|
105
|
+
* matter what the server thinks. This is one long-lived process (multi-node
|
|
106
|
+
* is a non-goal), so "table" means one Map — the cost is that a restart signs
|
|
107
|
+
* every browser out, which for an operator dashboard one secret away from
|
|
108
|
+
* re-login is the right trade. Keys are token digests: recovering an entry's
|
|
109
|
+
* token from lookup timing would need a SHA-256 preimage.
|
|
110
|
+
*/
|
|
111
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
112
|
+
const tokenKey = (token) => sha256(token).toString("hex");
|
|
113
|
+
const createSession = () => {
|
|
114
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
115
|
+
const oldest = sessions.keys().next().value;
|
|
116
|
+
if (oldest !== void 0) sessions.delete(oldest);
|
|
117
|
+
}
|
|
118
|
+
const token = randomBytes(32).toString("base64url");
|
|
119
|
+
sessions.set(tokenKey(token), { expiresAt: Date.now() + ttlMs });
|
|
120
|
+
return token;
|
|
121
|
+
};
|
|
122
|
+
const cookieToken = (req) => {
|
|
123
|
+
const header = req.headers.cookie;
|
|
124
|
+
if (typeof header !== "string") return void 0;
|
|
125
|
+
for (const part of header.split(";")) {
|
|
126
|
+
const eq = part.indexOf("=");
|
|
127
|
+
if (eq === -1) continue;
|
|
128
|
+
if (part.slice(0, eq).trim() === cookieName) return part.slice(eq + 1).trim();
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const hasSession = (req) => {
|
|
132
|
+
const token = cookieToken(req);
|
|
133
|
+
if (token === void 0 || token === "") return false;
|
|
134
|
+
const key = tokenKey(token);
|
|
135
|
+
const entry = sessions.get(key);
|
|
136
|
+
if (entry === void 0) return false;
|
|
137
|
+
if (entry.expiresAt <= Date.now()) {
|
|
138
|
+
sessions.delete(key);
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
};
|
|
143
|
+
/** Last value of a possibly comma-joined forwarded header — the one appended
|
|
144
|
+
* (or set) by the trusted proxy; every earlier position is client-writable. */
|
|
145
|
+
const forwardedLast = (value) => {
|
|
146
|
+
if (value === void 0) return void 0;
|
|
147
|
+
const last = (Array.isArray(value) ? value.join(",") : value).split(",").at(-1)?.trim();
|
|
148
|
+
return last === "" ? void 0 : last;
|
|
149
|
+
};
|
|
150
|
+
const isSecure = (req) => {
|
|
151
|
+
if (req.socket.encrypted === true) return true;
|
|
152
|
+
return trustProxy && forwardedLast(req.headers["x-forwarded-proto"])?.toLowerCase() === "https";
|
|
153
|
+
};
|
|
154
|
+
/** The origin this server believes it is being served as, from the request's
|
|
155
|
+
* own Host (or the proxy's forwarded host) — the only self-knowledge we have. */
|
|
156
|
+
const expectedOrigin = (req) => {
|
|
157
|
+
const host = (trustProxy ? forwardedLast(req.headers["x-forwarded-host"]) : void 0) ?? req.headers.host;
|
|
158
|
+
if (host === void 0 || host === "") return null;
|
|
159
|
+
try {
|
|
160
|
+
return new URL(`${isSecure(req) ? "https" : "http"}://${host}`).origin;
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* The CSRF core. `SameSite=Lax` alone is not enough for two reasons: same
|
|
167
|
+
* *site* is not same *origin* (another port on localhost — any other local
|
|
168
|
+
* web app — is same-site, cookies attach), and the WS handshake is exempt
|
|
169
|
+
* from CORS, so a foreign page that gets the cookie attached can read the
|
|
170
|
+
* stream. So the Origin header is checked explicitly, against the request's
|
|
171
|
+
* own origin (full scheme + authority: an http:// page on the same host must
|
|
172
|
+
* not drive the https:// dashboard) or the operator's allowlist. `Origin:
|
|
173
|
+
* null` and unparseable values are foreign. Verdicts are tri-state because
|
|
174
|
+
* absence means different things per call site: every current browser sends
|
|
175
|
+
* Origin on cross-site POSTs and every WS handshake, so absence means a
|
|
176
|
+
* non-browser client — which carries no ambient cookie and gets to decide
|
|
177
|
+
* per-endpoint below.
|
|
178
|
+
*/
|
|
179
|
+
const originVerdict = (req) => {
|
|
180
|
+
const raw = req.headers.origin;
|
|
181
|
+
if (raw === void 0) return "absent";
|
|
182
|
+
let origin;
|
|
183
|
+
try {
|
|
184
|
+
origin = new URL(raw).origin;
|
|
185
|
+
} catch {
|
|
186
|
+
return "foreign";
|
|
187
|
+
}
|
|
188
|
+
if (allowedOrigins.has(origin)) return "ok";
|
|
189
|
+
const expected = expectedOrigin(req);
|
|
190
|
+
return expected !== null && origin === expected ? "ok" : "foreign";
|
|
191
|
+
};
|
|
192
|
+
const headerSecret = (req) => {
|
|
193
|
+
const key = req.headers["x-workerdeck-key"];
|
|
194
|
+
if (typeof key === "string" && key !== "") return key;
|
|
195
|
+
const authorization = req.headers.authorization;
|
|
196
|
+
if (typeof authorization === "string") {
|
|
197
|
+
const match = /^Bearer\s+(.+)$/i.exec(authorization);
|
|
198
|
+
if (match !== null) return match[1];
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
const openPrincipal = {
|
|
202
|
+
via: "open",
|
|
203
|
+
canManageProfiles: true
|
|
204
|
+
};
|
|
205
|
+
const authenticate = (req) => {
|
|
206
|
+
if (!enabled) return openPrincipal;
|
|
207
|
+
const provided = headerSecret(req);
|
|
208
|
+
if (provided !== void 0) return secretMatches(provided) ? {
|
|
209
|
+
via: "header",
|
|
210
|
+
canManageProfiles: true
|
|
211
|
+
} : null;
|
|
212
|
+
if (!hasSession(req)) return null;
|
|
213
|
+
const verdict = originVerdict(req);
|
|
214
|
+
if (verdict === "foreign") return null;
|
|
215
|
+
const upgrade = req.headers.upgrade;
|
|
216
|
+
const isUpgrade = typeof upgrade === "string" && upgrade.toLowerCase().includes("websocket");
|
|
217
|
+
const unsafe = !SAFE_METHODS.has((req.method ?? "GET").toUpperCase());
|
|
218
|
+
if ((isUpgrade || unsafe) && verdict !== "ok") return null;
|
|
219
|
+
return {
|
|
220
|
+
via: "cookie",
|
|
221
|
+
canManageProfiles: true
|
|
222
|
+
};
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* Login throttle: the secret is the only factor and the endpoint is reachable
|
|
226
|
+
* by anyone who can reach the port, so guessing must be rate-limited. Failed
|
|
227
|
+
* attempts count per client IP inside a fixed window, with a global cap
|
|
228
|
+
* behind it so rotating IPs (trivial over IPv6) buys an attacker nothing.
|
|
229
|
+
* Only wrong secrets count — malformed requests and foreign-Origin posts are
|
|
230
|
+
* refused earlier precisely so a hostile page cannot burn a victim IP's
|
|
231
|
+
* budget cross-site. The global cap also bounds this map's size: expired
|
|
232
|
+
* entries are swept once it grows past a nominal size.
|
|
233
|
+
*/
|
|
234
|
+
const failures = /* @__PURE__ */ new Map();
|
|
235
|
+
const globalFailures = {
|
|
236
|
+
count: 0,
|
|
237
|
+
windowStart: 0
|
|
238
|
+
};
|
|
239
|
+
const clientIp = (req) => (trustProxy ? forwardedLast(req.headers["x-forwarded-for"]) : void 0) ?? req.socket.remoteAddress ?? "unknown";
|
|
240
|
+
const blockedMs = (entry, max, now) => entry !== void 0 && entry.count >= max && now - entry.windowStart < windowMs ? entry.windowStart + windowMs - now : 0;
|
|
241
|
+
const loginBlockedMs = (ip, now) => Math.max(blockedMs(failures.get(ip), maxFailuresPerIp, now), blockedMs(globalFailures, maxFailuresGlobal, now));
|
|
242
|
+
const recordFailure = (ip, now) => {
|
|
243
|
+
if (failures.size > 256) {
|
|
244
|
+
for (const [key, entry] of failures) if (now - entry.windowStart >= windowMs) failures.delete(key);
|
|
245
|
+
}
|
|
246
|
+
const entry = failures.get(ip);
|
|
247
|
+
if (entry === void 0 || now - entry.windowStart >= windowMs) failures.set(ip, {
|
|
248
|
+
count: 1,
|
|
249
|
+
windowStart: now
|
|
250
|
+
});
|
|
251
|
+
else entry.count += 1;
|
|
252
|
+
if (now - globalFailures.windowStart >= windowMs) {
|
|
253
|
+
globalFailures.count = 1;
|
|
254
|
+
globalFailures.windowStart = now;
|
|
255
|
+
} else globalFailures.count += 1;
|
|
256
|
+
};
|
|
257
|
+
const cookieAttributes = (req) => {
|
|
258
|
+
const attrs = [
|
|
259
|
+
"Path=/",
|
|
260
|
+
"HttpOnly",
|
|
261
|
+
"SameSite=Lax"
|
|
262
|
+
];
|
|
263
|
+
if (isSecure(req)) attrs.push("Secure");
|
|
264
|
+
return attrs;
|
|
265
|
+
};
|
|
266
|
+
const setCookieValue = (token, req) => [
|
|
267
|
+
`${cookieName}=${token}`,
|
|
268
|
+
`Max-Age=${Math.ceil(ttlMs / 1e3)}`,
|
|
269
|
+
...cookieAttributes(req)
|
|
270
|
+
].join("; ");
|
|
271
|
+
const clearCookieValue = (req) => [
|
|
272
|
+
`${cookieName}=`,
|
|
273
|
+
"Max-Age=0",
|
|
274
|
+
...cookieAttributes(req)
|
|
275
|
+
].join("; ");
|
|
276
|
+
const respondJson = (res, status, body, headers) => {
|
|
277
|
+
res.writeHead(status, {
|
|
278
|
+
"content-type": "application/json",
|
|
279
|
+
"cache-control": "no-store",
|
|
280
|
+
...headers
|
|
281
|
+
}).end(JSON.stringify(body));
|
|
282
|
+
};
|
|
283
|
+
const respondRedirect = (res, location, headers) => {
|
|
284
|
+
res.writeHead(303, {
|
|
285
|
+
location,
|
|
286
|
+
"cache-control": "no-store",
|
|
287
|
+
...headers
|
|
288
|
+
}).end();
|
|
289
|
+
};
|
|
290
|
+
/** JSON responses when the client asks for them, 303 redirects otherwise —
|
|
291
|
+
* so a dependency-free `<form method="post">` login page works without JS,
|
|
292
|
+
* and a fetch()-based one gets real status codes. */
|
|
293
|
+
const wantsJson = (req) => (req.headers.accept ?? "").includes("application/json");
|
|
294
|
+
const readBody = (req, maxBytes) => new Promise((resolve) => {
|
|
295
|
+
const chunks = [];
|
|
296
|
+
let size = 0;
|
|
297
|
+
let settled = false;
|
|
298
|
+
const finish = (value) => {
|
|
299
|
+
if (!settled) {
|
|
300
|
+
settled = true;
|
|
301
|
+
resolve(value);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
req.on("data", (chunk) => {
|
|
305
|
+
size += chunk.length;
|
|
306
|
+
if (size > maxBytes) {
|
|
307
|
+
finish(null);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
chunks.push(chunk);
|
|
311
|
+
});
|
|
312
|
+
req.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
|
|
313
|
+
req.on("error", () => finish(null));
|
|
314
|
+
});
|
|
315
|
+
const handleLogin = async (req, res) => {
|
|
316
|
+
const json = wantsJson(req);
|
|
317
|
+
if (!enabled) {
|
|
318
|
+
respondJson(res, 409, { error: "auth is disabled: no secret is configured" });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (originVerdict(req) === "foreign") {
|
|
322
|
+
respondJson(res, 403, { error: "origin not allowed" });
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const ip = clientIp(req);
|
|
326
|
+
const blocked = loginBlockedMs(ip, Date.now());
|
|
327
|
+
if (blocked > 0) {
|
|
328
|
+
const retryAfter = String(Math.ceil(blocked / 1e3));
|
|
329
|
+
if (json) respondJson(res, 429, { error: "too many failed attempts" }, { "retry-after": retryAfter });
|
|
330
|
+
else respondRedirect(res, "/?auth=throttled", { "retry-after": retryAfter });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const body = await readBody(req, MAX_LOGIN_BODY_BYTES);
|
|
334
|
+
if (body === null) {
|
|
335
|
+
respondJson(res, 413, { error: "body too large" });
|
|
336
|
+
res.once("finish", () => req.destroy());
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const contentType = (req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
|
|
340
|
+
let candidate;
|
|
341
|
+
if (contentType === "application/x-www-form-urlencoded") candidate = new URLSearchParams(body).get("secret");
|
|
342
|
+
else if (contentType === "application/json") try {
|
|
343
|
+
candidate = JSON.parse(body).secret;
|
|
344
|
+
} catch {
|
|
345
|
+
respondJson(res, 400, { error: "invalid body" });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
respondJson(res, 415, { error: "expected application/x-www-form-urlencoded or application/json" });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (typeof candidate !== "string" || candidate === "") {
|
|
353
|
+
respondJson(res, 400, { error: "missing secret" });
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (!secretMatches(candidate)) {
|
|
357
|
+
recordFailure(ip, Date.now());
|
|
358
|
+
if (json) respondJson(res, 401, { error: "invalid secret" });
|
|
359
|
+
else respondRedirect(res, "/?auth=failed");
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
failures.delete(ip);
|
|
363
|
+
const cookie = setCookieValue(createSession(), req);
|
|
364
|
+
if (json) res.writeHead(204, {
|
|
365
|
+
"set-cookie": cookie,
|
|
366
|
+
"cache-control": "no-store"
|
|
367
|
+
}).end();
|
|
368
|
+
else respondRedirect(res, "/", { "set-cookie": cookie });
|
|
369
|
+
};
|
|
370
|
+
const handleLogout = (req, res) => {
|
|
371
|
+
const json = wantsJson(req);
|
|
372
|
+
if (enabled) {
|
|
373
|
+
if (originVerdict(req) === "foreign") {
|
|
374
|
+
respondJson(res, 403, { error: "origin not allowed" });
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const token = cookieToken(req);
|
|
378
|
+
if (token !== void 0 && token !== "") sessions.delete(tokenKey(token));
|
|
379
|
+
}
|
|
380
|
+
const cookie = clearCookieValue(req);
|
|
381
|
+
if (json) res.writeHead(204, {
|
|
382
|
+
"set-cookie": cookie,
|
|
383
|
+
"cache-control": "no-store"
|
|
384
|
+
}).end();
|
|
385
|
+
else respondRedirect(res, "/", { "set-cookie": cookie });
|
|
386
|
+
};
|
|
387
|
+
const handleAuthRoute = async (pathname, req, res) => {
|
|
388
|
+
if (pathname === "/auth/status") {
|
|
389
|
+
if (req.method !== "GET") respondJson(res, 405, { error: "method not allowed" }, { allow: "GET" });
|
|
390
|
+
else respondJson(res, 200, {
|
|
391
|
+
enabled,
|
|
392
|
+
authenticated: enabled ? hasSession(req) : true
|
|
393
|
+
});
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (pathname === "/auth/login") {
|
|
397
|
+
if (req.method !== "POST") respondJson(res, 405, { error: "method not allowed" }, { allow: "POST" });
|
|
398
|
+
else await handleLogin(req, res);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (pathname === "/auth/logout") {
|
|
402
|
+
if (req.method !== "POST") respondJson(res, 405, { error: "method not allowed" }, { allow: "POST" });
|
|
403
|
+
else handleLogout(req, res);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
respondJson(res, 404, { error: "not found" });
|
|
407
|
+
};
|
|
408
|
+
const handleAuthRequest = (req, res) => {
|
|
409
|
+
let pathname;
|
|
410
|
+
try {
|
|
411
|
+
pathname = new URL(req.url ?? "/", "http://internal").pathname;
|
|
412
|
+
} catch {
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
if (pathname !== "/auth" && !pathname.startsWith("/auth/")) return false;
|
|
416
|
+
return handleAuthRoute(pathname, req, res).then(() => true);
|
|
417
|
+
};
|
|
418
|
+
const loginPage = (req) => {
|
|
419
|
+
let reason = null;
|
|
420
|
+
try {
|
|
421
|
+
reason = new URL(req.url ?? "/", "http://internal").searchParams.get("auth");
|
|
422
|
+
} catch {
|
|
423
|
+
reason = null;
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
action: "/auth/login",
|
|
427
|
+
field: "secret",
|
|
428
|
+
error: reason === "failed" ? "Invalid access key. Try again." : reason === "throttled" ? "Too many failed attempts. Wait a few minutes, then try again." : void 0
|
|
429
|
+
};
|
|
430
|
+
};
|
|
431
|
+
return {
|
|
432
|
+
enabled,
|
|
433
|
+
authenticate,
|
|
434
|
+
handleAuthRequest,
|
|
435
|
+
hasValidSession: (req) => enabled ? hasSession(req) : true,
|
|
436
|
+
loginPage
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
//#endregion
|
|
440
|
+
//#region src/config.ts
|
|
441
|
+
/**
|
|
442
|
+
* The config surface has to be JavaScript, not JSON: the two options a real
|
|
443
|
+
* deployment always needs — `authenticate` and `buildRunnerConfig` — are
|
|
444
|
+
* functions. So the file default-exports `WorkerServerOptions` (optionally as a
|
|
445
|
+
* function, sync or async, for config that has to await something), and flags
|
|
446
|
+
* and env cover the cases that fit on a command line.
|
|
447
|
+
*
|
|
448
|
+
* Precedence, narrowest wins: flags > env > config file > defaults. A config
|
|
449
|
+
* file that sets `authenticate` itself opts out of the built-in shared-secret
|
|
450
|
+
* auth entirely — see `resolveInstanceConfig`.
|
|
451
|
+
*/
|
|
452
|
+
const CONFIG_BASENAMES = [
|
|
453
|
+
"workerdeck.config.mjs",
|
|
454
|
+
"workerdeck.config.js",
|
|
455
|
+
"workerdeck.config.cjs"
|
|
456
|
+
];
|
|
457
|
+
var ConfigError = class extends Error {};
|
|
458
|
+
function parsePort(raw, source) {
|
|
459
|
+
const port = Number(raw);
|
|
460
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new ConfigError(`${source}: not a valid port: ${raw}`);
|
|
461
|
+
return port;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Hand-rolled rather than a dependency: the CLI's whole value is that `npx
|
|
465
|
+
* workerdeck` pulls down a small tree, and an arg parser is a hundred lines
|
|
466
|
+
* of it.
|
|
467
|
+
*/
|
|
468
|
+
function parseArgs(argv) {
|
|
469
|
+
const flags = {
|
|
470
|
+
profiles: [],
|
|
471
|
+
cwdRoots: [],
|
|
472
|
+
allowedOrigins: [],
|
|
473
|
+
allowedHosts: [],
|
|
474
|
+
insecureHosts: []
|
|
475
|
+
};
|
|
476
|
+
const next = (i, name) => {
|
|
477
|
+
const value = argv[i + 1];
|
|
478
|
+
if (value === void 0 || value.startsWith("-")) throw new ConfigError(`${name} requires a value`);
|
|
479
|
+
return value;
|
|
480
|
+
};
|
|
481
|
+
for (let i = 0; i < argv.length; i++) {
|
|
482
|
+
const arg = argv[i];
|
|
483
|
+
switch (arg) {
|
|
484
|
+
case "-h":
|
|
485
|
+
case "--help":
|
|
486
|
+
flags.help = true;
|
|
487
|
+
break;
|
|
488
|
+
case "-v":
|
|
489
|
+
case "--version":
|
|
490
|
+
flags.version = true;
|
|
491
|
+
break;
|
|
492
|
+
case "-c":
|
|
493
|
+
case "--config":
|
|
494
|
+
flags.config = next(i, arg);
|
|
495
|
+
i++;
|
|
496
|
+
break;
|
|
497
|
+
case "-p":
|
|
498
|
+
case "--port":
|
|
499
|
+
flags.port = parsePort(next(i, arg), arg);
|
|
500
|
+
i++;
|
|
501
|
+
break;
|
|
502
|
+
case "--host":
|
|
503
|
+
flags.host = next(i, arg);
|
|
504
|
+
i++;
|
|
505
|
+
break;
|
|
506
|
+
case "--auth-key":
|
|
507
|
+
flags.authKey = next(i, arg);
|
|
508
|
+
i++;
|
|
509
|
+
break;
|
|
510
|
+
case "--profile": {
|
|
511
|
+
const raw = next(i, arg);
|
|
512
|
+
i++;
|
|
513
|
+
const eq = raw.indexOf("=");
|
|
514
|
+
if (eq <= 0) throw new ConfigError(`--profile expects name=dir, got: ${raw}`);
|
|
515
|
+
const name = raw.slice(0, eq);
|
|
516
|
+
const dir = raw.slice(eq + 1);
|
|
517
|
+
if (!dir) throw new ConfigError(`--profile ${name}= is missing a directory`);
|
|
518
|
+
flags.profiles.push({
|
|
519
|
+
name,
|
|
520
|
+
configDir: resolve(dir)
|
|
521
|
+
});
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
case "--cwd-root":
|
|
525
|
+
flags.cwdRoots.push(resolve(next(i, arg)));
|
|
526
|
+
i++;
|
|
527
|
+
break;
|
|
528
|
+
case "--allowed-origin":
|
|
529
|
+
flags.allowedOrigins.push(next(i, arg));
|
|
530
|
+
i++;
|
|
531
|
+
break;
|
|
532
|
+
case "--allowed-host":
|
|
533
|
+
flags.allowedHosts.push(next(i, arg));
|
|
534
|
+
i++;
|
|
535
|
+
break;
|
|
536
|
+
case "--insecure-host":
|
|
537
|
+
flags.insecureHosts.push(next(i, arg));
|
|
538
|
+
i++;
|
|
539
|
+
break;
|
|
540
|
+
case "--trust-proxy":
|
|
541
|
+
flags.trustProxy = true;
|
|
542
|
+
break;
|
|
543
|
+
case "--state-dir":
|
|
544
|
+
flags.stateDir = resolve(next(i, arg));
|
|
545
|
+
i++;
|
|
546
|
+
break;
|
|
547
|
+
case "--no-parking-store":
|
|
548
|
+
flags.parking = false;
|
|
549
|
+
break;
|
|
550
|
+
case "--insecure":
|
|
551
|
+
flags.insecure = true;
|
|
552
|
+
break;
|
|
553
|
+
case "--open":
|
|
554
|
+
flags.open = true;
|
|
555
|
+
break;
|
|
556
|
+
default: throw new ConfigError(`unknown option: ${arg}`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return flags;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Explicit `--config` must exist — a typo that silently starts a default
|
|
563
|
+
* instance is worse than a failure. An implicit one is looked up in cwd only:
|
|
564
|
+
* walking parent directories would make what a given command does depend on
|
|
565
|
+
* where it was run from.
|
|
566
|
+
*/
|
|
567
|
+
async function loadConfigFile(explicit, cwd = process.cwd()) {
|
|
568
|
+
let path = null;
|
|
569
|
+
if (explicit) {
|
|
570
|
+
path = isAbsolute(explicit) ? explicit : resolve(cwd, explicit);
|
|
571
|
+
if (!existsSync(path)) throw new ConfigError(`no config file at ${path}`);
|
|
572
|
+
} else path = CONFIG_BASENAMES.map((name) => join(cwd, name)).find((p) => existsSync(p)) ?? null;
|
|
573
|
+
if (!path) return {
|
|
574
|
+
path: null,
|
|
575
|
+
options: {}
|
|
576
|
+
};
|
|
577
|
+
let mod;
|
|
578
|
+
try {
|
|
579
|
+
mod = await import(pathToFileURL(path).href);
|
|
580
|
+
} catch (error) {
|
|
581
|
+
throw new ConfigError(`failed to load ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
582
|
+
}
|
|
583
|
+
const exported = mod.default;
|
|
584
|
+
if (exported === void 0) throw new ConfigError(`${path} has no default export (expected WorkerServerOptions)`);
|
|
585
|
+
const options = typeof exported === "function" ? await exported() : exported;
|
|
586
|
+
if (typeof options !== "object" || options === null) throw new ConfigError(`${path} default export is not an options object`);
|
|
587
|
+
return {
|
|
588
|
+
path,
|
|
589
|
+
options
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
const LOOPBACK = new Set([
|
|
593
|
+
"127.0.0.1",
|
|
594
|
+
"::1",
|
|
595
|
+
"localhost",
|
|
596
|
+
"::ffff:127.0.0.1"
|
|
597
|
+
]);
|
|
598
|
+
function isLoopback(host) {
|
|
599
|
+
return LOOPBACK.has(host);
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Durable parking is on by default because this is a long-lived instance: a
|
|
603
|
+
* turnkey tool that silently drops parked work on every restart is the wrong
|
|
604
|
+
* default. The store writes whole transcripts in plaintext, so it goes beside
|
|
605
|
+
* the config file (or under the home directory) rather than anywhere temporary,
|
|
606
|
+
* and one directory serves exactly one instance — the store is single-process
|
|
607
|
+
* by design, which the single-port model already implies.
|
|
608
|
+
*/
|
|
609
|
+
function defaultStateDir(configPath) {
|
|
610
|
+
return configPath ? join(dirname(configPath), ".workerdeck") : join(homedir(), ".workerdeck");
|
|
611
|
+
}
|
|
612
|
+
/** Hostname out of a Host header, minus the port and any IPv6 brackets. */
|
|
613
|
+
function hostnameOf(hostHeader) {
|
|
614
|
+
try {
|
|
615
|
+
return new URL(`http://${hostHeader}`).hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
616
|
+
} catch {
|
|
617
|
+
return "";
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
/** 127.0.0.0/8, ::1, and the names that mean them. */
|
|
621
|
+
function isLoopbackHostname(hostname) {
|
|
622
|
+
if (LOOPBACK.has(hostname)) return true;
|
|
623
|
+
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* An `insecureHosts` entry names a host, never an endpoint: it is compared
|
|
627
|
+
* against the bind host and against Host headers, and both are portless by the
|
|
628
|
+
* time they are compared. An entry carrying a port would therefore never match
|
|
629
|
+
* anything — a gate that looks armed and is not — so it is rejected loudly, as
|
|
630
|
+
* is anything that does not parse as a host name or address. Bare IPv6 is
|
|
631
|
+
* bracketed before parsing (WHATWG URL requires that), and the result is
|
|
632
|
+
* lowercased to match `hostnameOf`'s normal form.
|
|
633
|
+
*/
|
|
634
|
+
function normalizeInsecureHost(raw) {
|
|
635
|
+
const entry = raw.trim();
|
|
636
|
+
const looksLikeNamePort = /^[^:]+:\d+$/.test(entry);
|
|
637
|
+
const candidate = entry.includes(":") && !entry.startsWith("[") && !looksLikeNamePort ? `[${entry}]` : entry;
|
|
638
|
+
let url;
|
|
639
|
+
try {
|
|
640
|
+
url = new URL(`http://${candidate}`);
|
|
641
|
+
} catch {
|
|
642
|
+
throw new ConfigError(`not a host name or address in insecureHosts: ${JSON.stringify(raw)}`);
|
|
643
|
+
}
|
|
644
|
+
if (url.port !== "") throw new ConfigError(`insecureHosts entry ${JSON.stringify(raw)} carries a port — name the host alone; the bind host and the Host header are both compared portless`);
|
|
645
|
+
return url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
646
|
+
}
|
|
647
|
+
function resolveInstanceConfig(flags, loaded, env = process.env, cwd = process.cwd()) {
|
|
648
|
+
const envPort = env.WORKERDECK_PORT ? parsePort(env.WORKERDECK_PORT, "WORKERDECK_PORT") : void 0;
|
|
649
|
+
const port = flags.port ?? envPort ?? loaded.options.port ?? 8787;
|
|
650
|
+
const host = flags.host ?? env.WORKERDECK_HOST ?? loaded.options.host ?? "127.0.0.1";
|
|
651
|
+
const authKey = flags.authKey || env.WORKERDECK_AUTH_KEY || loaded.options.auth?.secret || void 0;
|
|
652
|
+
const hostAuthenticates = typeof loaded.options.authenticate === "function";
|
|
653
|
+
const insecureHosts = new Set([...flags.insecureHosts, ...loaded.options.insecureHosts ?? []].map(normalizeInsecureHost));
|
|
654
|
+
/** The bind host in the same normal form the entries were put in. It never
|
|
655
|
+
* carries a port — that is a separate flag — so only brackets and case vary. */
|
|
656
|
+
const bindHost = host.trim().replace(/^\[|\]$/g, "").toLowerCase();
|
|
657
|
+
const generateAuthKey = !authKey && !hostAuthenticates && !loaded.options.allowUnauthenticated && !isLoopback(host) && !flags.insecure && !insecureHosts.has(bindHost);
|
|
658
|
+
const stateDir = flags.parking === false || loaded.options.stateDir === null ? null : flags.stateDir ?? env.WORKERDECK_STATE_DIR ?? loaded.options.stateDir ?? defaultStateDir(loaded.path);
|
|
659
|
+
const envCwdRoots = env.WORKERDECK_CWD_ROOTS?.split(":").filter(Boolean).map((p) => resolve(cwd, p));
|
|
660
|
+
const cwdRoots = flags.cwdRoots.length ? flags.cwdRoots : envCwdRoots;
|
|
661
|
+
const auth = {
|
|
662
|
+
...loaded.options.auth,
|
|
663
|
+
secret: authKey,
|
|
664
|
+
trustProxy: flags.trustProxy ?? loaded.options.auth?.trustProxy,
|
|
665
|
+
allowedOrigins: [...loaded.options.auth?.allowedOrigins ?? [], ...flags.allowedOrigins]
|
|
666
|
+
};
|
|
667
|
+
const allowedHosts = Boolean(authKey) || hostAuthenticates || generateAuthKey ? null : new Set([
|
|
668
|
+
...LOOPBACK,
|
|
669
|
+
...[...flags.allowedHosts, ...loaded.options.allowedHosts ?? []].map((name) => name.toLowerCase()),
|
|
670
|
+
...insecureHosts
|
|
671
|
+
]);
|
|
672
|
+
const { port: _p, host: _h, auth: _a, stateDir: _s, allowedHosts: _ah, insecureHosts: _ih, webRoot: _w, ...serverOptions } = loaded.options;
|
|
673
|
+
const options = { ...serverOptions };
|
|
674
|
+
if (cwdRoots?.length) options.allowedCwdRoots = cwdRoots;
|
|
675
|
+
if (flags.profiles.length) options.profiles = flags.profiles;
|
|
676
|
+
return {
|
|
677
|
+
port,
|
|
678
|
+
host,
|
|
679
|
+
authKey,
|
|
680
|
+
auth,
|
|
681
|
+
stateDir,
|
|
682
|
+
configPath: loaded.path,
|
|
683
|
+
hostAuthenticates,
|
|
684
|
+
generateAuthKey,
|
|
685
|
+
allowedHosts,
|
|
686
|
+
webRoot: loaded.options.webRoot,
|
|
687
|
+
open: flags.open ?? false,
|
|
688
|
+
options
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
//#endregion
|
|
692
|
+
//#region src/login-page.ts
|
|
693
|
+
const escapeHtml = (value) => value.replace(/[&<>"']/g, (c) => ({
|
|
694
|
+
"&": "&",
|
|
695
|
+
"<": "<",
|
|
696
|
+
">": ">",
|
|
697
|
+
"\"": """,
|
|
698
|
+
"'": "'"
|
|
699
|
+
})[c] ?? c);
|
|
700
|
+
function renderLoginPage(options) {
|
|
701
|
+
const { action, field, error, redirectTo, redirectField } = options;
|
|
702
|
+
const hidden = redirectField && redirectTo ? `<input type="hidden" name="${escapeHtml(redirectField)}" value="${escapeHtml(redirectTo)}">` : "";
|
|
703
|
+
const alert = error ? `<p class="error" role="alert">${escapeHtml(error)}</p>` : "";
|
|
704
|
+
return `<!doctype html>
|
|
705
|
+
<html lang="en">
|
|
706
|
+
<head>
|
|
707
|
+
<meta charset="utf-8">
|
|
708
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
709
|
+
<meta name="robots" content="noindex">
|
|
710
|
+
<title>workerdeck</title>
|
|
711
|
+
<style>
|
|
712
|
+
:root {
|
|
713
|
+
color-scheme: light dark;
|
|
714
|
+
--bg: #fbfaf9; --fg: #1c1917; --muted: #78716c;
|
|
715
|
+
--card: #ffffff; --border: #e7e5e4; --accent: #c2410c; --error: #b91c1c;
|
|
716
|
+
}
|
|
717
|
+
@media (prefers-color-scheme: dark) {
|
|
718
|
+
:root {
|
|
719
|
+
--bg: #1c1917; --fg: #fafaf9; --muted: #a8a29e;
|
|
720
|
+
--card: #292524; --border: #44403c; --accent: #fb923c; --error: #fca5a5;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
* { box-sizing: border-box; }
|
|
724
|
+
body {
|
|
725
|
+
margin: 0; min-height: 100dvh; display: grid; place-items: center; padding: 1.5rem;
|
|
726
|
+
background: var(--bg); color: var(--fg);
|
|
727
|
+
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
728
|
+
}
|
|
729
|
+
form {
|
|
730
|
+
width: 100%; max-width: 22rem; background: var(--card);
|
|
731
|
+
border: 1px solid var(--border); border-radius: 12px; padding: 1.75rem;
|
|
732
|
+
}
|
|
733
|
+
h1 { margin: 0 0 .25rem; font-size: 1.05rem; letter-spacing: -0.01em; }
|
|
734
|
+
p.sub { margin: 0 0 1.25rem; color: var(--muted); font-size: .875rem; }
|
|
735
|
+
label { display: block; font-size: .8125rem; font-weight: 500; margin-bottom: .375rem; }
|
|
736
|
+
input[type=password] {
|
|
737
|
+
width: 100%; padding: .5rem .625rem; font: inherit; color: inherit;
|
|
738
|
+
background: var(--bg); border: 1px solid var(--border); border-radius: 7px;
|
|
739
|
+
}
|
|
740
|
+
input[type=password]:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
741
|
+
button {
|
|
742
|
+
width: 100%; margin-top: 1rem; padding: .5rem .75rem; font: inherit; font-weight: 500;
|
|
743
|
+
color: var(--bg); background: var(--fg); border: 0; border-radius: 7px; cursor: pointer;
|
|
744
|
+
}
|
|
745
|
+
button:hover { opacity: .9; }
|
|
746
|
+
.error { margin: 0 0 1rem; color: var(--error); font-size: .8125rem; }
|
|
747
|
+
</style>
|
|
748
|
+
</head>
|
|
749
|
+
<body>
|
|
750
|
+
<form method="post" action="${escapeHtml(action)}">
|
|
751
|
+
<h1>workerdeck</h1>
|
|
752
|
+
<p class="sub">This instance is protected. Enter its access key to continue.</p>
|
|
753
|
+
${alert}
|
|
754
|
+
<label for="key">Access key</label>
|
|
755
|
+
<input id="key" name="${escapeHtml(field)}" type="password" autocomplete="current-password"
|
|
756
|
+
autofocus required spellcheck="false">
|
|
757
|
+
${hidden}
|
|
758
|
+
<button type="submit">Sign in</button>
|
|
759
|
+
</form>
|
|
760
|
+
</body>
|
|
761
|
+
</html>
|
|
762
|
+
`;
|
|
763
|
+
}
|
|
764
|
+
//#endregion
|
|
765
|
+
//#region src/static.ts
|
|
766
|
+
/**
|
|
767
|
+
* Static file serving for the bundled dashboard. Deliberately policy-free: what
|
|
768
|
+
* counts as a document, and whether an unauthenticated visitor gets the app or a
|
|
769
|
+
* login page, is decided by the caller (see `instance.ts`). This module only
|
|
770
|
+
* answers "is there such a file, and what headers does it want".
|
|
771
|
+
*/
|
|
772
|
+
const CONTENT_TYPES = {
|
|
773
|
+
".html": "text/html; charset=utf-8",
|
|
774
|
+
".js": "text/javascript; charset=utf-8",
|
|
775
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
776
|
+
".css": "text/css; charset=utf-8",
|
|
777
|
+
".json": "application/json; charset=utf-8",
|
|
778
|
+
".map": "application/json; charset=utf-8",
|
|
779
|
+
".svg": "image/svg+xml",
|
|
780
|
+
".png": "image/png",
|
|
781
|
+
".jpg": "image/jpeg",
|
|
782
|
+
".jpeg": "image/jpeg",
|
|
783
|
+
".webp": "image/webp",
|
|
784
|
+
".avif": "image/avif",
|
|
785
|
+
".ico": "image/x-icon",
|
|
786
|
+
".woff": "font/woff",
|
|
787
|
+
".woff2": "font/woff2",
|
|
788
|
+
".ttf": "font/ttf",
|
|
789
|
+
".wasm": "application/wasm",
|
|
790
|
+
".txt": "text/plain; charset=utf-8"
|
|
791
|
+
};
|
|
792
|
+
function contentTypeFor(pathname) {
|
|
793
|
+
const dot = pathname.lastIndexOf(".");
|
|
794
|
+
if (dot < 0) return "application/octet-stream";
|
|
795
|
+
return CONTENT_TYPES[pathname.slice(dot).toLowerCase()] ?? "application/octet-stream";
|
|
796
|
+
}
|
|
797
|
+
/** A request for a file rather than an app route: anything with a known extension. */
|
|
798
|
+
function looksLikeAsset(pathname) {
|
|
799
|
+
const dot = pathname.lastIndexOf(".");
|
|
800
|
+
if (dot < 0) return false;
|
|
801
|
+
return pathname.slice(dot).toLowerCase() in CONTENT_TYPES;
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Resolve `pathname` inside `root`, or null if it escapes. Vite emits every
|
|
805
|
+
* asset under a content-hashed name, so the only paths that ever reach here are
|
|
806
|
+
* ones the app itself generated — but this server is reachable by anything that
|
|
807
|
+
* can open a socket, and `..` in a URL is the oldest trick there is.
|
|
808
|
+
*/
|
|
809
|
+
function resolveWithinRoot(root, pathname) {
|
|
810
|
+
let decoded;
|
|
811
|
+
try {
|
|
812
|
+
decoded = decodeURIComponent(pathname);
|
|
813
|
+
} catch {
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
if (decoded.includes("\0")) return null;
|
|
817
|
+
const candidate = resolve(join(root, decoded));
|
|
818
|
+
const base = resolve(root);
|
|
819
|
+
if (candidate !== base && !candidate.startsWith(base + sep)) return null;
|
|
820
|
+
return candidate;
|
|
821
|
+
}
|
|
822
|
+
function sendHtml(req, res, status, html, cache) {
|
|
823
|
+
const body = Buffer.from(html, "utf8");
|
|
824
|
+
res.writeHead(status, {
|
|
825
|
+
"content-type": "text/html; charset=utf-8",
|
|
826
|
+
"content-length": body.byteLength,
|
|
827
|
+
"cache-control": cache,
|
|
828
|
+
"x-frame-options": "DENY",
|
|
829
|
+
"x-content-type-options": "nosniff",
|
|
830
|
+
"referrer-policy": "same-origin"
|
|
831
|
+
});
|
|
832
|
+
res.end(req.method === "HEAD" ? void 0 : body);
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Stream a file out of `root`. `immutable` is the caller's call, because it is a
|
|
836
|
+
* promise about the URL, not the file: Vite's hashed assets can be cached
|
|
837
|
+
* forever, but index.html must be revalidated every time or a deployed update
|
|
838
|
+
* never reaches a browser that already has the old one.
|
|
839
|
+
*/
|
|
840
|
+
async function serveFile(req, res, filePath, options = {}) {
|
|
841
|
+
if (req.method !== "GET" && req.method !== "HEAD") return "method-not-allowed";
|
|
842
|
+
let size;
|
|
843
|
+
try {
|
|
844
|
+
const info = await stat(filePath);
|
|
845
|
+
if (!info.isFile()) return "not-found";
|
|
846
|
+
size = info.size;
|
|
847
|
+
} catch {
|
|
848
|
+
return "not-found";
|
|
849
|
+
}
|
|
850
|
+
res.writeHead(200, {
|
|
851
|
+
"content-type": contentTypeFor(filePath),
|
|
852
|
+
"content-length": size,
|
|
853
|
+
"cache-control": options.immutable ? "public, max-age=31536000, immutable" : "no-cache, must-revalidate",
|
|
854
|
+
"x-content-type-options": "nosniff"
|
|
855
|
+
});
|
|
856
|
+
if (req.method === "HEAD") {
|
|
857
|
+
res.end();
|
|
858
|
+
return "served";
|
|
859
|
+
}
|
|
860
|
+
await new Promise((resolvePromise) => {
|
|
861
|
+
const stream = createReadStream(filePath);
|
|
862
|
+
stream.on("error", () => {
|
|
863
|
+
res.destroy();
|
|
864
|
+
resolvePromise();
|
|
865
|
+
});
|
|
866
|
+
stream.on("end", () => resolvePromise());
|
|
867
|
+
stream.pipe(res);
|
|
868
|
+
});
|
|
869
|
+
return "served";
|
|
870
|
+
}
|
|
871
|
+
//#endregion
|
|
872
|
+
//#region src/instance.ts
|
|
873
|
+
/**
|
|
874
|
+
* The dashboard comes from `@workerdeck/web`, which ships it prebuilt and
|
|
875
|
+
* exports the path to it. Depending on the package rather than vendoring a copy
|
|
876
|
+
* means one dashboard, versioned in lockstep with everything else.
|
|
877
|
+
*
|
|
878
|
+
* In a checkout that directory only exists once the app has been built — dev
|
|
879
|
+
* never builds — so the miss is worth a real message rather than a stack trace
|
|
880
|
+
* from the static host.
|
|
881
|
+
*/
|
|
882
|
+
function resolveWebRoot() {
|
|
883
|
+
if (existsSync(join(dashboardDir, "index.html"))) return dashboardDir;
|
|
884
|
+
throw new Error(`no dashboard build at ${dashboardDir}\n in a checkout: pnpm --filter @workerdeck/web run build`);
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* The Host-header gate for an unauthenticated instance. `allowedHosts` is null
|
|
888
|
+
* whenever auth is on, and then this is the identity function — with a
|
|
889
|
+
* credential in play a rebound origin holds no cookie and fails `authenticate`
|
|
890
|
+
* anyway. Loopback *names* are what's checked, not the socket: the attacker in
|
|
891
|
+
* this scenario controls DNS, so the connection genuinely arrives on 127.0.0.1;
|
|
892
|
+
* what they cannot control is the name the victim's browser writes into Host.
|
|
893
|
+
*/
|
|
894
|
+
function createHostGuard(allowedHosts) {
|
|
895
|
+
if (allowedHosts === null) return () => true;
|
|
896
|
+
return (req) => {
|
|
897
|
+
const header = req.headers.host;
|
|
898
|
+
if (header === void 0) return true;
|
|
899
|
+
const hostname = hostnameOf(header);
|
|
900
|
+
if (hostname === "") return false;
|
|
901
|
+
return isLoopbackHostname(hostname) || allowedHosts.has(hostname);
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Everything outside `/v1`. Order matters: the auth endpoints first (they are
|
|
906
|
+
* how a browser gets a session in the first place), then assets, which stay
|
|
907
|
+
* ungated — they are the app's own code, hold no secrets, and gating them would
|
|
908
|
+
* only mean the login page could not be styled by the app it gates. Documents
|
|
909
|
+
* come last, and that is the single place the auth decision is made.
|
|
910
|
+
*/
|
|
911
|
+
function createFallback(auth, webRoot, hostAllowed) {
|
|
912
|
+
return async (req, res) => {
|
|
913
|
+
if (!hostAllowed(req)) {
|
|
914
|
+
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
|
|
915
|
+
res.end("unrecognised Host header.\n\nThis instance runs without auth, so it only answers to loopback host names and the ones it was told to expect. Declare this name with --insecure-host <name> (which also lets it be bound without a key) or --allowed-host <name>, or set --auth-key.\n");
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (await auth.handleAuthRequest(req, res)) return;
|
|
919
|
+
const pathname = new URL(req.url ?? "/", "http://internal").pathname;
|
|
920
|
+
if (looksLikeAsset(pathname)) {
|
|
921
|
+
const filePath = resolveWithinRoot(webRoot, pathname);
|
|
922
|
+
if (!filePath) {
|
|
923
|
+
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
|
|
924
|
+
res.end("bad request");
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
const result = await serveFile(req, res, filePath, { immutable: pathname.startsWith("/assets/") });
|
|
928
|
+
if (result === "served") return;
|
|
929
|
+
if (result === "method-not-allowed") {
|
|
930
|
+
res.writeHead(405, { allow: "GET, HEAD" });
|
|
931
|
+
res.end();
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
935
|
+
res.end("not found");
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
939
|
+
res.writeHead(405, { allow: "GET, HEAD" });
|
|
940
|
+
res.end();
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (auth.enabled && !auth.hasValidSession(req)) {
|
|
944
|
+
sendHtml(req, res, 401, renderLoginPage(auth.loginPage(req)), "no-store");
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
if (await serveFile(req, res, join(webRoot, "index.html"), { immutable: false }) !== "served") {
|
|
948
|
+
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
|
949
|
+
res.end("dashboard build is missing its entry document");
|
|
950
|
+
}
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
async function startInstance(config, options = {}) {
|
|
954
|
+
const webRoot = config.webRoot ?? resolveWebRoot();
|
|
955
|
+
const generated = config.generateAuthKey && !config.hostAuthenticates ? await materializeAuthKey(config.stateDir) : null;
|
|
956
|
+
const auth = createCliAuth(config.hostAuthenticates ? {
|
|
957
|
+
...config.auth,
|
|
958
|
+
secret: void 0
|
|
959
|
+
} : generated ? {
|
|
960
|
+
...config.auth,
|
|
961
|
+
secret: generated.key
|
|
962
|
+
} : config.auth);
|
|
963
|
+
if (config.allowedHosts === null && !config.hostAuthenticates && !auth.enabled) throw new Error("refusing to serve: the resolved config expects auth but no shared secret was materialized — this instance would be open while believing itself authenticated");
|
|
964
|
+
const hostAllowed = createHostGuard(config.allowedHosts);
|
|
965
|
+
const fallback = createFallback(auth, webRoot, hostAllowed);
|
|
966
|
+
const parking = { ...config.options.parking };
|
|
967
|
+
if (config.stateDir && !parking.store) parking.store = createFileSessionStore({
|
|
968
|
+
dir: join(config.stateDir, "parked"),
|
|
969
|
+
onError: (error, context) => {
|
|
970
|
+
process.stderr.write(`[workerdeck] parked-session store ${context.op} failed for ${context.path}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
const server = createWorkerServer({
|
|
974
|
+
...config.options,
|
|
975
|
+
checkCredentials: config.options.checkCredentials ?? true,
|
|
976
|
+
parking,
|
|
977
|
+
fallback,
|
|
978
|
+
authenticate: config.hostAuthenticates ? (req) => hostAllowed(req) ? config.options.authenticate(req) : null : (req) => hostAllowed(req) ? auth.authenticate(req) : null
|
|
979
|
+
});
|
|
980
|
+
const { port } = await server.listen(config.port, config.host);
|
|
981
|
+
const displayHost = config.host === "0.0.0.0" || config.host === "::" ? "localhost" : config.host;
|
|
982
|
+
const url = `http://${displayHost.includes(":") ? `[${displayHost}]` : displayHost}:${port}`;
|
|
983
|
+
let resolveClosed = () => {};
|
|
984
|
+
const closed = new Promise((r) => {
|
|
985
|
+
resolveClosed = r;
|
|
986
|
+
});
|
|
987
|
+
if (!options.quiet) {
|
|
988
|
+
const line = (text) => void process.stdout.write(`${text}\n`);
|
|
989
|
+
line("");
|
|
990
|
+
line(` workerdeck ${url}`);
|
|
991
|
+
if (config.hostAuthenticates) line(" auth: the config file supplies its own `authenticate`");
|
|
992
|
+
else if (generated?.source === "created") {
|
|
993
|
+
line(` auth: generated key ${generated.key}`);
|
|
994
|
+
line(` stored in ${generated.path} — later starts reuse it without printing it`);
|
|
995
|
+
} else if (generated?.source === "ephemeral") {
|
|
996
|
+
line(` auth: generated key ${generated.key}`);
|
|
997
|
+
line(" ephemeral — no state dir to keep it, so the next start mints a new one");
|
|
998
|
+
} else if (generated?.source === "stored") line(` auth: shared key from ${generated.path}`);
|
|
999
|
+
else if (auth.enabled) line(" auth: shared key — browsers sign in, services send a header");
|
|
1000
|
+
else line(" NO AUTH — anyone who can reach this port gets a session");
|
|
1001
|
+
line(config.stateDir ? ` parked sessions persist in ${join(config.stateDir, "parked")}` : " parked sessions are in memory only — a restart drops them");
|
|
1002
|
+
if (config.configPath) line(` config ${config.configPath}`);
|
|
1003
|
+
line("");
|
|
1004
|
+
}
|
|
1005
|
+
return {
|
|
1006
|
+
server,
|
|
1007
|
+
url,
|
|
1008
|
+
port,
|
|
1009
|
+
closed,
|
|
1010
|
+
close: async () => {
|
|
1011
|
+
await server.close();
|
|
1012
|
+
resolveClosed();
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
//#endregion
|
|
1017
|
+
export { ConfigError as a, isLoopback as c, parseArgs as d, resolveInstanceConfig as f, renderLoginPage as i, isLoopbackHostname as l, materializeAuthKey as m, resolveWebRoot as n, defaultStateDir as o, createCliAuth as p, startInstance as r, hostnameOf as s, createHostGuard as t, loadConfigFile as u };
|
|
1018
|
+
|
|
1019
|
+
//# sourceMappingURL=instance-kupxU5UD.mjs.map
|