blockyard 0.0.9 → 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/CHANGELOG.md +251 -1
- package/README.md +42 -23
- package/bin/blockyard.js +2 -1
- package/docs/API.md +16 -14
- package/docs/ARCHITECTURE.md +92 -5
- package/docs/CONFIGURATION.md +33 -26
- package/docs/GETTING-STARTED.md +5 -2
- package/docs/INSTALL.md +90 -33
- package/docs/MEASUREMENTS.md +147 -0
- package/docs/SECURITY.md +32 -15
- package/docs/TROUBLESHOOTING.md +35 -1
- package/docs/USER-GUIDE.md +266 -26
- package/package.json +1 -1
- package/public/404.html +1 -1
- package/public/css/app.css +306 -82
- package/public/donate-qr.png +0 -0
- package/public/index.html +295 -103
- package/public/js/agents.js +228 -51
- package/public/js/app.js +82 -8
- package/public/js/blockscene3d.js +179 -27
- package/public/js/charts.js +21 -21
- package/public/js/depthchart.js +31 -27
- package/public/js/details3d.js +1456 -71
- package/public/js/doom.js +31 -0
- package/public/js/dosaudio.js +48 -0
- package/public/js/dosgame.js +389 -0
- package/public/js/dosio.js +186 -0
- package/public/js/dospc.js +1353 -0
- package/public/js/dosworker.js +196 -0
- package/public/js/login.js +5 -0
- package/public/js/markets.js +46 -8
- package/public/js/mining.js +310 -32
- package/public/js/panels.js +14 -10
- package/public/js/pricechart.js +14 -13
- package/public/js/quake.js +20 -0
- package/public/js/settings.js +103 -21
- package/public/js/soundcard.js +459 -0
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/scripts/donate-qr.py +12 -9
- package/scripts/dos-bench.js +56 -0
- package/scripts/setup.js +34 -12
- package/scripts/shots.mjs +6 -0
- package/scripts/smoke.sh +1 -1
- package/scripts/tls.js +31 -0
- package/server/chain/index/build.js +21 -4
- package/server/collect/monitor.js +30 -1
- package/server/collect/network.js +295 -0
- package/server/config.js +46 -22
- package/server/http/api.js +49 -5
- package/server/http/games.js +77 -0
- package/server/http/server.js +8 -0
- package/server/main.js +53 -8
- package/server/tls/selfsigned.js +160 -0
- package/systemd/blockyard.service +7 -5
- package/docs/PRIVATE-LEADERBOARD.md +0 -230
- package/docs/STATE-2026-09-09.md +0 -200
package/server/config.js
CHANGED
|
@@ -27,9 +27,13 @@ Defaults are Bitcoin Core's own, so \`npm start\` works against a stock local no
|
|
|
27
27
|
|
|
28
28
|
const DEFAULTS = {
|
|
29
29
|
server: {
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
30
|
+
// THIS MACHINE ONLY, out of the box (2026-09-15, after the first outside review of 0.0.9:
|
|
31
|
+
// "Default is 0.0.0.0 with auth disabled, so anyone that can hit port 21000 gets node state
|
|
32
|
+
// plus the read RPC console ... I would run it only bound to 127.0.0.1, with BLOCKYARD_AUTH=1,
|
|
33
|
+
// behind SSH/TLS" -- operator: "Update the default setup to be hardened"). It bound 0.0.0.0
|
|
34
|
+
// because multi-user means the LAN has to reach it; now reaching it from elsewhere is a
|
|
35
|
+
// decision you make -- BLOCKYARD_BIND=0.0.0.0, or a LAN address, or `blockyard setup`.
|
|
36
|
+
host: '127.0.0.1',
|
|
33
37
|
// 21000 (operator, 2026-09-13: "make default web port 21000 for access"). It was 8088, which
|
|
34
38
|
// sits in the range every other monitor on a box reaches for; this one is ours.
|
|
35
39
|
port: 21000,
|
|
@@ -37,15 +41,19 @@ const DEFAULTS = {
|
|
|
37
41
|
allowCidrs: [],
|
|
38
42
|
trustProxy: false,
|
|
39
43
|
// TLS is off unless both files are named, and then it is on for every listener.
|
|
40
|
-
// It
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
44
|
+
// It was opt-in because a self-signed certificate produces a browser warning per
|
|
45
|
+
// address and an SSH tunnel or a reverse proxy that owns a real certificate is better
|
|
46
|
+
// on a machine you control. What was missing until 2026-09-09 was the option at all --
|
|
47
|
+
// serving a session cookie and every RPC reply over plain HTTP on a LAN is not a gap
|
|
48
|
+
// you get to call "documented, therefore fine".
|
|
49
|
+
// HTTPS BY DEFAULT since 2026-09-15 (operator: "make https the forced default"): with
|
|
50
|
+
// no certificate of your own named, the server makes a self-signed one on first start
|
|
51
|
+
// (server/tls/selfsigned.js, kept under <data>/tls) naming the addresses it is reached
|
|
52
|
+
// on, and serves HTTPS with it. BLOCKYARD_TLS=0 (server.tls.enabled: false) is the way
|
|
53
|
+
// to plain HTTP, for a reverse proxy that terminates TLS in front.
|
|
47
54
|
tls: {
|
|
48
|
-
|
|
55
|
+
enabled: true,
|
|
56
|
+
cert: null, // PEM; BLOCKYARD_TLS_CERT -- your own certificate, instead of the made one
|
|
49
57
|
key: null, // PEM; BLOCKYARD_TLS_KEY
|
|
50
58
|
// Sent over TLS responses only. Two days, not the usual year: a LAN address
|
|
51
59
|
// can be reissued to something else, and HSTS is the header that cannot be
|
|
@@ -193,10 +201,11 @@ const DEFAULTS = {
|
|
|
193
201
|
auditKeep: 5,
|
|
194
202
|
},
|
|
195
203
|
auth: {
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
// a
|
|
199
|
-
//
|
|
204
|
+
// SIGN-IN BY DEFAULT (2026-09-15, the same review): the first start creates an `admin`
|
|
205
|
+
// account and prints its password once (or takes BLOCKYARD_ADMIN_PASSWORD). It shipped OPEN,
|
|
206
|
+
// like a block explorer, so that anyone who could reach the port could read; that is still
|
|
207
|
+
// available -- BLOCKYARD_AUTH=0, or auth.enabled: false -- as a posture you choose, announced
|
|
208
|
+
// by the boot warning that names the addresses it leaves readable.
|
|
200
209
|
//
|
|
201
210
|
// What "open" is bounded by, in server/http/server.js:
|
|
202
211
|
// * the anonymous role is `viewer` and the ceiling is not configurable; user
|
|
@@ -207,9 +216,9 @@ const DEFAULTS = {
|
|
|
207
216
|
// should discover by accident);
|
|
208
217
|
// * rate limits key on the IP, so one noisy tab cannot spend everyone's bucket.
|
|
209
218
|
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
enabled:
|
|
219
|
+
// Accounts, roles, sessions, CSRF and the audit trail-by-user are on; BLOCKYARD_AUTH=0
|
|
220
|
+
// (or auth.enabled: false in config/local.json) opens the monitor to readers.
|
|
221
|
+
enabled: true,
|
|
213
222
|
dataDir: null,
|
|
214
223
|
// THE LONG ONE IS THE ABSOLUTE LIFETIME, the short one the idle ceiling -- which is the way
|
|
215
224
|
// round the names read, and the opposite of what shipped until 2026-09-13. With an 8 h
|
|
@@ -273,7 +282,11 @@ const DEFAULTS = {
|
|
|
273
282
|
},
|
|
274
283
|
// The Markets tab (server/collect/markets.js): public exchange APIs over HTTPS -- the one
|
|
275
284
|
// outbound connection that is not the node. Polled only while someone has the tab open, and
|
|
276
|
-
// parked idleAfterMs after the last request. BLOCKYARD_MARKETS=0
|
|
285
|
+
// parked idleAfterMs after the last request. BLOCKYARD_MARKETS=0 removes it altogether.
|
|
286
|
+
// POLLING IS OFF OUT OF THE BOX regardless (operator, 2026-09-15: "disable markets by default so
|
|
287
|
+
// we can claim true zero telemetry out of the box" ... "an app wide 'Enable Market Polling'
|
|
288
|
+
// checkbox"): the feed is built here but asks nobody anything until the switch in Display
|
|
289
|
+
// settings -> Markets & Price -> Enable market polling is on (http/api.js marketsPollingOn).
|
|
277
290
|
markets: {
|
|
278
291
|
enabled: true,
|
|
279
292
|
tickerMs: 15000,
|
|
@@ -395,6 +408,7 @@ export function loadConfig({ configFile = defaultConfigFile(), ifaces = null, no
|
|
|
395
408
|
'BLOCKYARD_LOG_SOURCE': ['log.enabled', Boolean],
|
|
396
409
|
'BLOCKYARD_MARKETS': ['markets.enabled', Boolean],
|
|
397
410
|
'BLOCKYARD_SECURE_COOKIE': ['auth.secureCookie', Boolean],
|
|
411
|
+
'BLOCKYARD_TLS': ['server.tls.enabled', Boolean],
|
|
398
412
|
'BLOCKYARD_TLS_CERT': ['server.tls.cert', String],
|
|
399
413
|
'BLOCKYARD_TLS_KEY': ['server.tls.key', String],
|
|
400
414
|
'BLOCKYARD_ACTIONS': ['actions.allow', (v) => v.split(',').map((s) => s.trim()).filter(Boolean)],
|
|
@@ -493,8 +507,12 @@ export function configProblems() { return problems; }
|
|
|
493
507
|
function validateTls(cfg, now = Date.now()) {
|
|
494
508
|
const tls = cfg.server.tls ?? {};
|
|
495
509
|
cfg.server.tls = tls;
|
|
496
|
-
cfg.tls =
|
|
497
|
-
|
|
510
|
+
if (tls.enabled === false) { cfg.tls = false; cfg.__tlsAuto = false; return; } // plain HTTP, chosen
|
|
511
|
+
cfg.tls = true;
|
|
512
|
+
// no certificate named: the server makes its own at boot (main.js, ensureSelfSigned) and
|
|
513
|
+
// inspects it then -- so nothing below applies yet
|
|
514
|
+
if (!tls.cert && !tls.key) { cfg.__tlsAuto = true; return; }
|
|
515
|
+
cfg.__tlsAuto = false;
|
|
498
516
|
if (!tls.cert || !tls.key) {
|
|
499
517
|
problems.push(`server.tls needs BOTH cert and key (got ${tls.cert ? 'cert only' : 'key only'}); a half-configured TLS would fall back to plaintext on a port you believe is HTTPS`);
|
|
500
518
|
return;
|
|
@@ -507,6 +525,12 @@ function validateTls(cfg, now = Date.now()) {
|
|
|
507
525
|
}
|
|
508
526
|
}
|
|
509
527
|
if (problems.length) return;
|
|
528
|
+
inspectTls(cfg, tls, now);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// read the certificate: fingerprint, expiry, whether it is self-signed; problems for an
|
|
532
|
+
// unparseable or expired one, a note for one about to expire
|
|
533
|
+
export function inspectTls(cfg, tls, now = Date.now()) {
|
|
510
534
|
try {
|
|
511
535
|
const x = new crypto.X509Certificate(fs.readFileSync(tls.cert, 'utf8'));
|
|
512
536
|
tls.fingerprint = x.fingerprint256;
|
|
@@ -530,7 +554,7 @@ function validate(cfg, ifaces = null, now = Date.now()) {
|
|
|
530
554
|
if (!Number.isInteger(cfg.server.port) || cfg.server.port < 1 || cfg.server.port > 65535) problems.push('server.port invalid');
|
|
531
555
|
// `hosts` is the truth: one address, a comma list, or an array all normalise here.
|
|
532
556
|
// `host` stays populated with the first entry for anything that still reads it.
|
|
533
|
-
cfg.server.hosts = hostList(cfg.server.hosts ?? cfg.server.host ?? '
|
|
557
|
+
cfg.server.hosts = hostList(cfg.server.hosts ?? cfg.server.host ?? '127.0.0.1');
|
|
534
558
|
cfg.server.host = cfg.server.hosts[0];
|
|
535
559
|
if (!cfg.server.hosts.length) problems.push('server.hosts is empty; nothing would be served');
|
|
536
560
|
// A hostname here binds whatever DNS says at boot, and fails at listen() with a
|
package/server/http/api.js
CHANGED
|
@@ -12,8 +12,36 @@ import { xSearch, xTx, xBlock, xAddress } from './explorer.js';
|
|
|
12
12
|
|
|
13
13
|
// Dollar figures for the explorer: a spot price if one is at hand within 1.5 s -- never a slower
|
|
14
14
|
// page for want of one (server/collect/markets.js spot()).
|
|
15
|
+
// THE MARKET SWITCH (operator, 2026-09-15: "disable markets by default so we can claim true zero
|
|
16
|
+
// telemetry out of the box" ... "an app wide 'Enable Market Polling' checkbox"). Two layers:
|
|
17
|
+
// - BLOCKYARD_MARKETS=0 (markets.enabled=false) removes the feed from the server altogether;
|
|
18
|
+
// nothing in the browser can turn it on. For machines that must never reach out.
|
|
19
|
+
// - otherwise the feed exists but polls only while the Display setting
|
|
20
|
+
// markets.polling is on -- and that ships OFF, so a fresh install makes no outbound
|
|
21
|
+
// connection but to the node until someone ticks the box.
|
|
22
|
+
// The setting lives in the server's settings file (config/blockyard.json, the same one every
|
|
23
|
+
// screen shares); it is read here per request, cached on the file's mtime and size, so a tick in
|
|
24
|
+
// the panel takes effect on the next call without a restart -- and the next call also PARKS the
|
|
25
|
+
// feed, so unticking stops the exchange traffic at once rather than ten minutes later.
|
|
26
|
+
const MARKETS_OFF = 'market data is off on this server (BLOCKYARD_MARKETS=0 or markets.enabled=false); the switch in Display settings cannot turn it on';
|
|
27
|
+
const POLLING_OFF = 'market polling is off -- the default, so that out of the box this monitor makes no outbound connection but to your node. Turn it on under Display settings → Markets & Price → Enable market polling';
|
|
28
|
+
const pollingCache = new WeakMap();
|
|
29
|
+
export async function marketsPollingOn(app) {
|
|
30
|
+
const file = app.settingsFile;
|
|
31
|
+
if (!file) return false;
|
|
32
|
+
let st;
|
|
33
|
+
try { st = await fsp.stat(file); } catch { return false; }
|
|
34
|
+
const hit = pollingCache.get(app);
|
|
35
|
+
if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit.value;
|
|
36
|
+
let value = false;
|
|
37
|
+
try { value = JSON.parse(await fsp.readFile(file, 'utf8'))?.markets?.polling === true; } catch { value = false; }
|
|
38
|
+
pollingCache.set(app, { mtimeMs: st.mtimeMs, size: st.size, value });
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
const pollingOff = (app) => { app.markets?.stop?.(); return { ok: true, enabled: false, polling: false, note: POLLING_OFF }; };
|
|
42
|
+
|
|
15
43
|
async function withUsd(app, r) {
|
|
16
|
-
if (!r?.ok || !app.markets) return r;
|
|
44
|
+
if (!r?.ok || !app.markets || !(await marketsPollingOn(app))) return r;
|
|
17
45
|
const p = await Promise.race([app.markets.spot().catch(() => null), new Promise((res) => { setTimeout(res, 1500, null).unref?.(); })]);
|
|
18
46
|
return { ...r, usd: p?.usd ?? null };
|
|
19
47
|
}
|
|
@@ -425,17 +453,33 @@ export const routes = [
|
|
|
425
453
|
// Exchange prices (server/collect/markets.js). Asking is what keeps the feed polling.
|
|
426
454
|
{
|
|
427
455
|
method: 'GET', path: '/api/markets', auth: 'any',
|
|
428
|
-
handler: (ctx, app) => {
|
|
429
|
-
if (!app.markets) return { ok: true, enabled: false, note:
|
|
456
|
+
handler: async (ctx, app) => {
|
|
457
|
+
if (!app.markets) return { ok: true, enabled: false, note: MARKETS_OFF };
|
|
458
|
+
if (!(await marketsPollingOn(app))) return pollingOff(app);
|
|
430
459
|
app.markets.touch();
|
|
431
460
|
return app.markets.view();
|
|
432
461
|
},
|
|
433
462
|
},
|
|
463
|
+
// THE SPOT PRICE, for dollar figures on pages that are not Markets (the Mining tab's reward
|
|
464
|
+
// stats, 2026-09-15: "Add dollar figures"): the feed's median while it is polling, else the
|
|
465
|
+
// explorer's cached spot read (two exchanges, at most once a minute) -- and null, saying why,
|
|
466
|
+
// while market polling is off. Does NOT touch the feed: asking the price here never starts
|
|
467
|
+
// the week-long polling that Markets does.
|
|
468
|
+
{
|
|
469
|
+
method: 'GET', path: '/api/price', auth: 'any',
|
|
470
|
+
handler: async (ctx, app) => {
|
|
471
|
+
if (!app.markets) return { ok: true, usd: null, enabled: false, note: MARKETS_OFF };
|
|
472
|
+
if (!(await marketsPollingOn(app))) return { ok: true, usd: null, polling: false, note: POLLING_OFF };
|
|
473
|
+
const p = await Promise.race([app.markets.spot().catch(() => null), new Promise((res) => { setTimeout(res, 1500, null).unref?.(); })]);
|
|
474
|
+
return { ok: true, usd: p?.usd ?? null, at: p?.at ?? null, source: p?.source ?? null };
|
|
475
|
+
},
|
|
476
|
+
},
|
|
434
477
|
// The depth chart: the books as cumulative depth, and the snapshot `ago` seconds earlier.
|
|
435
478
|
{
|
|
436
479
|
method: 'GET', path: '/api/markets/depth', auth: 'any',
|
|
437
|
-
handler: (ctx, app) => {
|
|
438
|
-
if (!app.markets) return { ok: true, enabled: false, note:
|
|
480
|
+
handler: async (ctx, app) => {
|
|
481
|
+
if (!app.markets) return { ok: true, enabled: false, note: MARKETS_OFF };
|
|
482
|
+
if (!(await marketsPollingOn(app))) return pollingOff(app);
|
|
439
483
|
app.markets.touch();
|
|
440
484
|
return app.markets.depthView(Number(ctx.query.ago) || 600);
|
|
441
485
|
},
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// THE GAME FILES (operator, 2026-09-15: "I've added doom_dos to the project directory. Get DOOM
|
|
2
|
+
// working as a diversion inside blockyard with zero dependancies"; later "move doom_dos out of the
|
|
3
|
+
// root and move into games", and "get Quake working as a diversion").
|
|
4
|
+
//
|
|
5
|
+
// The DOS Diversions run shareware DOOM and Quake in a PC emulated in the browser (public/js/x86.js,
|
|
6
|
+
// dospc.js, soundcard.js), and the emulator needs the games' own files. They live in games/, one
|
|
7
|
+
// directory a game, where the operator put them -- not in public/, which is the app and is stamped
|
|
8
|
+
// with a build id computed over every file in it (18 MB of PAK in that digest, re-hashed every two
|
|
9
|
+
// seconds of page loads, would be a cost paid by every page for two diversions).
|
|
10
|
+
//
|
|
11
|
+
// Served under /games/<game>/<path>, and only that: a game this file names, and a DOS path of at
|
|
12
|
+
// most one directory and an 8.3 name of a kind the games read (an executable, a WAD, a PAK, a
|
|
13
|
+
// config, Wolfenstein 3D's .WL1 data). No dots but the one in each name, so there is no path to
|
|
14
|
+
// traverse; the lookup is
|
|
15
|
+
// case-insensitive because DOS names are, and the files on disk are upper-case while a browser asks
|
|
16
|
+
// for whatever it was told.
|
|
17
|
+
import fsp from 'node:fs/promises';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { securityHeaders } from './static.js';
|
|
20
|
+
|
|
21
|
+
/** Each game's directory under the games root. */
|
|
22
|
+
export const GAME_DIRS = Object.freeze({ wolf3d: 'wolf3d_dos', doom: 'doom_dos', quake: 'quake_dos' });
|
|
23
|
+
|
|
24
|
+
export const GAME_PATH = /^\/games\/([a-z0-9]+)\/((?:[A-Za-z0-9_-]{1,8}\/)?[A-Za-z0-9_-]{1,8}\.(?:wad|exe|cfg|pak|wl1))$/i;
|
|
25
|
+
|
|
26
|
+
/** The file under `dir` at the DOS path `rel` ("ID1/PAK0.PAK"), matching each part ignoring case, or null. */
|
|
27
|
+
export async function findGameFile(dir, rel) {
|
|
28
|
+
let at = dir;
|
|
29
|
+
const parts = rel.split('/');
|
|
30
|
+
for (let i = 0; i < parts.length; i++) {
|
|
31
|
+
let names;
|
|
32
|
+
try { names = await fsp.readdir(at, { withFileTypes: true }); } catch { return null; }
|
|
33
|
+
const want = parts[i].toUpperCase(), last = i === parts.length - 1;
|
|
34
|
+
const hit = names.find((e) => (last ? e.isFile() : e.isDirectory()) && e.name.toUpperCase() === want);
|
|
35
|
+
if (!hit) return null;
|
|
36
|
+
at = path.join(at, hit.name);
|
|
37
|
+
}
|
|
38
|
+
return at;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Answer a /games/ request. Returns { status } when it answered, or null when the path is not one
|
|
43
|
+
* of ours (the caller's 404 applies).
|
|
44
|
+
*/
|
|
45
|
+
export async function serveGame(req, res, urlPath, root, { tls = false, hstsMs = 0 } = {}) {
|
|
46
|
+
const m = GAME_PATH.exec(urlPath);
|
|
47
|
+
if (!m || !Object.hasOwn(GAME_DIRS, m[1])) return null;
|
|
48
|
+
const dirName = GAME_DIRS[m[1]];
|
|
49
|
+
const file = await findGameFile(path.join(root, dirName), m[2]);
|
|
50
|
+
const headers = securityHeaders({ tls, hstsMs });
|
|
51
|
+
if (!file) {
|
|
52
|
+
res.writeHead(404, { ...headers, 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
53
|
+
res.end(req.method === 'HEAD' ? undefined : `${m[2].toUpperCase()} is not in games/${dirName}/`);
|
|
54
|
+
return { status: 404 };
|
|
55
|
+
}
|
|
56
|
+
const st = await fsp.stat(file);
|
|
57
|
+
const etag = `W/"${st.size.toString(16)}-${Math.floor(st.mtimeMs).toString(16)}"`;
|
|
58
|
+
if (req.headers['if-none-match'] === etag) {
|
|
59
|
+
res.writeHead(304, { ...headers, ETag: etag, 'Cache-Control': 'no-cache' });
|
|
60
|
+
res.end();
|
|
61
|
+
return { status: 304 };
|
|
62
|
+
}
|
|
63
|
+
res.writeHead(200, {
|
|
64
|
+
...headers,
|
|
65
|
+
'Content-Type': 'application/octet-stream',
|
|
66
|
+
'Content-Length': st.size,
|
|
67
|
+
'Cache-Control': 'no-cache',
|
|
68
|
+
ETag: etag,
|
|
69
|
+
'Last-Modified': new Date(st.mtimeMs).toUTCString(),
|
|
70
|
+
});
|
|
71
|
+
if (req.method === 'HEAD') { res.end(); return { status: 200 }; }
|
|
72
|
+
const { createReadStream } = await import('node:fs');
|
|
73
|
+
const stream = createReadStream(file);
|
|
74
|
+
stream.on('error', () => res.destroy());
|
|
75
|
+
stream.pipe(res);
|
|
76
|
+
return { status: 200 };
|
|
77
|
+
}
|
package/server/http/server.js
CHANGED
|
@@ -7,6 +7,7 @@ import http from 'node:http';
|
|
|
7
7
|
import https from 'node:https';
|
|
8
8
|
import { URL } from 'node:url';
|
|
9
9
|
import { StaticFiles, SECURITY_HEADERS, securityHeaders } from './static.js';
|
|
10
|
+
import { serveGame } from './games.js';
|
|
10
11
|
import { routes, HttpError } from './api.js';
|
|
11
12
|
import { parseCookies, serializeCookie, csrfOk } from '../auth/sessions.js';
|
|
12
13
|
import { ipDecision } from '../netinfo.js';
|
|
@@ -136,6 +137,13 @@ export function createAppServer(app) {
|
|
|
136
137
|
return res.end();
|
|
137
138
|
}
|
|
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
|
+
}
|
|
139
147
|
const out = await statics.serve(req, res, path);
|
|
140
148
|
if (out?.error) return serveStaticError(req, res, statics, path, out.error, H());
|
|
141
149
|
app.access({ req, res, path, status: out?.status ?? 200, ms: Date.now() - started, ip, user: null });
|
package/server/main.js
CHANGED
|
@@ -12,6 +12,9 @@ import { createAppServer } from './http/server.js';
|
|
|
12
12
|
import { computeBuildId } from './http/static.js';
|
|
13
13
|
import { NodeMonitor } from './collect/monitor.js';
|
|
14
14
|
import { localAddresses, bindProblemMessage, planBinds } from './netinfo.js';
|
|
15
|
+
import { ensureSelfSigned } from './tls/selfsigned.js';
|
|
16
|
+
import { inspectTls } from './config.js';
|
|
17
|
+
import os from 'node:os';
|
|
15
18
|
import { fileURLToPath } from 'node:url';
|
|
16
19
|
|
|
17
20
|
// ONE PLACE, NOT TWO. This was a literal here AND a "version" field in package.json, and on
|
|
@@ -49,6 +52,8 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
|
49
52
|
),
|
|
50
53
|
startedAt: Date.now(),
|
|
51
54
|
publicDir: path.join(ROOT, 'public'),
|
|
55
|
+
// the DOS Diversions' game files, a directory a game, where the operator put them (http/games.js)
|
|
56
|
+
gamesDir: path.join(ROOT, 'games'),
|
|
52
57
|
monitors: new Map(),
|
|
53
58
|
stateSeq: 0,
|
|
54
59
|
rssStart: process.memoryUsage().rss,
|
|
@@ -66,6 +71,18 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
|
66
71
|
// Decided here, before any listener exists, and the cookie follows it: a Secure
|
|
67
72
|
// cookie on an HTTP listener is a cookie the browser will not send, which reads
|
|
68
73
|
// as "login keeps failing" -- so the two settings must not be independently set.
|
|
74
|
+
// HTTPS BY DEFAULT (2026-09-15): no certificate named means the monitor's own, made here on
|
|
75
|
+
// first start under <data>/tls and kept, naming every address it can be reached on -- the
|
|
76
|
+
// bound hosts, this machine's addresses, its hostname, localhost -- and remade when it nears
|
|
77
|
+
// expiry or stops naming a bound host. BLOCKYARD_TLS=0 is plain HTTP.
|
|
78
|
+
if (cfg.__tlsAuto) {
|
|
79
|
+
const bound = (cfg.server.hosts ?? [cfg.server.host]).filter((h) => h && h !== '0.0.0.0' && h !== '::');
|
|
80
|
+
const sans = ['localhost', os.hostname(), '127.0.0.1', '::1', ...bound, ...localAddresses().map((a) => a.address)];
|
|
81
|
+
const made = ensureSelfSigned(path.join(cfg.store.dir, 'tls'), { sans, mustName: bound });
|
|
82
|
+
cfg.server.tls.cert = made.certFile; cfg.server.tls.key = made.keyFile;
|
|
83
|
+
if (made.made) app.log({ level: 'warn', msg: `made this monitor's own self-signed certificate (${made.why}) at ${made.certFile}, valid to ${new Date(made.notAfter).toISOString().slice(0, 10)}, for ${made.sans.join(', ')} -- browsers warn once per address; name your own with BLOCKYARD_TLS_CERT/KEY, or BLOCKYARD_TLS=0 behind a proxy that terminates TLS` });
|
|
84
|
+
inspectTls(cfg, cfg.server.tls); // fingerprint, expiry note, self-signed flag for the log line below
|
|
85
|
+
}
|
|
69
86
|
app.tls = Boolean(cfg.server.tls?.cert && cfg.server.tls?.key);
|
|
70
87
|
if (app.tls) {
|
|
71
88
|
app.tlsOptions = {
|
|
@@ -77,7 +94,8 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
|
77
94
|
app.log({ level: 'info', msg: 'TLS is on, so the session cookie is now Secure (a Secure cookie over plain HTTP is never sent, which looks like a login that will not stick)' });
|
|
78
95
|
}
|
|
79
96
|
app.log({ level: 'warn', msg: `TLS on (fingerprint ${String(cfg.server.tls.fingerprint).slice(0, 17)}…${cfg.server.tls.selfSigned ? ', self-signed: expect a browser warning the first time per address' : ''})${cfg.__tlsExpiring ? `; WARNING ${cfg.__tlsExpiring}` : ''}` });
|
|
80
|
-
} else if (cfg.auth.enabled) {
|
|
97
|
+
} else if (cfg.auth.enabled && !(cfg.server.hosts ?? [cfg.server.host]).every((h) => LOOPBACK.has(h))) {
|
|
98
|
+
// (not said for a loopback-only bind -- the default now -- where nothing crosses the LAN)
|
|
81
99
|
app.log({ level: 'warn', msg: 'serving HTTP, not HTTPS: the session cookie and every RPC reply cross the LAN in the clear. Either put a TLS terminator in front (then BLOCKYARD_SECURE_COOKIE=1), name server.tls.cert/key, or bind 127.0.0.1 and use an SSH tunnel -- see README, "TLS, or the lack of it".' });
|
|
82
100
|
}
|
|
83
101
|
app.scheme = app.tls ? 'https' : 'http';
|
|
@@ -128,7 +146,7 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
|
128
146
|
const where = (cfg.server.hosts ?? [cfg.server.host]).join(', ') || '(wildcard)';
|
|
129
147
|
app.log({
|
|
130
148
|
level: 'warn',
|
|
131
|
-
msg: `NO SIGN-IN (auth.enabled=false
|
|
149
|
+
msg: `NO SIGN-IN (auth.enabled=false -- accounts were switched off; they are on out of the box): anyone who can reach ${where}:${cfg.server.port} reads this monitor — charts, the event feed, peer and mempool detail, and the read-only RPC console — as role "viewer". Not open to them: user administration, the audit trail, password changes, and node writes (BLOCKYARD_AUTH=1, or drop the override, for accounts, roles, sessions and CSRF).`,
|
|
132
150
|
});
|
|
133
151
|
}
|
|
134
152
|
|
|
@@ -342,7 +360,10 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
|
342
360
|
}
|
|
343
361
|
const served = plan.bindable.map((h) => `${app.scheme}://${h}:${cfg.server.port}`);
|
|
344
362
|
app.log({ level: 'info', msg: `BlockYard ${VERSION} listening on ${served.join(' and ')}` });
|
|
345
|
-
|
|
363
|
+
const loopbackOnly = plan.bindable.every((h) => LOOPBACK.has(h));
|
|
364
|
+
if (loopbackOnly) {
|
|
365
|
+
app.log({ level: 'info', msg: 'bound to this machine only (the default): reach it from elsewhere over an SSH tunnel, or bind a LAN address with BLOCKYARD_BIND / server.hosts -- docs/INSTALL.md §7' });
|
|
366
|
+
} else if (!plan.bindable.includes('0.0.0.0') && !plan.bindable.includes('::')) {
|
|
346
367
|
const v4 = localAddresses().filter((a) => a.family === 'IPv4' && !plan.bindable.includes(a.address) && !a.internal);
|
|
347
368
|
const v6 = localAddresses().filter((a) => a.family === 'IPv6' && !plan.bindable.includes(a.address) && !a.internal).length;
|
|
348
369
|
// warn, not info: when loopback is not among the bound addresses, "connection
|
|
@@ -352,7 +373,7 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
|
352
373
|
app.log({
|
|
353
374
|
level: 'warn',
|
|
354
375
|
msg: `bound to specific interfaces -- NOT reachable on ${v4.map((a) => `${a.address} (${a.name})`).join(', ') || 'other IPv4 addresses'}`
|
|
355
|
-
+ `${v6 ? ` (plus ${v6} IPv6 address(es))` : ''}, and not on 127.0.0.1 either: use one of the addresses above from this machine too
|
|
376
|
+
+ `${v6 ? ` (plus ${v6} IPv6 address(es))` : ''}${plan.bindable.some((h) => LOOPBACK.has(h)) ? '.' : ', and not on 127.0.0.1 either: use one of the addresses above from this machine too.'}`,
|
|
356
377
|
});
|
|
357
378
|
if (plan.missing.length) {
|
|
358
379
|
app.log({ level: 'warn', msg: `skipped at boot: ${plan.missing.join(', ')} -- clients that would have used those addresses will get "connection refused", which is not a crash` });
|
|
@@ -443,13 +464,25 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
|
443
464
|
// when its RPC slows past the monitor's own threshold (rpc.slowLatencyMs, 5 s) the next file waits
|
|
444
465
|
// until it recovers (2026-09-14, the first Mac install: 18 s answers and 90 s timeouts while the
|
|
445
466
|
// build ran flat out -- which turned out to be gettxoutsetinfo, not the build, but the pacing stays)
|
|
467
|
+
let phase = null, phaseAt = Date.now(), lastFlag = 0, lastProgressAt = Date.now();
|
|
468
|
+
const flagLine = () => {
|
|
469
|
+
const pct = status.total ? Math.round((100 * status.done) / status.total) : 0;
|
|
470
|
+
const quiet = Date.now() - lastProgressAt;
|
|
471
|
+
return `the address index is being built: ${status.phase ?? 'starting'} ${Number(status.done ?? 0).toLocaleString()} of ${Number(status.total ?? 0).toLocaleString()} (${pct}%)${status.rows ? `, ${status.rows.toLocaleString()} rows so far` : ''}${status.eta ? `, about ${status.eta} left` : ''}${status.paused ? ' -- paused while the node\'s RPC is slow' : quiet > 120_000 ? ` -- no progress for ${Math.round(quiet / 60000)} min` : ''}`;
|
|
472
|
+
};
|
|
446
473
|
const pace = rpcPacer(m.rpc, { slowMs: cfg.rpc?.slowLatencyMs ?? 5000, onChange: (held, t) => {
|
|
447
474
|
status.paused = held;
|
|
475
|
+
lastFlag = Date.now(); m.flagQuality?.('address-index-building', flagLine(), 'info'); // on the flag the moment it changes
|
|
448
476
|
app.log({ level: 'info', msg: held ? `address index build: paused while the node's RPC is ${t.breakerOpen ? 'refused' : t.lastError ? 'failing' : `answering in ${((t.avgLatencyMs ?? 0) / 1000).toFixed(1)} s`}` : 'address index build: resumed' });
|
|
449
477
|
} });
|
|
450
478
|
const say = (text, severity = 'info') => { m.addEvent?.({ kind: 'index', severity, tag: 'index', ts: Date.now(), text }); app.log({ level: severity === 'warn' ? 'warn' : 'info', msg: text }); };
|
|
451
479
|
say(`address index: building ${dir} from ${m.id}'s block files with ${workers} workers -- the Overview shows the progress`);
|
|
452
|
-
|
|
480
|
+
// THE FLAG IS REWRITTEN ON A CLOCK, NOT ONLY ON PROGRESS (2026-09-15: "scan 5,720 of 5,721,
|
|
481
|
+
// about 1 s left (88m ago)" -- a build that had stopped moving showed its last good line,
|
|
482
|
+
// and a pause showed nothing at all until the next file finished). Every 30 s it says how
|
|
483
|
+
// long since anything happened, and a pause is on it the moment it begins.
|
|
484
|
+
const heartbeat = setInterval(() => { if (Date.now() - lastFlag > 25_000) { lastFlag = Date.now(); m.flagQuality?.('address-index-building', flagLine(), 'info'); } }, 30_000);
|
|
485
|
+
heartbeat.unref?.();
|
|
453
486
|
buildIndex({
|
|
454
487
|
rpc, blocksDir: path.join(m.cfg.datadir, 'blocks'), out: dir, workers, pace,
|
|
455
488
|
onProgress: (p) => {
|
|
@@ -457,13 +490,14 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
|
457
490
|
const elapsed = (Date.now() - phaseAt) / 1000;
|
|
458
491
|
const rate = elapsed > 0 && p.done > 0 ? p.done / elapsed : 0;
|
|
459
492
|
Object.assign(status, { phase: p.phase, done: p.done, total: p.total, rows: p.rows ?? status.rows, eta: rate > 0 && p.total > p.done ? HMS((p.total - p.done) / rate) : null });
|
|
493
|
+
lastProgressAt = Date.now();
|
|
460
494
|
if (Date.now() - lastFlag > 5000) {
|
|
461
495
|
lastFlag = Date.now();
|
|
462
|
-
|
|
463
|
-
m.flagQuality?.('address-index-building', `the address index is being built: ${p.phase} ${p.done.toLocaleString()} of ${p.total.toLocaleString()} (${pct}%)${p.rows ? `, ${p.rows.toLocaleString()} rows so far` : ''}${status.eta ? `, about ${status.eta} left` : ''}${status.paused ? ' -- paused while the node\'s RPC is slow' : ''}`, 'info');
|
|
496
|
+
m.flagQuality?.('address-index-building', flagLine(), 'info');
|
|
464
497
|
}
|
|
465
498
|
},
|
|
466
499
|
}).then((manifest) => {
|
|
500
|
+
clearInterval(heartbeat);
|
|
467
501
|
registerIndexBuild(dir, null);
|
|
468
502
|
m.indexBuild = null;
|
|
469
503
|
m.clearQuality?.('address-index-building');
|
|
@@ -471,6 +505,7 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
|
471
505
|
say(`address index built: ${Number(manifest.rows ?? 0).toLocaleString()} rows to block ${Number(manifest.tip?.height ?? 0).toLocaleString()} in ${mins} min -- address pages are live`);
|
|
472
506
|
try { follow(dir, m); } catch (err) { say(`address index ${dir}: built, but the follower could not start: ${err.message}`, 'warn'); }
|
|
473
507
|
}).catch((err) => {
|
|
508
|
+
clearInterval(heartbeat);
|
|
474
509
|
status.error = err.message;
|
|
475
510
|
registerIndexBuild(dir, null);
|
|
476
511
|
m.indexBuild = null;
|
|
@@ -593,6 +628,8 @@ function installShutdown(app) {
|
|
|
593
628
|
});
|
|
594
629
|
}
|
|
595
630
|
|
|
631
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost']);
|
|
632
|
+
|
|
596
633
|
export function banner(app) {
|
|
597
634
|
const lines = [];
|
|
598
635
|
const host = app.cfg.server.host;
|
|
@@ -608,7 +645,15 @@ export function banner(app) {
|
|
|
608
645
|
// Same content as the boot warning, in the banner: the first thing on screen
|
|
609
646
|
// after `npm start` should be the sentence about who can read the node.
|
|
610
647
|
lines.push(' login DISABLED — open to anyone who can reach the addresses above (role: viewer, read-only)');
|
|
611
|
-
lines.push(' user admin, the audit trail and node writes stay closed; BLOCKYARD_AUTH=1 turns accounts on');
|
|
648
|
+
lines.push(' user admin, the audit trail and node writes stay closed; BLOCKYARD_AUTH=1 turns accounts back on');
|
|
649
|
+
}
|
|
650
|
+
// LOOPBACK IS THE DEFAULT (2026-09-15): say how to reach it from anywhere else, because "it
|
|
651
|
+
// works on the box and nowhere else" is the first thing a new install runs into now
|
|
652
|
+
const hosts = app.cfg.server.hosts ?? [host];
|
|
653
|
+
if (hosts.every((h) => LOOPBACK.has(h))) {
|
|
654
|
+
const p = app.cfg.server.port;
|
|
655
|
+
lines.push(` reach this machine only. From elsewhere: ssh -L ${p}:127.0.0.1:${p} you@this-host, then ${app.scheme}://localhost:${p}`);
|
|
656
|
+
lines.push(' or bind a LAN address: BLOCKYARD_BIND=192.0.2.10 (or 0.0.0.0 for every interface) — docs/INSTALL.md §7');
|
|
612
657
|
}
|
|
613
658
|
lines.push(` nodes ${[...app.monitors.values()].map((m) => `${m.id} -> ${m.rpc.url}`).join(', ')}`);
|
|
614
659
|
if (app.cfg.server.allowCidrs.length) lines.push(` CIDRs ${app.cfg.server.allowCidrs.join(', ')}`);
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// A SELF-SIGNED CERTIFICATE, MADE HERE (operator, 2026-09-15: "Can we make the signing process
|
|
2
|
+
// part of the installer to generate per-user certs for their nodes?" -- then "make https the
|
|
3
|
+
// forced default"). Node can make a key pair and sign bytes but cannot write an X.509
|
|
4
|
+
// certificate, and this repository has no dependencies by decision, so the certificate is
|
|
5
|
+
// assembled by hand: a v3 TBSCertificate in DER, signed with ECDSA P-256 over SHA-256, wrapped
|
|
6
|
+
// in PEM. Every install gets its own key and its own certificate, valid for a little over two
|
|
7
|
+
// years, naming the addresses the monitor is reached on (subjectAltName), and the server makes
|
|
8
|
+
// one on first start when no certificate of the operator's own is configured.
|
|
9
|
+
//
|
|
10
|
+
// The shape is the one `openssl req -x509` produces: issuer == subject (CN=blockyard),
|
|
11
|
+
// basicConstraints CA:TRUE, keyUsage digitalSignature+keyCertSign, extKeyUsage serverAuth. A
|
|
12
|
+
// browser warns once per address, as with any self-signed certificate, and then remembers it.
|
|
13
|
+
// Node's own X509Certificate parses the result, which is what the tests hold it to.
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import net from 'node:net';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------- DER, the little that is needed
|
|
20
|
+
const len = (n) => {
|
|
21
|
+
if (n < 0x80) return Buffer.from([n]);
|
|
22
|
+
const b = [];
|
|
23
|
+
for (let x = n; x > 0; x = Math.floor(x / 256)) b.unshift(x & 0xff);
|
|
24
|
+
return Buffer.from([0x80 | b.length, ...b]);
|
|
25
|
+
};
|
|
26
|
+
const tlv = (tag, body) => Buffer.concat([Buffer.from([tag]), len(body.length), body]);
|
|
27
|
+
const seq = (...parts) => tlv(0x30, Buffer.concat(parts));
|
|
28
|
+
const set = (...parts) => tlv(0x31, Buffer.concat(parts));
|
|
29
|
+
const int = (v) => {
|
|
30
|
+
let b = Buffer.isBuffer(v) ? Buffer.from(v) : Buffer.from([v]);
|
|
31
|
+
while (b.length > 1 && b[0] === 0 && !(b[1] & 0x80)) b = b.subarray(1);
|
|
32
|
+
if (b[0] & 0x80) b = Buffer.concat([Buffer.from([0]), b]);
|
|
33
|
+
return tlv(0x02, b);
|
|
34
|
+
};
|
|
35
|
+
const bool = (v) => tlv(0x01, Buffer.from([v ? 0xff : 0]));
|
|
36
|
+
// (arcs as an array, not dotted text: the address-hygiene test reads a four-arc OID as an IPv4 address)
|
|
37
|
+
const oid = (arcs) => {
|
|
38
|
+
const p = Array.isArray(arcs) ? arcs : String(arcs).split('.').map(Number);
|
|
39
|
+
const out = [40 * p[0] + p[1]];
|
|
40
|
+
for (const v of p.slice(2)) {
|
|
41
|
+
const s = [];
|
|
42
|
+
let x = v;
|
|
43
|
+
do { s.unshift(x & 0x7f); x = Math.floor(x / 128); } while (x > 0);
|
|
44
|
+
for (let i = 0; i < s.length - 1; i++) s[i] |= 0x80;
|
|
45
|
+
out.push(...s);
|
|
46
|
+
}
|
|
47
|
+
return tlv(0x06, Buffer.from(out));
|
|
48
|
+
};
|
|
49
|
+
const utf8 = (s) => tlv(0x0c, Buffer.from(s, 'utf8'));
|
|
50
|
+
const octet = (b) => tlv(0x04, b);
|
|
51
|
+
const bits = (b, unused = 0) => tlv(0x03, Buffer.concat([Buffer.from([unused]), b]));
|
|
52
|
+
const utc = (d) => {
|
|
53
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
54
|
+
return tlv(0x17, Buffer.from(`${p(d.getUTCFullYear() % 100)}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}Z`, 'ascii'));
|
|
55
|
+
};
|
|
56
|
+
const ctx = (n, body, constructed = true) => tlv((constructed ? 0xa0 : 0x80) | n, body);
|
|
57
|
+
|
|
58
|
+
// an IP address as the bytes subjectAltName wants: four for v4, sixteen for v6
|
|
59
|
+
export function ipBytes(addr) {
|
|
60
|
+
const kind = net.isIP(addr);
|
|
61
|
+
if (kind === 4) return Buffer.from(addr.split('.').map(Number));
|
|
62
|
+
if (kind !== 6) return null;
|
|
63
|
+
let a = addr;
|
|
64
|
+
// an embedded v4 tail (::ffff:192.0.2.1) becomes its two hextets
|
|
65
|
+
const m = a.match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/);
|
|
66
|
+
if (m) { const b = m[2].split('.').map(Number); a = `${m[1]}${((b[0] << 8) | b[1]).toString(16)}:${((b[2] << 8) | b[3]).toString(16)}`; }
|
|
67
|
+
const [head, tail = ''] = a.split('::');
|
|
68
|
+
const hs = head ? head.split(':') : [], ts = tail ? tail.split(':') : [];
|
|
69
|
+
const fill = a.includes('::') ? 8 - hs.length - ts.length : 0;
|
|
70
|
+
const hex = [...hs, ...Array(Math.max(0, fill)).fill('0'), ...ts];
|
|
71
|
+
if (hex.length !== 8) return null;
|
|
72
|
+
const out = Buffer.alloc(16);
|
|
73
|
+
hex.forEach((h, i) => out.writeUInt16BE(parseInt(h, 16), i * 2));
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const pem = (label, der) => `-----BEGIN ${label}-----\n${der.toString('base64').replace(/(.{64})/g, '$1\n').replace(/\n$/, '')}\n-----END ${label}-----\n`;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Make a self-signed certificate and its private key.
|
|
81
|
+
* @param {object} [o]
|
|
82
|
+
* @param {string} [o.cn='blockyard'] the common name (issuer and subject alike)
|
|
83
|
+
* @param {string[]} [o.sans=[]] addresses and names the certificate is valid for
|
|
84
|
+
* @param {number} [o.days=825] validity, from `now`
|
|
85
|
+
* @param {number} [o.now=Date.now()]
|
|
86
|
+
* @returns {{ cert: string, key: string, fingerprint: string, notAfter: number, sans: string[] }}
|
|
87
|
+
*/
|
|
88
|
+
export function makeSelfSigned({ cn = 'blockyard', sans = [], days = 825, now = Date.now() } = {}) {
|
|
89
|
+
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
|
90
|
+
const spki = publicKey.export({ type: 'spki', format: 'der' });
|
|
91
|
+
const ECDSA_SHA256 = oid([1, 2, 840, 10045, 4, 3, 2]);
|
|
92
|
+
const name = seq(set(seq(oid([2, 5, 4, 3]), utf8(cn))));
|
|
93
|
+
const notBefore = new Date(now - 5 * 60 * 1000); // five minutes of clock skew
|
|
94
|
+
const notAfter = new Date(now + days * 86_400_000);
|
|
95
|
+
const serial = crypto.randomBytes(16); serial[0] &= 0x7f; // positive, 128 bits
|
|
96
|
+
// the names: unique, addresses as bytes, everything else as a DNS name
|
|
97
|
+
const uniq = [...new Set(sans.map((s) => String(s).trim()).filter(Boolean))];
|
|
98
|
+
// [2] dNSName and [7] iPAddress are IMPLICIT: the tag replaces the string's own, so the body is
|
|
99
|
+
// the bare bytes (an IA5String TLV inside read back as '"\u0016\u0009localhost"')
|
|
100
|
+
const names = uniq.map((s) => { const ip = ipBytes(s); return ip ? ctx(7, ip, false) : ctx(2, Buffer.from(s, 'ascii'), false); });
|
|
101
|
+
const ext = (id, critical, body) => seq(oid(id), ...(critical ? [bool(true)] : []), octet(body));
|
|
102
|
+
const extensions = [
|
|
103
|
+
ext([2, 5, 29, 19], true, seq(bool(true))), // basicConstraints CA:TRUE
|
|
104
|
+
ext([2, 5, 29, 15], true, bits(Buffer.from([0x84]), 2)), // keyUsage: digitalSignature, keyCertSign
|
|
105
|
+
ext([2, 5, 29, 37], false, seq(oid([1, 3, 6, 1, 5, 5, 7, 3, 1]))), // extKeyUsage: serverAuth
|
|
106
|
+
...(names.length ? [ext([2, 5, 29, 17], false, seq(...names))] : []), // subjectAltName
|
|
107
|
+
];
|
|
108
|
+
const tbs = seq(
|
|
109
|
+
ctx(0, int(2)), // version 3
|
|
110
|
+
int(serial),
|
|
111
|
+
seq(ECDSA_SHA256),
|
|
112
|
+
name, // issuer
|
|
113
|
+
seq(utc(notBefore), utc(notAfter)),
|
|
114
|
+
name, // subject: the same, which is what "self-signed" means
|
|
115
|
+
spki,
|
|
116
|
+
ctx(3, seq(...extensions)),
|
|
117
|
+
);
|
|
118
|
+
const signature = crypto.sign('sha256', tbs, { key: privateKey, dsaEncoding: 'der' });
|
|
119
|
+
const cert = pem('CERTIFICATE', seq(tbs, seq(ECDSA_SHA256), bits(signature)));
|
|
120
|
+
const key = privateKey.export({ type: 'pkcs8', format: 'pem' });
|
|
121
|
+
const x = new crypto.X509Certificate(cert);
|
|
122
|
+
return { cert, key, fingerprint: x.fingerprint256, notAfter: Date.parse(x.validTo), sans: uniq };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A name as it compares: an IP by its bytes (Node prints v6 expanded and upper-case), a DNS name lower-case. */
|
|
126
|
+
export const canonName = (s) => { const b = ipBytes(String(s).trim()); return b ? `ip:${b.toString('hex')}` : String(s).trim().toLowerCase(); };
|
|
127
|
+
/** The names a certificate carries, canonical (canonName), IPs and DNS names alike. */
|
|
128
|
+
export function certNames(certPem) {
|
|
129
|
+
const x = new crypto.X509Certificate(certPem);
|
|
130
|
+
return (x.subjectAltName ?? '').split(',').map((s) => s.trim().replace(/^(DNS|IP Address):/, '')).filter(Boolean).map(canonName);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The monitor's own certificate under `dir`: made on first use, kept after that, remade when it
|
|
135
|
+
* is within a fortnight of expiry or no longer names one of `mustName`. Returns the files and
|
|
136
|
+
* whether anything was written.
|
|
137
|
+
*/
|
|
138
|
+
export function ensureSelfSigned(dir, { sans = [], mustName = [], days = 825, now = Date.now(), force = false } = {}) {
|
|
139
|
+
const certFile = path.join(dir, 'cert.pem'), keyFile = path.join(dir, 'key.pem');
|
|
140
|
+
let why = null;
|
|
141
|
+
if (force) why = 'asked to';
|
|
142
|
+
else if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) why = 'none yet';
|
|
143
|
+
else {
|
|
144
|
+
try {
|
|
145
|
+
const x = new crypto.X509Certificate(fs.readFileSync(certFile, 'utf8'));
|
|
146
|
+
const have = certNames(fs.readFileSync(certFile, 'utf8'));
|
|
147
|
+
const missing = mustName.filter((n) => !have.includes(canonName(n)));
|
|
148
|
+
if (Date.parse(x.validTo) - now < 14 * 86_400_000) why = `it expires ${x.validTo}`;
|
|
149
|
+
else if (missing.length) why = `it does not name ${missing.join(', ')}`;
|
|
150
|
+
else if (!x.checkPrivateKey(crypto.createPrivateKey(fs.readFileSync(keyFile, 'utf8')))) why = 'the key does not match it';
|
|
151
|
+
} catch (err) { why = `it cannot be read (${err.message})`; }
|
|
152
|
+
}
|
|
153
|
+
if (!why) return { certFile, keyFile, made: false, why: null };
|
|
154
|
+
const made = makeSelfSigned({ sans: [...new Set([...sans, ...mustName])], days, now });
|
|
155
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
156
|
+
const write = (file, text) => { const tmp = `${file}.tmp`; fs.writeFileSync(tmp, text, { mode: 0o600 }); fs.renameSync(tmp, file); };
|
|
157
|
+
write(keyFile, made.key);
|
|
158
|
+
write(certFile, made.cert);
|
|
159
|
+
return { certFile, keyFile, made: true, why, fingerprint: made.fingerprint, notAfter: made.notAfter, sans: made.sans };
|
|
160
|
+
}
|