blockyard 0.0.1 → 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.
Files changed (122) hide show
  1. package/CHANGELOG.md +929 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +191 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +41 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1577 -0
  9. package/docs/ARCHITECTURE.md +1394 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +847 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +205 -0
  15. package/docs/INSTALL.md +547 -0
  16. package/docs/MEASUREMENTS.md +1401 -0
  17. package/docs/RULES.md +681 -0
  18. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  19. package/docs/SECURITY-AUDIT.md +258 -0
  20. package/docs/SECURITY.md +212 -0
  21. package/docs/TROUBLESHOOTING.md +332 -0
  22. package/docs/USER-GUIDE.md +1262 -0
  23. package/package.json +53 -5
  24. package/public/404.html +9 -0
  25. package/public/css/app.css +2009 -0
  26. package/public/donate-qr.png +0 -0
  27. package/public/index.html +1085 -0
  28. package/public/js/about.js +112 -0
  29. package/public/js/agents.js +1141 -0
  30. package/public/js/app.js +1386 -0
  31. package/public/js/arkanoid.js +806 -0
  32. package/public/js/blockanoid.js +347 -0
  33. package/public/js/blockout.js +347 -0
  34. package/public/js/blockpack.js +428 -0
  35. package/public/js/blockscene3d.js +2830 -0
  36. package/public/js/breakout.js +224 -0
  37. package/public/js/charts.js +635 -0
  38. package/public/js/depthchart.js +315 -0
  39. package/public/js/details3d.js +4342 -0
  40. package/public/js/doom.js +31 -0
  41. package/public/js/dosaudio.js +48 -0
  42. package/public/js/dosgame.js +389 -0
  43. package/public/js/dosio.js +186 -0
  44. package/public/js/dospc.js +1353 -0
  45. package/public/js/dosworker.js +196 -0
  46. package/public/js/explorer.js +405 -0
  47. package/public/js/feepalette.js +149 -0
  48. package/public/js/fmt.js +162 -0
  49. package/public/js/goggles.js +886 -0
  50. package/public/js/kiosk.js +41 -0
  51. package/public/js/login.js +88 -0
  52. package/public/js/markets.js +395 -0
  53. package/public/js/mining.js +1416 -0
  54. package/public/js/panels.js +970 -0
  55. package/public/js/pricechart.js +189 -0
  56. package/public/js/quake.js +20 -0
  57. package/public/js/settings.js +1096 -0
  58. package/public/js/soundcard.js +459 -0
  59. package/public/js/tetris.js +226 -0
  60. package/public/js/tetrust.js +356 -0
  61. package/public/js/tetsound.js +175 -0
  62. package/public/js/theme.js +235 -0
  63. package/public/js/wolf3d.js +22 -0
  64. package/public/js/x86.js +1978 -0
  65. package/public/login.html +33 -0
  66. package/scripts/blockfile-measure.js +156 -0
  67. package/scripts/browser-check.mjs +286 -0
  68. package/scripts/check.js +173 -0
  69. package/scripts/decode-check.js +81 -0
  70. package/scripts/doc-counts.js +109 -0
  71. package/scripts/donate-qr.py +23 -0
  72. package/scripts/dos-bench.js +56 -0
  73. package/scripts/fake-node.js +534 -0
  74. package/scripts/index-bench.js +216 -0
  75. package/scripts/index-benchmark.js +117 -0
  76. package/scripts/index-build.js +40 -0
  77. package/scripts/live-render-check.mjs +89 -0
  78. package/scripts/manage-users.js +132 -0
  79. package/scripts/motion-check.mjs +138 -0
  80. package/scripts/pool-map.js +157 -0
  81. package/scripts/setup.js +432 -0
  82. package/scripts/shots.mjs +278 -0
  83. package/scripts/smoke.sh +327 -0
  84. package/scripts/tls.js +31 -0
  85. package/scripts/ui.js +174 -0
  86. package/server/auth/sessions.js +221 -0
  87. package/server/auth/users.js +243 -0
  88. package/server/chain/blockfile.js +234 -0
  89. package/server/chain/index/build.js +210 -0
  90. package/server/chain/index/heights.js +36 -0
  91. package/server/chain/index/live.js +276 -0
  92. package/server/chain/index/rows.js +145 -0
  93. package/server/chain/index/store.js +154 -0
  94. package/server/chain/index/worker.js +109 -0
  95. package/server/chain/tx.js +310 -0
  96. package/server/collect/gbt.js +229 -0
  97. package/server/collect/logparse.js +765 -0
  98. package/server/collect/logtail.js +189 -0
  99. package/server/collect/markets.js +333 -0
  100. package/server/collect/mining.js +333 -0
  101. package/server/collect/monitor.js +2545 -0
  102. package/server/collect/network.js +295 -0
  103. package/server/collect/nextblock.js +275 -0
  104. package/server/collect/sync.js +386 -0
  105. package/server/config.js +644 -0
  106. package/server/http/api.js +1319 -0
  107. package/server/http/explorer.js +418 -0
  108. package/server/http/games.js +77 -0
  109. package/server/http/server.js +420 -0
  110. package/server/http/sse.js +176 -0
  111. package/server/http/static.js +212 -0
  112. package/server/main.js +673 -0
  113. package/server/netinfo.js +253 -0
  114. package/server/rpc/allowlist.js +130 -0
  115. package/server/rpc/client.js +414 -0
  116. package/server/store/audit.js +148 -0
  117. package/server/store/history.js +220 -0
  118. package/server/store/ledger.js +290 -0
  119. package/server/store/ring.js +173 -0
  120. package/server/tls/selfsigned.js +160 -0
  121. package/server/util/fmt.js +29 -0
  122. package/systemd/blockyard.service +102 -0
@@ -0,0 +1,420 @@
1
+ // HTTP assembly: security gate -> static or API -> JSON envelope.
2
+ //
3
+ // Every mutating request needs the CSRF double-submit token; every request after
4
+ // the first needs a session; every request is rate limited per user. The audit
5
+ // trail records credentials use without ever recording a credential.
6
+ import http from 'node:http';
7
+ import https from 'node:https';
8
+ import { URL } from 'node:url';
9
+ import { StaticFiles, SECURITY_HEADERS, securityHeaders } from './static.js';
10
+ import { serveGame } from './games.js';
11
+ import { routes, HttpError } from './api.js';
12
+ import { parseCookies, serializeCookie, csrfOk } from '../auth/sessions.js';
13
+ import { ipDecision } from '../netinfo.js';
14
+
15
+ // Kept importable from the HTTP module while the gate lives here; the logic is in
16
+ // netinfo.js so it can be tested without assembling a server.
17
+ export { ipAllowed, parseIp, parseCidr } from '../netinfo.js';
18
+
19
+ const MAX_BODY = 1024 * 1024; // 1 MB: a raw transaction is nowhere near this
20
+
21
+ function compile(routesTable) {
22
+ return routesTable.map((r) => {
23
+ const names = [];
24
+ const re = new RegExp('^' + r.path.replace(/:[A-Za-z_]+/g, (m) => { names.push(m.slice(1)); return '([^/]+)'; }) + '$');
25
+ return { ...r, re, names };
26
+ });
27
+ }
28
+
29
+ export function createAppServer(app) {
30
+ const compiled = compile(routes);
31
+ const hstsMs = app.cfg.server.tls?.hstsMs ?? 0;
32
+ // Accounts off => every request below is served as `viewer` with no session.
33
+ const openAccess = !app.cfg.auth.enabled;
34
+ const statics = new StaticFiles(app.publicDir, { version: app.version, tls: app.tls, hstsMs });
35
+ const limiter = app.limiter;
36
+ // Responses built here (JSON, 404 pages, OPTIONS) carry the same policy as the
37
+ // static ones; HSTS is only included when this listener actually speaks TLS.
38
+ const H = () => securityHeaders({ tls: app.tls, hstsMs });
39
+
40
+ const listener = (req, res) => {
41
+ // HSTS on every TLS response. It is set once, here, rather than per sendJson /
42
+ // static / SSE path: a header that is valid on any HTTPS response and must not
43
+ // depend on which code path answered is a listener concern. Over plain HTTP it
44
+ // would pin an upgrade this server cannot serve, so it is conditional.
45
+ if (app.tls && hstsMs > 0) res.setHeader('Strict-Transport-Security', `max-age=${Math.floor(hstsMs / 1000)}`);
46
+ handle(req, res).catch((err) => {
47
+ app.log({ level: 'error', msg: `unhandled request error: ${err.stack ?? err.message}` });
48
+ if (!res.headersSent) sendJson(req, res, 500, { error: { message: 'internal error', kind: 'internal' } });
49
+ else res.destroy();
50
+ });
51
+ };
52
+ // TLS is per-listener, and every listener gets it or none does: a monitor that
53
+ // serves the LAN in clear text while the tunnel is encrypted is a monitor whose
54
+ // weakest address decides whether the session cookie is a secret. app.tlsOptions
55
+ // is loaded once at boot, where the certificate was already validated.
56
+ const server = app.tls && app.tlsOptions
57
+ ? https.createServer({ ...app.tlsOptions }, listener)
58
+ : http.createServer(listener);
59
+ server.keepAliveTimeout = 5000;
60
+ server.headersTimeout = 10000;
61
+ // SSE connections are long-lived by design; do not let the defaults reap them.
62
+ server.requestTimeout = 0;
63
+ server.headersTimeout = 15000;
64
+
65
+ async function handle(req, res) {
66
+ const ip = clientIp(req, app.cfg);
67
+ const started = Date.now();
68
+
69
+ // Network gate before anything else -- including before we spend a KDF on a
70
+ // login attempt from an address we will not serve anyway.
71
+ if (app.cfg.server.allowCidrs.length) {
72
+ const gate = ipDecision(ip, app.cfg.server.allowCidrs);
73
+ if (!gate.allowed) {
74
+ // The reason goes to the log, not the client: telling a stranger which
75
+ // prefixes are configured is a map of the inside.
76
+ app.log({ level: 'warn', msg: `refused connection from ${ip}: ${gate.reason}` });
77
+ return sendJson(req, res, 403, { error: { message: 'this address is not permitted', kind: 'forbidden' } });
78
+ }
79
+ if (gate.malformed?.length) {
80
+ // Loud, but only once per request path: a typo in server.allowCidrs makes
81
+ // an entry inert, and an inert entry in an allowlist is a silent hole.
82
+ app.log({ level: 'warn', msg: `server.allowCidrs has unusable entries: ${gate.malformed.join(', ')}` });
83
+ }
84
+ }
85
+
86
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
87
+ const path = url.pathname;
88
+ const query = Object.fromEntries(url.searchParams.entries());
89
+
90
+ if (req.method === 'OPTIONS') {
91
+ res.writeHead(204, { Allow: 'GET,POST,DELETE,HEAD', ...H() });
92
+ return res.end();
93
+ }
94
+
95
+ // SSE needs the raw response, so it is matched ahead of the JSON handlers.
96
+ if (path === '/api/stream' && req.method === 'GET') {
97
+ // The stream is the dashboard. If it asks for a session that the rest of the
98
+ // server does not ask for, open mode serves a page of frozen numbers -- the
99
+ // exact "nothing refreshes" shape this repo has already been burned by.
100
+ const user = openAccess ? { user: ANONYMOUS_USER } : resolveSession(req, app);
101
+ if (!user) return sendJson(req, res, 401, { error: { message: 'authentication required', kind: 'auth' } });
102
+ const rl = limiter.check(openAccess ? `sse:anon:${ip}` : `sse:${user.user.id}`, 1);
103
+ if (!rl.ok) return sendJson(req, res, 429, { error: { message: 'too many streams', kind: 'ratelimited' } });
104
+ // A stream is accepted for an unknown node id and then every frame is filtered
105
+ // out by nodeId -- which means a tab left pointing at a removed node gets a
106
+ // connection that is genuinely "live", a badge that says "live", and no data,
107
+ // forever, with nothing in any log. Refuse it here instead, so the client can
108
+ // see the failure and recover. `sse #N opened` should only ever be printed for
109
+ // a stream that will actually deliver something.
110
+ const wantNode = query.node || null;
111
+ if (wantNode && !app.monitors.has(wantNode)) {
112
+ return sendJson(req, res, 404, {
113
+ error: {
114
+ message: `no node "${wantNode}"; known: ${[...app.monitors.keys()].join(', ') || '(none configured)'}`,
115
+ kind: 'unknown_node',
116
+ },
117
+ });
118
+ }
119
+ const client = app.hub.add(req, res, { user: user.user, nodeId: wantNode });
120
+ app.log({ level: 'info', msg: `sse #${client.id} opened by ${user.user.username} (${ip})` });
121
+ req.on('close', () => app.log({ level: 'info', msg: `sse #${client.id} closed (${Math.round((Date.now() - started) / 1000)}s)` }));
122
+ return undefined;
123
+ }
124
+
125
+ const match = matchRoute(compiled, req.method, path);
126
+ if (match?.methodMismatch) {
127
+ res.setHeader('Allow', [...new Set(compiled.filter((r) => r.re.test(path)).map((r) => r.method))].join(', '));
128
+ return sendJson(req, res, 405, { error: { message: `${req.method} is not allowed on ${path}`, kind: 'method' } });
129
+ }
130
+ if (!match) {
131
+ if (path.startsWith('/api/')) return sendJson(req, res, 404, { error: { message: `no such endpoint ${path}`, kind: 'not_found' } });
132
+ // With accounts off there is nothing to sign in to, and a login form that
133
+ // cannot be submitted is worse than no form: send the visitor to the dashboard
134
+ // and let /api/me explain the mode.
135
+ if (openAccess && req.method === 'GET' && (path === '/login' || path === '/login.html')) {
136
+ res.writeHead(302, { Location: '/', 'Cache-Control': 'no-store', ...H() });
137
+ return res.end();
138
+ }
139
+ if (req.method !== 'GET' && req.method !== 'HEAD') return sendJson(req, res, 405, { error: { message: 'method not allowed', kind: 'method' } });
140
+ // The DOS Diversions' game files (http/games.js). Behind the session when accounts are on:
141
+ // twenty megabytes of somebody else's games are not a public asset of this monitor.
142
+ if (path.startsWith('/games/')) {
143
+ if (!openAccess && !resolveSession(req, app)) return sendJson(req, res, 401, { error: { message: 'authentication required', kind: 'auth' }, login: '/login' });
144
+ const done = await serveGame(req, res, path, app.gamesDir, { tls: app.tls, hstsMs });
145
+ if (done) { app.access({ req, res, path, status: done.status, ms: Date.now() - started, ip, user: null }); return undefined; }
146
+ }
147
+ const out = await statics.serve(req, res, path);
148
+ if (out?.error) return serveStaticError(req, res, statics, path, out.error, H());
149
+ app.access({ req, res, path, status: out?.status ?? 200, ms: Date.now() - started, ip, user: null });
150
+ return undefined;
151
+ }
152
+
153
+ const { route, params } = match;
154
+ const cookies = parseCookies(req.headers.cookie);
155
+ let session = null;
156
+ let user = null;
157
+ let token = null;
158
+
159
+ if (route.auth !== 'none') {
160
+ if (openAccess) {
161
+ // Accounts are off: nobody is authenticated, so nobody is denied either --
162
+ // up to the ceiling applied just below, which is what makes "open to
163
+ // everyone" mean open READS rather than open everything.
164
+ user = ANONYMOUS_USER;
165
+ // Rate limit per ADDRESS. Keying on user.id here would hand every client on
166
+ // the LAN one shared bucket, so a single chatty tab would rate-limit
167
+ // everyone else -- an outage caused by the neighbour's browser.
168
+ const rl = limiter.check(`anon:${ip}`, 1);
169
+ if (!rl.ok) {
170
+ return sendJson(req, res, 429, {
171
+ error: { message: `rate limited; retry in ${Math.ceil(rl.retryAfterMs / 1000)}s`, kind: 'ratelimited' },
172
+ });
173
+ }
174
+ } else {
175
+ const resolved = resolveSession(req, app, cookies);
176
+ if (!resolved) {
177
+ return sendJson(req, res, 401, { error: { message: 'authentication required', kind: 'auth' }, login: '/login' });
178
+ }
179
+ session = resolved.session;
180
+ user = resolved.user;
181
+ token = resolved.token;
182
+ if (user.disabled) return sendJson(req, res, 403, { error: { message: 'account disabled', kind: 'forbidden' } });
183
+ const rl = limiter.check(`${user.id}`, 1);
184
+ if (!rl.ok) {
185
+ return sendJson(req, res, 429, {
186
+ error: { message: `rate limited; retry in ${Math.ceil(rl.retryAfterMs / 1000)}s`, kind: 'ratelimited' },
187
+ });
188
+ }
189
+ }
190
+
191
+ // Enforced on BOTH branches. This is the whole point of the ceiling: an
192
+ // anonymous visitor must not reach the user-admin or audit routes merely
193
+ // because there is no session to check. Keeping this test only in the
194
+ // authenticated branch is how "no login required" quietly becomes "no login
195
+ // required, and anyone may create accounts".
196
+ if (route.auth === 'admin' && user.role !== 'admin') {
197
+ return sendJson(req, res, 403, openAccess
198
+ ? { error: { message: 'accounts are disabled, so this endpoint has no one to authorise; start with BLOCKYARD_AUTH=1 to enable sign-in, users and the audit trail', kind: 'forbidden' }, accounts: false }
199
+ : { error: { message: 'administrator role required', kind: 'forbidden' } });
200
+ }
201
+ }
202
+
203
+ let body = null;
204
+ if (route.body || req.method === 'POST') {
205
+ const read = await readBody(req, res);
206
+ if (read.error) return sendJson(req, res, read.error, { error: { message: read.message, kind: 'body' } });
207
+ body = read.value;
208
+ }
209
+
210
+ if (route.csrf && session) {
211
+ // Header or body only, deliberately NOT the cookie: a cross-site request
212
+ // carries the cookie just as happily, so accepting it here would make the
213
+ // whole check decorative. Reading the cookie and echoing it back in a
214
+ // header is the part a cross-origin page cannot do.
215
+ //
216
+ // `&& session`: the check exists because a cross-site request can ride a
217
+ // session cookie. With accounts OFF there is no cookie and no credential to
218
+ // ride, so the check has nothing left to protect and would only break the
219
+ // read-only RPC console. What open mode does instead is cap the role at viewer
220
+ // and refuse node writes (see ANONYMOUS_USER and actionAllowed).
221
+ const given = req.headers['x-csrf-token'] ?? body?.csrf;
222
+ if (!csrfOk(session, given)) {
223
+ await app.audit({ type: 'csrf-rejected', username: user?.username ?? null, path, ip });
224
+ return sendJson(req, res, 403, { error: { message: 'CSRF token missing or incorrect', kind: 'csrf' } });
225
+ }
226
+ }
227
+
228
+ // OPEN MODE STILL HAS TO REFUSE CROSS-SITE WRITES.
229
+ //
230
+ // The check above is skipped without a session, on the reasoning that there is no credential
231
+ // to ride. That is true of READS, and false of every route that makes the SERVER act. The node
232
+ // connection test was the proof: with accounts off it took an unauthenticated POST -- and a
233
+ // plain cross-site <form> reaches it, because readBody accepts x-www-form-urlencoded, so there
234
+ // is no preflight to stop it -- and pointed a credentialed RPC probe at an attacker's URL.
235
+ //
236
+ // A page on another origin cannot suppress Origin on a form post, nor forge Sec-Fetch-Site, so
237
+ // these two are exactly the signal open mode has left. A non-browser client (curl, a script)
238
+ // sends neither and is unaffected: it can already reach the port, and this check is about what
239
+ // a BROWSER can be made to do on somebody's behalf.
240
+ if (route.csrf && !session) {
241
+ const origin = req.headers.origin;
242
+ const site = req.headers['sec-fetch-site'];
243
+ let crossSite = false;
244
+ if (origin && origin !== 'null') {
245
+ try { crossSite = new URL(origin).host !== req.headers.host; } catch { crossSite = true; }
246
+ } else if (origin === 'null') {
247
+ crossSite = true; // an opaque origin: a sandboxed frame or a data: URL
248
+ }
249
+ if (site && !['same-origin', 'none'].includes(site)) crossSite = true;
250
+ if (crossSite) {
251
+ await app.audit({ type: 'csrf-rejected', username: null, path, ip, reason: 'cross-site request in open mode' });
252
+ return sendJson(req, res, 403, {
253
+ error: { message: 'cross-site request refused: this endpoint changes state, and with accounts off there is no token to check', kind: 'csrf' },
254
+ });
255
+ }
256
+ }
257
+
258
+ const setCookies = [];
259
+ const ctx = {
260
+ req, res, app, ip, params, query, body, user, session, token,
261
+ setCookie: (name, value, opts) => setCookies.push(serializeCookie(name, value, { secure: app.cfg.auth.secureCookie, ...opts })),
262
+ clearCookie: (name) => setCookies.push(serializeCookie(name, '', { maxAgeMs: 0, secure: app.cfg.auth.secureCookie })),
263
+ };
264
+
265
+ try {
266
+ const result = await route.handler(ctx, app);
267
+ if (res.writableEnded || res.headersSent) return undefined;
268
+ if (setCookies.length) res.setHeader('Set-Cookie', setCookies);
269
+ const status = result?.__status ?? (req.method === 'POST' && !result?.ok ? 200 : 200);
270
+ sendJson(req, res, status, result ?? { ok: true });
271
+ app.access({ req, res, path, status, ms: Date.now() - started, ip, user: user?.username ?? null });
272
+ } catch (err) {
273
+ if (err instanceof HttpError) {
274
+ sendJson(req, res, err.status, { error: { message: err.message, kind: 'api', code: err.code }, ...(err.detail ? { detail: err.detail } : {}) }, setCookies);
275
+ app.access({ req, res, path, status: err.status, ms: Date.now() - started, ip, user: user?.username ?? null, error: err.message });
276
+ return undefined;
277
+ }
278
+ app.log({ level: 'error', msg: `${req.method} ${path} failed: ${err.stack ?? err.message}` });
279
+ sendJson(req, res, 500, { error: { message: 'internal error', kind: 'internal' } }, setCookies);
280
+ app.access({ req, res, path, status: 500, ms: Date.now() - started, ip, user: user?.username ?? null, error: err.message });
281
+ }
282
+ return undefined;
283
+ }
284
+
285
+ return server;
286
+ }
287
+
288
+ /**
289
+ * The identity every request carries when accounts are off.
290
+ *
291
+ * `viewer` is a ceiling, not a default: it is not configurable, it is not read from
292
+ * anywhere, and there is no credential that can raise it. That is what lets the
293
+ * dashboard be open to everyone while /api/users, /api/audit and /api/password stay
294
+ * shut — the admin routes still ask for role `admin`, and nothing in open mode has it.
295
+ */
296
+ const ANONYMOUS_USER = Object.freeze({
297
+ id: 'anonymous',
298
+ username: 'anonymous',
299
+ role: 'viewer',
300
+ disabled: false,
301
+ lastLoginAt: null,
302
+ open: true,
303
+ });
304
+
305
+ function matchRoute(compiled, method, path) {
306
+ let methodMismatch = false;
307
+ for (const r of compiled) {
308
+ const m = r.re.exec(path);
309
+ if (!m) continue;
310
+ if (r.method !== method) { methodMismatch = true; continue; }
311
+ const params = {};
312
+ r.names.forEach((n, i) => { params[n] = decodeURIComponent(m[i + 1]); });
313
+ return { route: r, params };
314
+ }
315
+ return methodMismatch ? { methodMismatch: true } : null;
316
+ }
317
+
318
+ // A missing page gets a page, not a bare status line, so the browser has
319
+ // something to show. A path-traversal attempt is a 403 and stays one.
320
+ //
321
+ // StaticFiles.serve() writes the response itself when it finds a file, so the
322
+ // fallback must not also write headers -- doing that is ERR_HTTP_HEADERS_SENT.
323
+ async function serveStaticError(req, res, statics, path, code, headers = SECURITY_HEADERS) {
324
+ const wantsPage = code === 404 && (/\.[a-z0-9]+$/i.test(path) === false || path.endsWith('.html'));
325
+ if (wantsPage && !path.startsWith('/assets')) {
326
+ const out = await statics.resolve('/404.html');
327
+ if (!out.error) {
328
+ // status 404 with the page body: serve() defaults to 200, and a missing
329
+ // path answering 200 would make every crawler and uptime check think the
330
+ // guess was right.
331
+ const r = await statics.serve(req, res, '/404.html', { status: 404 });
332
+ if (!r?.error) return undefined; // sent with the right status
333
+ }
334
+ res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache', ...headers });
335
+ res.end('<!doctype html><meta charset=utf-8><title>404</title><h1>404 not found</h1><p><a href="/">back to the dashboard</a>');
336
+ return undefined;
337
+ }
338
+ res.writeHead(code, { 'Content-Type': 'text/plain; charset=utf-8', ...headers });
339
+ res.end(code === 403 ? '403 forbidden' : '404 not found');
340
+ return undefined;
341
+ }
342
+
343
+ function resolveSession(req, app, cookies = parseCookies(req.headers.cookie)) {
344
+ const token = cookies[app.cfg.auth.cookieName];
345
+ if (!token) return null;
346
+ const session = app.sessions.get(token);
347
+ if (!session) return null;
348
+ const user = app.users.byId(session.userId);
349
+ if (!user || user.disabled) return null;
350
+ // Role changes take effect on the next request, not only at next login: a
351
+ // session must not keep a privilege the account no longer has.
352
+ session.role = user.role;
353
+ return { session, user: { ...user }, token };
354
+ }
355
+
356
+ function readBody(req, res) {
357
+ return new Promise((resolve) => {
358
+ const chunks = [];
359
+ let size = 0;
360
+ let done = false;
361
+ const finish = (v) => { if (!done) { done = true; resolve(v); } };
362
+ req.on('data', (c) => {
363
+ size += c.length;
364
+ if (size > MAX_BODY) {
365
+ finish({ error: 413, message: `request body larger than ${MAX_BODY / 1024} KB` });
366
+ req.destroy();
367
+ return;
368
+ }
369
+ chunks.push(c);
370
+ });
371
+ req.on('end', () => {
372
+ const raw = Buffer.concat(chunks).toString('utf8').trim();
373
+ if (!raw) return finish({ value: null });
374
+ const ctype = req.headers['content-type'] || '';
375
+ if (ctype.includes('application/x-www-form-urlencoded')) {
376
+ return finish({ value: Object.fromEntries(new URLSearchParams(raw).entries()) });
377
+ }
378
+ try {
379
+ finish({ value: JSON.parse(raw) });
380
+ } catch (err) {
381
+ finish({ error: 400, message: `body is not valid JSON: ${err.message}` });
382
+ }
383
+ });
384
+ req.on('error', () => finish({ error: 400, message: 'body read failed' }));
385
+ });
386
+ }
387
+
388
+ function sendJson(req, res, status, obj, setCookies) {
389
+ if (res.writableEnded) return;
390
+ const body = JSON.stringify(obj ?? null);
391
+ const headers = {
392
+ 'Content-Type': 'application/json; charset=utf-8',
393
+ 'Content-Length': Buffer.byteLength(body),
394
+ 'Cache-Control': 'no-store',
395
+ ...SECURITY_HEADERS,
396
+ };
397
+ if (setCookies?.length) headers['Set-Cookie'] = setCookies;
398
+ res.writeHead(status, headers);
399
+ res.end(req.method === 'HEAD' ? undefined : body);
400
+ }
401
+
402
+ export function clientIp(req, cfg) {
403
+ if (cfg?.server?.trustProxy) {
404
+ const fwd = req.headers['x-forwarded-for'];
405
+ if (typeof fwd === 'string' && fwd.length) return normalizeIp(fwd.split(',')[0].trim());
406
+ }
407
+ return normalizeIp(req.socket?.remoteAddress ?? '0.0.0.0');
408
+ }
409
+
410
+ export function normalizeIp(addr) {
411
+ if (typeof addr !== 'string') return '0.0.0.0';
412
+ if (addr.startsWith('::ffff:')) return addr.slice(7);
413
+ return addr;
414
+ }
415
+
416
+ // IPv4/IPv6 CIDR membership now lives in server/netinfo.js (parseIp / parseCidr /
417
+ // ipDecision), where it is tested directly. It used to sit here comparing the
418
+ // text prefix of an address, which over-permitted: see the note above the gate and
419
+ // docs/DEFECTS.md.
420
+
@@ -0,0 +1,176 @@
1
+ // Server-Sent Events hub.
2
+ //
3
+ // SSE over plain HTTP rather than WebSockets because Node's stdlib has no WS
4
+ // server and one-way node->browser updates do not need a second protocol, a
5
+ // handshake, or a dependency. It also survives the proxy/LAN setups that break
6
+ // upgrades, and reconnects itself in the browser via EventSource.
7
+ //
8
+ // Backpressure matters here: a snapshot with chart series is 20-150 KB, and a
9
+ // laptop on Wi-Fi with a throttled tab will eventually stop draining. So writes
10
+ // are coalesced per client -- at most one snapshot in flight, latest wins -- and
11
+ // the event feed is batched. A client that cannot keep up sees a coarser stream,
12
+ // never an unbounded queue that takes the process down.
13
+ class Client {
14
+ constructor(res, user, id) {
15
+ this.res = res;
16
+ this.user = user;
17
+ this.id = id;
18
+ this.pendingSnapshot = null; // latest wins
19
+ this.pendingSeries = null;
20
+ this.eventBatch = [];
21
+ this.writing = false;
22
+ this.dropped = { snapshot: 0, series: 0, events: 0 };
23
+ this.bytes = 0;
24
+ this.connectedAt = Date.now();
25
+ this.alive = true;
26
+ this.lastWriteAt = Date.now();
27
+ this.nodeId = null;
28
+ }
29
+
30
+ get dead() { return !this.alive || this.res.writableEnded || this.res.destroyed; }
31
+ }
32
+
33
+ export class StreamHub {
34
+ constructor({ log = () => {} } = {}) {
35
+ this.clients = new Set();
36
+ this.log = log;
37
+ this.seq = 0;
38
+ this.heartbeatMs = 15000;
39
+ this.timer = null;
40
+ }
41
+
42
+ add(req, res, { user = null, nodeId = null } = {}) {
43
+ const id = ++this.seq;
44
+ const client = new Client(res, user, id);
45
+ client.nodeId = nodeId;
46
+ res.writeHead(200, {
47
+ 'Content-Type': 'text/event-stream; charset=utf-8',
48
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
49
+ 'Connection': 'keep-alive',
50
+ 'X-Accel-Buffering': 'no', // nginx: do not buffer this into oblivion
51
+ });
52
+ res.write(': stream open\n\n');
53
+ this.clients.add(client);
54
+ const close = () => this.remove(client);
55
+ req.on('close', close);
56
+ req.on('aborted', close);
57
+ res.on('error', close);
58
+ if (!this.timer) this.startHeartbeat();
59
+ return client;
60
+ }
61
+
62
+ remove(client) {
63
+ client.alive = false;
64
+ this.clients.delete(client);
65
+ if (!this.clients.size && this.timer) { clearInterval(this.timer); this.timer = null; }
66
+ try { client.res.end(); } catch { /* already gone */ }
67
+ }
68
+
69
+ startHeartbeat() {
70
+ const tick = () => {
71
+ for (const c of [...this.clients]) {
72
+ if (c.dead) { this.remove(c); continue; }
73
+ // A comment frame keeps intermediaries from reaping an idle stream and
74
+ // lets us notice a dead socket on our own terms.
75
+ if (this.write(c, ': ping\n\n')) {
76
+ if (Date.now() - c.lastWriteAt > 120_000 && c.bytes === 0) this.remove(c);
77
+ }
78
+ }
79
+ };
80
+ this.timer = setInterval(tick, this.heartbeatMs);
81
+ this.timer.unref?.();
82
+ }
83
+
84
+ write(client, frame) {
85
+ if (client.dead) return false;
86
+ try {
87
+ const ok = client.res.write(frame);
88
+ client.bytes += frame.length;
89
+ client.lastWriteAt = Date.now();
90
+ if (!ok) client.backpressured = true;
91
+ return ok;
92
+ } catch (err) {
93
+ this.log({ level: 'debug', msg: `sse write failed: ${err.message}` });
94
+ this.remove(client);
95
+ return false;
96
+ }
97
+ }
98
+
99
+ // Latest-snapshot-wins: the newest state is always the most useful one, so an
100
+ // in-flight backlog is discarded rather than replayed.
101
+ pushSnapshot(state, { nodeId } = {}) {
102
+ for (const c of this.clients) {
103
+ if (c.dead || (nodeId && c.nodeId && c.nodeId !== nodeId)) continue;
104
+ if (c.pendingSnapshot !== null) c.dropped.snapshot += 1;
105
+ c.pendingSnapshot = state;
106
+ }
107
+ this.flush();
108
+ }
109
+
110
+ pushSeries(series, { nodeId } = {}) {
111
+ for (const c of this.clients) {
112
+ if (c.dead || (nodeId && c.nodeId !== nodeId)) continue;
113
+ if (c.pendingSeries !== null) c.dropped.series += 1;
114
+ c.pendingSeries = series;
115
+ }
116
+ this.flush();
117
+ }
118
+
119
+ pushEvent(row, { nodeId } = {}) {
120
+ for (const c of this.clients) {
121
+ if (c.dead || (nodeId && c.nodeId !== nodeId)) continue;
122
+ if (c.eventBatch.length > 800) { c.eventBatch.splice(0, 400); c.dropped.events += 1; }
123
+ c.eventBatch.push(row);
124
+ }
125
+ this.flush();
126
+ }
127
+
128
+ send(client, event, data, id = null) {
129
+ const parts = [];
130
+ if (id != null) parts.push(`id: ${id}`);
131
+ parts.push(`event: ${event}`);
132
+ parts.push(`data: ${JSON.stringify(data)}`);
133
+ parts.push('');
134
+ parts.push('');
135
+ return this.write(client, parts.join('\n'));
136
+ }
137
+
138
+ flush() {
139
+ if (this.flushing) return;
140
+ this.flushing = true;
141
+ setImmediate(() => {
142
+ this.flushing = false;
143
+ for (const c of [...this.clients]) {
144
+ if (c.dead) { this.remove(c); continue; }
145
+ if (c.pendingSnapshot !== null) {
146
+ const snap = c.pendingSnapshot;
147
+ c.pendingSnapshot = null;
148
+ this.send(c, 'snapshot', snap, snap?.seq ?? null);
149
+ }
150
+ if (c.pendingSeries !== null) {
151
+ const s = c.pendingSeries;
152
+ c.pendingSeries = null;
153
+ this.send(c, 'series', s);
154
+ }
155
+ if (c.eventBatch.length) {
156
+ const rows = c.eventBatch.splice(0, c.eventBatch.length);
157
+ this.send(c, 'events', rows);
158
+ }
159
+ }
160
+ });
161
+ }
162
+
163
+ stats() {
164
+ return {
165
+ clients: this.clients.size,
166
+ perClient: [...this.clients].map((c) => ({
167
+ id: c.id, user: c.user?.username ?? null, seconds: Math.round((Date.now() - c.connectedAt) / 1000),
168
+ kb: Math.round(c.bytes / 1024), dropped: { ...c.dropped }, node: c.nodeId,
169
+ })),
170
+ };
171
+ }
172
+
173
+ closeAll() {
174
+ for (const c of [...this.clients]) this.remove(c);
175
+ }
176
+ }