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.
Files changed (58) hide show
  1. package/CHANGELOG.md +251 -1
  2. package/README.md +42 -23
  3. package/bin/blockyard.js +2 -1
  4. package/docs/API.md +16 -14
  5. package/docs/ARCHITECTURE.md +92 -5
  6. package/docs/CONFIGURATION.md +33 -26
  7. package/docs/GETTING-STARTED.md +5 -2
  8. package/docs/INSTALL.md +90 -33
  9. package/docs/MEASUREMENTS.md +147 -0
  10. package/docs/SECURITY.md +32 -15
  11. package/docs/TROUBLESHOOTING.md +35 -1
  12. package/docs/USER-GUIDE.md +266 -26
  13. package/package.json +1 -1
  14. package/public/404.html +1 -1
  15. package/public/css/app.css +306 -82
  16. package/public/donate-qr.png +0 -0
  17. package/public/index.html +295 -103
  18. package/public/js/agents.js +228 -51
  19. package/public/js/app.js +82 -8
  20. package/public/js/blockscene3d.js +179 -27
  21. package/public/js/charts.js +21 -21
  22. package/public/js/depthchart.js +31 -27
  23. package/public/js/details3d.js +1456 -71
  24. package/public/js/doom.js +31 -0
  25. package/public/js/dosaudio.js +48 -0
  26. package/public/js/dosgame.js +389 -0
  27. package/public/js/dosio.js +186 -0
  28. package/public/js/dospc.js +1353 -0
  29. package/public/js/dosworker.js +196 -0
  30. package/public/js/login.js +5 -0
  31. package/public/js/markets.js +46 -8
  32. package/public/js/mining.js +310 -32
  33. package/public/js/panels.js +14 -10
  34. package/public/js/pricechart.js +14 -13
  35. package/public/js/quake.js +20 -0
  36. package/public/js/settings.js +103 -21
  37. package/public/js/soundcard.js +459 -0
  38. package/public/js/theme.js +235 -0
  39. package/public/js/wolf3d.js +22 -0
  40. package/public/js/x86.js +1978 -0
  41. package/scripts/donate-qr.py +12 -9
  42. package/scripts/dos-bench.js +56 -0
  43. package/scripts/setup.js +34 -12
  44. package/scripts/shots.mjs +6 -0
  45. package/scripts/smoke.sh +1 -1
  46. package/scripts/tls.js +31 -0
  47. package/server/chain/index/build.js +21 -4
  48. package/server/collect/monitor.js +30 -1
  49. package/server/collect/network.js +295 -0
  50. package/server/config.js +46 -22
  51. package/server/http/api.js +49 -5
  52. package/server/http/games.js +77 -0
  53. package/server/http/server.js +8 -0
  54. package/server/main.js +53 -8
  55. package/server/tls/selfsigned.js +160 -0
  56. package/systemd/blockyard.service +7 -5
  57. package/docs/PRIVATE-LEADERBOARD.md +0 -230
  58. 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
- // Multi-user means the LAN has to reach it, so it binds broadly by default.
31
- // Set BLOCKYARD_BIND=127.0.0.1 to keep it on this machine only.
32
- host: '0.0.0.0',
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 stays opt-in because this box is a LAN monitor whose certificate has no
41
- // issuer: a self-signed cert produces a browser warning on every address change,
42
- // and the alternative already documented (an SSH tunnel to 127.0.0.1, or a
43
- // reverse proxy that owns the cert) is better on a machine you control.
44
- // What was missing until 2026-09-09 was the option at all -- serving a session
45
- // cookie and every RPC reply over plain HTTP on a LAN is not a gap you get to
46
- // call "documented, therefore fine".
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
- cert: null, // PEM; BLOCKYARD_TLS_CERT
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
- // OPEN BY DEFAULT, like a block explorer: anyone who can reach the listen
197
- // addresses reads the dashboard with no account. This is a posture decision, not
198
- // a convenience -- see README "Open by default" and the boot warning, which names
199
- // the addresses this leaves readable.
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
- // Set BLOCKYARD_AUTH=1 (or auth.enabled in config/local.json) for accounts, roles,
211
- // sessions, CSRF and the audit trail-by-user.
212
- enabled: false,
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 turns it off.
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 = Boolean(tls.cert || tls.key);
497
- if (!cfg.tls) return;
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 ?? '0.0.0.0');
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
@@ -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: 'market data is off on this monitor (BLOCKYARD_MARKETS=0 or markets.enabled=false)' };
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: 'market data is off on this monitor (BLOCKYARD_MARKETS=0 or markets.enabled=false)' };
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
+ }
@@ -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, the default): 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 (set BLOCKYARD_AUTH=1 for accounts, roles, sessions and CSRF).`,
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
- if (!plan.bindable.includes('0.0.0.0') && !plan.bindable.includes('::')) {
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
- let phase = null, phaseAt = Date.now(), lastFlag = 0;
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
- const pct = p.total ? Math.round((100 * p.done) / p.total) : 0;
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
+ }