@scp-dev/dashboard 0.0.0 → 0.1.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/README.md +57 -0
- package/dist/public/app.css +187 -0
- package/dist/public/app.js +80 -0
- package/dist/public/graph.css +84 -0
- package/dist/public/graph.js +735 -0
- package/dist/public/index.html +1 -0
- package/dist/public/selfhost.js +1307 -0
- package/dist/server.js +458 -0
- package/package.json +18 -3
package/dist/server.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// src/server.ts
|
|
3
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { createServer, request as httpRequest, ServerResponse } from "node:http";
|
|
7
|
+
import { request as httpsRequest } from "node:https";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
var lifetime = 2592000;
|
|
10
|
+
var hash = (value) => createHash("sha256").update(value).digest("base64url");
|
|
11
|
+
var random = () => randomBytes(32).toString("base64url");
|
|
12
|
+
var csp = "default-src 'none'; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; script-src 'self'; connect-src 'self'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
|
|
13
|
+
var assets = {
|
|
14
|
+
"app.css": "text/css",
|
|
15
|
+
"graph.css": "text/css",
|
|
16
|
+
"app.js": "text/javascript",
|
|
17
|
+
"graph.js": "text/javascript",
|
|
18
|
+
"selfhost.js": "text/javascript"
|
|
19
|
+
};
|
|
20
|
+
function identity(value) {
|
|
21
|
+
return {
|
|
22
|
+
org: { handle: value.org.handle, name: value.org.name },
|
|
23
|
+
member: { id: value.member.id, name: value.member.name, role: value.member.role },
|
|
24
|
+
members: value.members.map(({ id, name }) => ({ id, name })),
|
|
25
|
+
devices: value.devices,
|
|
26
|
+
tokens: value.tokens,
|
|
27
|
+
scoped: value.scoped
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function cookies(req) {
|
|
31
|
+
return Object.fromEntries((req.headers.cookie ?? "").split(";").map((part) => {
|
|
32
|
+
const at = part.indexOf("=");
|
|
33
|
+
return [part.slice(0, at).trim(), part.slice(at + 1).trim()];
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
function equal(a, b) {
|
|
37
|
+
return typeof a === "string" && Buffer.byteLength(a) === Buffer.byteLength(b) && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
38
|
+
}
|
|
39
|
+
function parseListen(value = "127.0.0.1:8788") {
|
|
40
|
+
const match = /^(\[[^\]]+\]|[^:]+):(\d+)$/.exec(value);
|
|
41
|
+
if (!match || Number(match[2]) > 65535)
|
|
42
|
+
throw new Error("Expected listen host:port");
|
|
43
|
+
return { host: match[1].replace(/^\[|\]$/g, ""), port: Number(match[2]) };
|
|
44
|
+
}
|
|
45
|
+
function createDashboard(options) {
|
|
46
|
+
const relay = new URL(options.relay);
|
|
47
|
+
if (!["http:", "https:"].includes(relay.protocol) || relay.username || relay.password || relay.search || relay.hash || relay.pathname !== "/") {
|
|
48
|
+
throw new Error("Expected a relay HTTP(S) origin");
|
|
49
|
+
}
|
|
50
|
+
const publicOrigin = options.publicUrl ? new URL(options.publicUrl).origin : undefined;
|
|
51
|
+
const secure = publicOrigin?.startsWith("https:") ?? false;
|
|
52
|
+
const prefix = secure ? "__Host-" : "";
|
|
53
|
+
const sessionCookie = `${prefix}scp_session`;
|
|
54
|
+
const preCookie = `${prefix}scp_pre`;
|
|
55
|
+
const forward = [...new Set((options.forwardHeaders ?? []).map((name) => name.toLowerCase()))];
|
|
56
|
+
for (const name of forward) {
|
|
57
|
+
if (!/^[a-z0-9-]+$/.test(name) || /^(authorization|cookie|host|connection|upgrade|content-length|transfer-encoding|proxy-.*|sec-websocket-.*)$/.test(name)) {
|
|
58
|
+
throw new Error("Forward headers must be identity headers");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const sso = forward.length > 0;
|
|
62
|
+
const sessions = new Map;
|
|
63
|
+
const devices = new Map;
|
|
64
|
+
const secret = randomBytes(32);
|
|
65
|
+
const csrf = (binding) => createHmac("sha256", secret).update(`csrf:${binding}`).digest("base64url");
|
|
66
|
+
const upstreamHeaders = (req, session) => {
|
|
67
|
+
const headers = {};
|
|
68
|
+
if (sso) {
|
|
69
|
+
for (const name of forward) {
|
|
70
|
+
const value = req.headers[name];
|
|
71
|
+
if (typeof value === "string")
|
|
72
|
+
headers[name] = value;
|
|
73
|
+
}
|
|
74
|
+
} else if (session?.token)
|
|
75
|
+
headers.authorization = `Bearer ${session.token}`;
|
|
76
|
+
return headers;
|
|
77
|
+
};
|
|
78
|
+
const call = (req, path, session, body) => fetch(new URL(path, relay), {
|
|
79
|
+
method: body ? "POST" : "GET",
|
|
80
|
+
headers: { ...upstreamHeaders(req, session), ...body ? { "content-type": "application/json" } : {} },
|
|
81
|
+
...body ? { body: JSON.stringify(body) } : {},
|
|
82
|
+
redirect: "error",
|
|
83
|
+
signal: AbortSignal.timeout(15000)
|
|
84
|
+
});
|
|
85
|
+
const setCookie = (res, name, value, age = lifetime) => {
|
|
86
|
+
const existing = res.getHeader("set-cookie");
|
|
87
|
+
const list = Array.isArray(existing) ? existing.map(String) : existing ? [String(existing)] : [];
|
|
88
|
+
res.setHeader("set-cookie", [...list, `${name}=${value}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${age}${secure ? "; Secure" : ""}`]);
|
|
89
|
+
};
|
|
90
|
+
const endSession = (res, key) => {
|
|
91
|
+
sessions.delete(key);
|
|
92
|
+
setCookie(res, sessionCookie, "", 0);
|
|
93
|
+
};
|
|
94
|
+
const json = (res, status, body) => {
|
|
95
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
96
|
+
res.end(JSON.stringify(body));
|
|
97
|
+
};
|
|
98
|
+
const error = (res, status, code) => json(res, status, { error: code, message: code.replaceAll("_", " ") });
|
|
99
|
+
const originOK = (req) => {
|
|
100
|
+
const origin = req.headers.origin;
|
|
101
|
+
if (!origin)
|
|
102
|
+
return req.headers["sec-fetch-site"] === "same-origin";
|
|
103
|
+
if (origin === "null")
|
|
104
|
+
return false;
|
|
105
|
+
return origin === (publicOrigin ?? `${secure ? "https" : "http"}://${req.headers.host}`);
|
|
106
|
+
};
|
|
107
|
+
const context = async (req, res) => {
|
|
108
|
+
const jar = cookies(req);
|
|
109
|
+
const key = hash(jar[sessionCookie] ?? "");
|
|
110
|
+
let session = sessions.get(key);
|
|
111
|
+
if (session && session.expires <= Date.now()) {
|
|
112
|
+
endSession(res, key);
|
|
113
|
+
session = undefined;
|
|
114
|
+
}
|
|
115
|
+
let info;
|
|
116
|
+
if (session || sso) {
|
|
117
|
+
const response = await call(req, "/v1/dashboard/session", session);
|
|
118
|
+
if (response.status === 401) {
|
|
119
|
+
if (session)
|
|
120
|
+
endSession(res, key);
|
|
121
|
+
session = undefined;
|
|
122
|
+
} else if (!response.ok)
|
|
123
|
+
throw new Error("Relay unavailable");
|
|
124
|
+
else
|
|
125
|
+
info = identity(await response.json());
|
|
126
|
+
}
|
|
127
|
+
if (sso && info && !session) {
|
|
128
|
+
const value = random();
|
|
129
|
+
session = { expires: Date.now() + lifetime * 1000 };
|
|
130
|
+
const newKey = hash(value);
|
|
131
|
+
sessions.set(newKey, session);
|
|
132
|
+
setCookie(res, sessionCookie, value);
|
|
133
|
+
return { key: newKey, session, identity: info, binding: `s:${newKey}` };
|
|
134
|
+
}
|
|
135
|
+
let pre = jar[preCookie];
|
|
136
|
+
if (!session && !/^[A-Za-z0-9_-]{43}$/.test(pre ?? "")) {
|
|
137
|
+
pre = random();
|
|
138
|
+
setCookie(res, preCookie, pre);
|
|
139
|
+
}
|
|
140
|
+
return { key, session, identity: info, binding: session ? `s:${key}` : `p:${pre}` };
|
|
141
|
+
};
|
|
142
|
+
const signIn = async (req, res, ctx, token, device = false) => {
|
|
143
|
+
if (sso || typeof token !== "string" || !/^scp_[td]_[A-Za-z0-9_-]+$/.test(token))
|
|
144
|
+
return error(res, 401, "invalid_token");
|
|
145
|
+
const session = { token, expires: Date.now() + lifetime * 1000 };
|
|
146
|
+
const response = await call(req, "/v1/dashboard/session", session);
|
|
147
|
+
if (response.status === 401)
|
|
148
|
+
return error(res, 401, "invalid_token");
|
|
149
|
+
if (!response.ok)
|
|
150
|
+
return error(res, 502, "relay_unavailable");
|
|
151
|
+
identity(await response.json());
|
|
152
|
+
sessions.delete(ctx.key);
|
|
153
|
+
const value = random();
|
|
154
|
+
sessions.set(hash(value), session);
|
|
155
|
+
setCookie(res, sessionCookie, value);
|
|
156
|
+
setCookie(res, preCookie, "", 0);
|
|
157
|
+
json(res, 200, device ? { status: "ok" } : { ok: true });
|
|
158
|
+
};
|
|
159
|
+
const server = createServer(async (req, res) => {
|
|
160
|
+
try {
|
|
161
|
+
const url = new URL(req.url ?? "/", "http://dashboard.invalid");
|
|
162
|
+
const path = url.pathname;
|
|
163
|
+
const get = req.method === "GET" || req.method === "HEAD";
|
|
164
|
+
if (get && path === "/health")
|
|
165
|
+
return json(res, 200, { ok: true });
|
|
166
|
+
const asset = Object.hasOwn(assets, path.slice(1)) ? assets[path.slice(1)] : undefined;
|
|
167
|
+
const shell = ["/", "/sign-in", "/device", "/orgs"].includes(path) || path.startsWith("/orgs/") && !/^\/orgs\/[^/]+\/events$/.test(path);
|
|
168
|
+
if (get && (asset || shell)) {
|
|
169
|
+
const data = await readFile(new URL(asset ? path.slice(1) : "index.html", options.publicDir ?? new URL("./public/", import.meta.url)));
|
|
170
|
+
res.writeHead(200, {
|
|
171
|
+
"content-type": `${asset ?? "text/html"}; charset=utf-8`,
|
|
172
|
+
"cache-control": asset ? "public, max-age=3600" : "no-store",
|
|
173
|
+
"x-content-type-options": "nosniff",
|
|
174
|
+
...!asset ? { "referrer-policy": "same-origin", "x-frame-options": "DENY", "content-security-policy": csp } : {}
|
|
175
|
+
});
|
|
176
|
+
return res.end(req.method === "HEAD" ? undefined : data);
|
|
177
|
+
}
|
|
178
|
+
const posts = ["sign-in", "device", "device/poll", "sign-out"];
|
|
179
|
+
if (!path.startsWith("/v1/dashboard/") || !(get || req.method === "POST" && posts.includes(path.slice(14))))
|
|
180
|
+
return error(res, 404, "not_found");
|
|
181
|
+
if (req.method === "POST") {
|
|
182
|
+
if (!originOK(req))
|
|
183
|
+
return error(res, 403, "bad_origin");
|
|
184
|
+
if (req.headers["content-type"]?.split(";")[0]?.trim().toLowerCase() !== "application/json")
|
|
185
|
+
return error(res, 415, "bad_content_type");
|
|
186
|
+
}
|
|
187
|
+
const ctx = await context(req, res);
|
|
188
|
+
if (get && path === "/v1/dashboard/session")
|
|
189
|
+
return json(res, 200, { ...ctx.identity, signedIn: !!ctx.identity, csrf: csrf(ctx.binding) });
|
|
190
|
+
if (req.method === "POST") {
|
|
191
|
+
const chunks = [];
|
|
192
|
+
let size = 0;
|
|
193
|
+
for await (const chunk of req) {
|
|
194
|
+
size += chunk.length;
|
|
195
|
+
if (size > 16384)
|
|
196
|
+
return error(res, 413, "body_too_large");
|
|
197
|
+
chunks.push(Buffer.from(chunk));
|
|
198
|
+
}
|
|
199
|
+
let body;
|
|
200
|
+
try {
|
|
201
|
+
body = JSON.parse(Buffer.concat(chunks).toString());
|
|
202
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
203
|
+
throw new Error;
|
|
204
|
+
} catch {
|
|
205
|
+
return error(res, 400, "bad_json");
|
|
206
|
+
}
|
|
207
|
+
if (!equal(body.csrf, csrf(ctx.binding)))
|
|
208
|
+
return error(res, 403, "bad_csrf");
|
|
209
|
+
if (path.endsWith("/sign-in"))
|
|
210
|
+
return await signIn(req, res, ctx, body.token);
|
|
211
|
+
if (path.endsWith("/sign-out")) {
|
|
212
|
+
endSession(res, ctx.key);
|
|
213
|
+
return json(res, 200, { ok: true });
|
|
214
|
+
}
|
|
215
|
+
if (sso)
|
|
216
|
+
return error(res, 403, "sso_required");
|
|
217
|
+
if (path.endsWith("/device")) {
|
|
218
|
+
const response = await call(req, "/v1/device/code", undefined, { label: "dashboard" });
|
|
219
|
+
if (!response.ok)
|
|
220
|
+
return error(res, 502, "relay_unavailable");
|
|
221
|
+
const data = await response.json();
|
|
222
|
+
devices.set(hash(data.device_code), { binding: ctx.binding, expires: Date.now() + data.expires_in * 1000 });
|
|
223
|
+
return json(res, 200, { user_code: data.user_code, device_code: data.device_code, interval: data.interval, expires_in: data.expires_in });
|
|
224
|
+
}
|
|
225
|
+
if (path.endsWith("/device/poll")) {
|
|
226
|
+
if (typeof body.device_code !== "string")
|
|
227
|
+
return error(res, 400, "bad_device_code");
|
|
228
|
+
const key = hash(body.device_code);
|
|
229
|
+
const device = devices.get(key);
|
|
230
|
+
if (!device || device.binding !== ctx.binding || device.expires <= Date.now())
|
|
231
|
+
return json(res, 200, { status: "expired" });
|
|
232
|
+
const response = await call(req, "/v1/device/token", undefined, { device_code: body.device_code });
|
|
233
|
+
const data = await response.json();
|
|
234
|
+
if (response.ok && data.access_token) {
|
|
235
|
+
devices.delete(key);
|
|
236
|
+
await signIn(req, res, ctx, data.access_token, true);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (["authorization_pending", "slow_down"].includes(data.error ?? ""))
|
|
240
|
+
return json(res, 200, { status: "pending" });
|
|
241
|
+
if (["access_denied", "expired_token", "invalid_grant"].includes(data.error ?? "")) {
|
|
242
|
+
devices.delete(key);
|
|
243
|
+
return json(res, 200, { status: data.error === "access_denied" ? "denied" : "expired" });
|
|
244
|
+
}
|
|
245
|
+
return error(res, 502, "relay_unavailable");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (get && ["/v1/dashboard/overview", "/v1/dashboard/graph"].includes(path)) {
|
|
249
|
+
if (!ctx.identity)
|
|
250
|
+
return json(res, 401, { error: "signed_out" });
|
|
251
|
+
const response = await call(req, path + (path.endsWith("/graph") ? url.search : ""), ctx.session);
|
|
252
|
+
if (response.status === 401) {
|
|
253
|
+
endSession(res, ctx.key);
|
|
254
|
+
return json(res, 401, { error: "signed_out" });
|
|
255
|
+
}
|
|
256
|
+
if (!response.ok)
|
|
257
|
+
return error(res, 502, "relay_unavailable");
|
|
258
|
+
return json(res, 200, await response.json());
|
|
259
|
+
}
|
|
260
|
+
error(res, 404, "not_found");
|
|
261
|
+
} catch {
|
|
262
|
+
if (!res.headersSent)
|
|
263
|
+
error(res, 502, "relay_unavailable");
|
|
264
|
+
else
|
|
265
|
+
res.end();
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
const sockets = new Set;
|
|
269
|
+
server.on("upgrade", async (req, socket, head) => {
|
|
270
|
+
const refuse = (status) => {
|
|
271
|
+
socket.end(`HTTP/1.1 ${status} Rejected\r
|
|
272
|
+
Content-Length: 0\r
|
|
273
|
+
Connection: close\r
|
|
274
|
+
\r
|
|
275
|
+
`);
|
|
276
|
+
};
|
|
277
|
+
try {
|
|
278
|
+
const match = /^\/orgs\/([^/]+)\/events$/.exec(new URL(req.url ?? "/", "http://dashboard.invalid").pathname);
|
|
279
|
+
if (!match)
|
|
280
|
+
return refuse(404);
|
|
281
|
+
if (!originOK(req))
|
|
282
|
+
return refuse(403);
|
|
283
|
+
if (!sessions.has(hash(cookies(req)[sessionCookie] ?? "")))
|
|
284
|
+
return refuse(401);
|
|
285
|
+
const res = new ServerResponse(req);
|
|
286
|
+
const ctx = await context(req, res);
|
|
287
|
+
if (!ctx.identity || !ctx.session)
|
|
288
|
+
return refuse(401);
|
|
289
|
+
if (match[1] !== ctx.identity.org.handle)
|
|
290
|
+
return refuse(404);
|
|
291
|
+
const headers = upstreamHeaders(req, ctx.session);
|
|
292
|
+
for (const name of ["sec-websocket-key", "sec-websocket-version", "sec-websocket-protocol"]) {
|
|
293
|
+
const value = req.headers[name];
|
|
294
|
+
if (typeof value === "string")
|
|
295
|
+
headers[name] = value;
|
|
296
|
+
}
|
|
297
|
+
headers.connection = "Upgrade";
|
|
298
|
+
headers.upgrade = "websocket";
|
|
299
|
+
const upstream = (relay.protocol === "https:" ? httpsRequest : httpRequest)(new URL("/v1/dashboard/events", relay), { headers });
|
|
300
|
+
upstream.setTimeout(15000, () => upstream.destroy());
|
|
301
|
+
socket.on("close", () => upstream.destroy());
|
|
302
|
+
upstream.on("error", () => {
|
|
303
|
+
if (!socket.destroyed)
|
|
304
|
+
refuse(502);
|
|
305
|
+
});
|
|
306
|
+
upstream.on("response", (response) => {
|
|
307
|
+
response.resume();
|
|
308
|
+
if (response.statusCode === 401)
|
|
309
|
+
sessions.delete(ctx.key);
|
|
310
|
+
refuse(response.statusCode === 401 ? 401 : 502);
|
|
311
|
+
});
|
|
312
|
+
upstream.on("upgrade", (response, remote, remoteHead) => {
|
|
313
|
+
upstream.setTimeout(0);
|
|
314
|
+
let reply = `HTTP/1.1 101 Switching Protocols\r
|
|
315
|
+
Upgrade: websocket\r
|
|
316
|
+
Connection: Upgrade\r
|
|
317
|
+
`;
|
|
318
|
+
for (const name of ["sec-websocket-accept", "sec-websocket-protocol"]) {
|
|
319
|
+
if (response.headers[name])
|
|
320
|
+
reply += `${name}: ${response.headers[name]}\r
|
|
321
|
+
`;
|
|
322
|
+
}
|
|
323
|
+
socket.write(`${reply}\r
|
|
324
|
+
`);
|
|
325
|
+
if (remoteHead.length)
|
|
326
|
+
socket.write(remoteHead);
|
|
327
|
+
if (head.length)
|
|
328
|
+
remote.write(head);
|
|
329
|
+
sockets.add(socket);
|
|
330
|
+
sockets.add(remote);
|
|
331
|
+
socket.pipe(remote).pipe(socket);
|
|
332
|
+
const close = () => {
|
|
333
|
+
socket.destroy();
|
|
334
|
+
remote.destroy();
|
|
335
|
+
};
|
|
336
|
+
const timer = setInterval(async () => {
|
|
337
|
+
try {
|
|
338
|
+
const current = sessions.get(ctx.key);
|
|
339
|
+
const response = current && current.expires > Date.now() ? await call(req, "/v1/dashboard/session", current) : undefined;
|
|
340
|
+
if (response?.ok) {
|
|
341
|
+
await response.body?.cancel();
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
sessions.delete(ctx.key);
|
|
345
|
+
socket.end(Buffer.from([136, 2, 15, 163]));
|
|
346
|
+
remote.destroy();
|
|
347
|
+
} catch {
|
|
348
|
+
close();
|
|
349
|
+
}
|
|
350
|
+
}, options.revalidateMs ?? 30000);
|
|
351
|
+
timer.unref();
|
|
352
|
+
for (const peer of [socket, remote]) {
|
|
353
|
+
peer.on("error", close);
|
|
354
|
+
peer.on("close", () => {
|
|
355
|
+
clearInterval(timer);
|
|
356
|
+
sockets.delete(peer);
|
|
357
|
+
close();
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
upstream.end();
|
|
362
|
+
} catch {
|
|
363
|
+
refuse(502);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
const cleanup = setInterval(() => {
|
|
367
|
+
for (const [key, session] of sessions)
|
|
368
|
+
if (session.expires <= Date.now())
|
|
369
|
+
sessions.delete(key);
|
|
370
|
+
for (const [key, device] of devices)
|
|
371
|
+
if (device.expires <= Date.now())
|
|
372
|
+
devices.delete(key);
|
|
373
|
+
}, 60000);
|
|
374
|
+
cleanup.unref();
|
|
375
|
+
server.on("close", () => clearInterval(cleanup));
|
|
376
|
+
return {
|
|
377
|
+
server,
|
|
378
|
+
close: () => {
|
|
379
|
+
for (const socket of sockets)
|
|
380
|
+
socket.destroy();
|
|
381
|
+
return new Promise((resolve, reject) => {
|
|
382
|
+
server.close((err) => err ? reject(err) : resolve());
|
|
383
|
+
server.closeAllConnections?.();
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
async function main(args = process.argv.slice(2)) {
|
|
389
|
+
if (args.includes("--help")) {
|
|
390
|
+
console.log(`Usage: scp-dashboard --relay <url> [--listen 127.0.0.1:8788] [--public-url <url>] [--forward-header <name>]...
|
|
391
|
+
Environment: SCP_DASHBOARD_RELAY, SCP_DASHBOARD_LISTEN, SCP_DASHBOARD_PUBLIC_URL, SCP_DASHBOARD_FORWARD_HEADERS
|
|
392
|
+
Flags: --help, --version, --health`);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (args.includes("--version")) {
|
|
396
|
+
console.log("0.1.0");
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const options = {
|
|
400
|
+
relay: process.env.SCP_DASHBOARD_RELAY ?? "",
|
|
401
|
+
listen: process.env.SCP_DASHBOARD_LISTEN ?? "127.0.0.1:8788",
|
|
402
|
+
forwardHeaders: (process.env.SCP_DASHBOARD_FORWARD_HEADERS ?? "").split(",").map((s) => s.trim()).filter(Boolean),
|
|
403
|
+
...process.env.SCP_DASHBOARD_PUBLIC_URL ? { publicUrl: process.env.SCP_DASHBOARD_PUBLIC_URL } : {}
|
|
404
|
+
};
|
|
405
|
+
let health = false;
|
|
406
|
+
for (let i = 0;i < args.length; i++) {
|
|
407
|
+
const arg = args[i];
|
|
408
|
+
if (arg === "--health") {
|
|
409
|
+
health = true;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (!["--relay", "--listen", "--public-url", "--forward-header"].includes(arg ?? "") || !args[i + 1] || args[i + 1].startsWith("--"))
|
|
413
|
+
throw new Error("Invalid arguments. Use --help.");
|
|
414
|
+
const value = args[++i];
|
|
415
|
+
if (arg === "--relay")
|
|
416
|
+
options.relay = value;
|
|
417
|
+
if (arg === "--listen")
|
|
418
|
+
options.listen = value;
|
|
419
|
+
if (arg === "--public-url")
|
|
420
|
+
options.publicUrl = value;
|
|
421
|
+
if (arg === "--forward-header")
|
|
422
|
+
options.forwardHeaders.push(value);
|
|
423
|
+
}
|
|
424
|
+
const { host, port } = parseListen(options.listen);
|
|
425
|
+
if (health) {
|
|
426
|
+
const target = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host.includes(":") ? `[${host}]` : host;
|
|
427
|
+
const response = await fetch(`http://${target}:${port}/health`, { signal: AbortSignal.timeout(3000) });
|
|
428
|
+
if (!response.ok || (await response.json()).ok !== true)
|
|
429
|
+
throw new Error("Health check failed");
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (!options.relay)
|
|
433
|
+
throw new Error("--relay or SCP_DASHBOARD_RELAY is required");
|
|
434
|
+
const dashboard = createDashboard(options);
|
|
435
|
+
dashboard.server.listen(port, host, () => console.log(`SCP dashboard listening on ${host}:${port}`));
|
|
436
|
+
for (const signal of ["SIGTERM", "SIGINT"])
|
|
437
|
+
process.once(signal, () => {
|
|
438
|
+
dashboard.close();
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
function isMain() {
|
|
442
|
+
try {
|
|
443
|
+
return !!process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1]);
|
|
444
|
+
} catch {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (isMain()) {
|
|
449
|
+
main().catch(() => {
|
|
450
|
+
console.error("Dashboard failed. Check arguments and relay availability with --help.");
|
|
451
|
+
process.exitCode = 1;
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
export {
|
|
455
|
+
createDashboard,
|
|
456
|
+
main,
|
|
457
|
+
parseListen
|
|
458
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scp-dev/dashboard",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Optional dashboard for the SCP relay",
|
|
5
|
+
"type": "module",
|
|
5
6
|
"license": "Apache-2.0",
|
|
6
|
-
"repository": {
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/scphq/scp.git",
|
|
10
|
+
"directory": "packages/dashboard"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"scp-dashboard": "dist/server.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
}
|
|
7
22
|
}
|