blockyard 0.0.9 → 0.1.1
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 +359 -1
- package/README.md +48 -27
- package/SECURITY.md +2 -2
- package/bin/blockyard.js +2 -1
- package/docs/API.md +17 -15
- package/docs/ARCHITECTURE.md +128 -10
- package/docs/CONFIGURATION.md +39 -30
- package/docs/DEFECTS.md +4 -1
- package/docs/GETTING-STARTED.md +18 -8
- package/docs/INSTALL.md +97 -37
- package/docs/MEASUREMENTS.md +147 -0
- package/docs/PLAN-SCORCHED-YARD.md +456 -0
- package/docs/PLAN-SKIES.md +142 -0
- package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
- package/docs/SECURITY.md +58 -22
- package/docs/TROUBLESHOOTING.md +44 -5
- package/docs/USER-GUIDE.md +505 -35
- package/package.json +4 -2
- package/public/404.html +1 -1
- package/public/css/app.css +393 -82
- package/public/donate-qr.png +0 -0
- package/public/index.html +353 -109
- package/public/js/agents.js +228 -51
- package/public/js/app.js +131 -16
- package/public/js/blockanoid.js +15 -7
- package/public/js/blockout.js +15 -7
- package/public/js/blockscene3d.js +230 -38
- package/public/js/charts.js +21 -21
- package/public/js/depthchart.js +31 -27
- package/public/js/details3d.js +1481 -73
- 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/explorer.js +7 -1
- package/public/js/livingsky.js +494 -0
- package/public/js/login.js +8 -2
- package/public/js/markets.js +46 -8
- package/public/js/mining.js +314 -36
- package/public/js/panels.js +41 -28
- package/public/js/pricechart.js +14 -13
- package/public/js/quake.js +20 -0
- package/public/js/safenext.js +14 -0
- package/public/js/scorched.js +1051 -0
- package/public/js/scorchedai.js +227 -0
- package/public/js/scorchedair.js +286 -0
- package/public/js/scorchedfx.js +376 -0
- package/public/js/scorchedshop.js +105 -0
- package/public/js/scorchedwind.js +69 -0
- package/public/js/scorchedyard.js +1338 -0
- package/public/js/settings.js +368 -100
- package/public/js/soundcard.js +459 -0
- package/public/js/tetrust.js +15 -6
- package/public/js/tetsound.js +35 -5
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/scripts/check.js +46 -0
- package/scripts/donate-qr.py +12 -9
- package/scripts/dos-bench.js +56 -0
- package/scripts/index-build.js +9 -2
- package/scripts/pool-map.js +152 -36
- package/scripts/setup.js +142 -22
- package/scripts/shots.mjs +27 -0
- package/scripts/smoke.sh +7 -6
- package/scripts/tls.js +31 -0
- package/scripts/ui.js +4 -2
- package/server/auth/sessions.js +33 -13
- package/server/chain/blockfile.js +64 -5
- package/server/chain/index/build.js +451 -58
- package/server/chain/index/heights.js +29 -3
- package/server/chain/index/live.js +13 -7
- package/server/chain/index/rows.js +6 -1
- package/server/chain/index/store.js +28 -5
- package/server/chain/index/worker.js +23 -11
- package/server/collect/logparse.js +65 -18
- package/server/collect/markets.js +76 -7
- package/server/collect/mining.js +32 -0
- package/server/collect/monitor.js +54 -12
- package/server/collect/network.js +305 -0
- package/server/config.js +53 -22
- package/server/http/api.js +119 -18
- package/server/http/games.js +77 -0
- package/server/http/server.js +30 -5
- package/server/http/sse.js +53 -7
- package/server/main.js +66 -11
- package/server/rpc/allowlist.js +26 -0
- package/server/rpc/client.js +30 -2
- package/server/store/audit.js +6 -1
- package/server/store/history.js +19 -3
- package/server/store/ledger.js +15 -4
- package/server/tls/selfsigned.js +160 -0
- package/systemd/blockyard.service +41 -8
- package/docs/PRIVATE-LEADERBOARD.md +0 -230
- package/docs/STATE-2026-09-09.md +0 -200
package/server/http/sse.js
CHANGED
|
@@ -10,6 +10,20 @@
|
|
|
10
10
|
// are coalesced per client -- at most one snapshot in flight, latest wins -- and
|
|
11
11
|
// the event feed is batched. A client that cannot keep up sees a coarser stream,
|
|
12
12
|
// never an unbounded queue that takes the process down.
|
|
13
|
+
//
|
|
14
|
+
// THAT LAST SENTENCE WAS NOT TRUE UNTIL 2026-09-16 (audit H1). Coalescing only helps if nothing is
|
|
15
|
+
// written while the socket is full, and flush() wrote every pending frame regardless: `write()`
|
|
16
|
+
// noted `backpressured` and nothing ever read it. The reaper removed a client only if it had been
|
|
17
|
+
// sent zero bytes, which no client ever has. So a reader that stopped reading kept every snapshot
|
|
18
|
+
// queued in Node's socket buffer: 400 stalled connections took a test instance from 73 MB to 1.5 GB
|
|
19
|
+
// in three minutes. Now a backpressured client is sent nothing (its pending snapshot keeps being
|
|
20
|
+
// replaced, latest wins) until the socket drains; one whose buffer passes a ceiling, or that stays
|
|
21
|
+
// blocked past a deadline, is dropped; and one address holds a bounded number of streams.
|
|
22
|
+
export const SSE_LIMITS = Object.freeze({
|
|
23
|
+
maxBufferedBytes: 4 * 1024 * 1024, // well above one snapshot; a client this far behind is not reading
|
|
24
|
+
maxBlockedMs: 60_000, // blocked for a minute: an EventSource reconnects on its own
|
|
25
|
+
maxPerKey: 16, // streams per address (open mode) or per account
|
|
26
|
+
});
|
|
13
27
|
class Client {
|
|
14
28
|
constructor(res, user, id) {
|
|
15
29
|
this.res = res;
|
|
@@ -25,13 +39,17 @@ class Client {
|
|
|
25
39
|
this.alive = true;
|
|
26
40
|
this.lastWriteAt = Date.now();
|
|
27
41
|
this.nodeId = null;
|
|
42
|
+
this.key = null;
|
|
43
|
+
this.backpressured = false;
|
|
44
|
+
this.blockedSince = 0;
|
|
28
45
|
}
|
|
29
46
|
|
|
30
47
|
get dead() { return !this.alive || this.res.writableEnded || this.res.destroyed; }
|
|
31
48
|
}
|
|
32
49
|
|
|
33
50
|
export class StreamHub {
|
|
34
|
-
constructor({ log = () => {} } = {}) {
|
|
51
|
+
constructor({ log = () => {}, limits = {} } = {}) {
|
|
52
|
+
this.limits = { ...SSE_LIMITS, ...limits };
|
|
35
53
|
this.clients = new Set();
|
|
36
54
|
this.log = log;
|
|
37
55
|
this.seq = 0;
|
|
@@ -39,10 +57,18 @@ export class StreamHub {
|
|
|
39
57
|
this.timer = null;
|
|
40
58
|
}
|
|
41
59
|
|
|
42
|
-
|
|
60
|
+
/** How many open streams share this key (an address in open mode, an account otherwise). */
|
|
61
|
+
countFor(key) {
|
|
62
|
+
let n = 0;
|
|
63
|
+
for (const c of this.clients) if (c.key === key && !c.dead) n += 1;
|
|
64
|
+
return n;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
add(req, res, { user = null, nodeId = null, key = null } = {}) {
|
|
43
68
|
const id = ++this.seq;
|
|
44
69
|
const client = new Client(res, user, id);
|
|
45
70
|
client.nodeId = nodeId;
|
|
71
|
+
client.key = key;
|
|
46
72
|
res.writeHead(200, {
|
|
47
73
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
48
74
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
@@ -55,6 +81,11 @@ export class StreamHub {
|
|
|
55
81
|
req.on('close', close);
|
|
56
82
|
req.on('aborted', close);
|
|
57
83
|
res.on('error', close);
|
|
84
|
+
res.on('drain', () => {
|
|
85
|
+
client.backpressured = false;
|
|
86
|
+
client.blockedSince = 0;
|
|
87
|
+
this.flush();
|
|
88
|
+
});
|
|
58
89
|
if (!this.timer) this.startHeartbeat();
|
|
59
90
|
return client;
|
|
60
91
|
}
|
|
@@ -70,24 +101,35 @@ export class StreamHub {
|
|
|
70
101
|
const tick = () => {
|
|
71
102
|
for (const c of [...this.clients]) {
|
|
72
103
|
if (c.dead) { this.remove(c); continue; }
|
|
104
|
+
if (this.stalled(c)) continue;
|
|
73
105
|
// 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
|
-
}
|
|
106
|
+
// lets us notice a dead socket on our own terms. Never onto a full socket.
|
|
107
|
+
if (!c.backpressured) this.write(c, ': ping\n\n');
|
|
78
108
|
}
|
|
79
109
|
};
|
|
80
110
|
this.timer = setInterval(tick, this.heartbeatMs);
|
|
81
111
|
this.timer.unref?.();
|
|
82
112
|
}
|
|
83
113
|
|
|
114
|
+
/** Drop a client that is too far behind, or blocked too long; true if it was dropped. */
|
|
115
|
+
stalled(client) {
|
|
116
|
+
const buffered = client.res.writableLength ?? 0;
|
|
117
|
+
const blockedFor = client.backpressured && client.blockedSince ? Date.now() - client.blockedSince : 0;
|
|
118
|
+
if (buffered <= this.limits.maxBufferedBytes && blockedFor <= this.limits.maxBlockedMs) return false;
|
|
119
|
+
this.log({ level: 'warn', msg: `sse #${client.id} dropped: not reading (${Math.round(buffered / 1024)} KB buffered, blocked ${Math.round(blockedFor / 1000)}s)` });
|
|
120
|
+
this.remove(client);
|
|
121
|
+
try { client.res.destroy(); } catch { /* already gone */ }
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
84
125
|
write(client, frame) {
|
|
85
126
|
if (client.dead) return false;
|
|
86
127
|
try {
|
|
87
128
|
const ok = client.res.write(frame);
|
|
88
129
|
client.bytes += frame.length;
|
|
89
130
|
client.lastWriteAt = Date.now();
|
|
90
|
-
if (!ok) client.backpressured = true;
|
|
131
|
+
if (!ok && !client.backpressured) { client.backpressured = true; client.blockedSince = Date.now(); }
|
|
132
|
+
if (!ok) this.stalled(client);
|
|
91
133
|
return ok;
|
|
92
134
|
} catch (err) {
|
|
93
135
|
this.log({ level: 'debug', msg: `sse write failed: ${err.message}` });
|
|
@@ -142,6 +184,9 @@ export class StreamHub {
|
|
|
142
184
|
this.flushing = false;
|
|
143
185
|
for (const c of [...this.clients]) {
|
|
144
186
|
if (c.dead) { this.remove(c); continue; }
|
|
187
|
+
// A full socket gets nothing more: what is pending stays pending (and is replaced by
|
|
188
|
+
// newer state) until 'drain' flushes again.
|
|
189
|
+
if (c.backpressured) { this.stalled(c); continue; }
|
|
145
190
|
if (c.pendingSnapshot !== null) {
|
|
146
191
|
const snap = c.pendingSnapshot;
|
|
147
192
|
c.pendingSnapshot = null;
|
|
@@ -166,6 +211,7 @@ export class StreamHub {
|
|
|
166
211
|
perClient: [...this.clients].map((c) => ({
|
|
167
212
|
id: c.id, user: c.user?.username ?? null, seconds: Math.round((Date.now() - c.connectedAt) / 1000),
|
|
168
213
|
kb: Math.round(c.bytes / 1024), dropped: { ...c.dropped }, node: c.nodeId,
|
|
214
|
+
backpressured: c.backpressured, bufferedKb: Math.round((c.res.writableLength ?? 0) / 1024),
|
|
169
215
|
})),
|
|
170
216
|
};
|
|
171
217
|
}
|
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
|
|
@@ -23,8 +26,9 @@ const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf
|
|
|
23
26
|
|
|
24
27
|
export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
25
28
|
const cfg = loadConfig({ configFile });
|
|
26
|
-
|
|
27
|
-
await fsp.mkdir(cfg.
|
|
29
|
+
// owner-only where this creates them (audit 2026-09-16, L10); an existing directory keeps its mode
|
|
30
|
+
await fsp.mkdir(cfg.store.dir, { recursive: true, mode: 0o700 });
|
|
31
|
+
await fsp.mkdir(cfg.auth.dataDir, { recursive: true, mode: 0o700 });
|
|
28
32
|
|
|
29
33
|
const app = {
|
|
30
34
|
version: VERSION,
|
|
@@ -49,6 +53,8 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
|
49
53
|
),
|
|
50
54
|
startedAt: Date.now(),
|
|
51
55
|
publicDir: path.join(ROOT, 'public'),
|
|
56
|
+
// the DOS Diversions' game files, a directory a game, where the operator put them (http/games.js)
|
|
57
|
+
gamesDir: path.join(ROOT, 'games'),
|
|
52
58
|
monitors: new Map(),
|
|
53
59
|
stateSeq: 0,
|
|
54
60
|
rssStart: process.memoryUsage().rss,
|
|
@@ -66,6 +72,18 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
|
66
72
|
// Decided here, before any listener exists, and the cookie follows it: a Secure
|
|
67
73
|
// cookie on an HTTP listener is a cookie the browser will not send, which reads
|
|
68
74
|
// as "login keeps failing" -- so the two settings must not be independently set.
|
|
75
|
+
// HTTPS BY DEFAULT (2026-09-15): no certificate named means the monitor's own, made here on
|
|
76
|
+
// first start under <data>/tls and kept, naming every address it can be reached on -- the
|
|
77
|
+
// bound hosts, this machine's addresses, its hostname, localhost -- and remade when it nears
|
|
78
|
+
// expiry or stops naming a bound host. BLOCKYARD_TLS=0 is plain HTTP.
|
|
79
|
+
if (cfg.__tlsAuto) {
|
|
80
|
+
const bound = (cfg.server.hosts ?? [cfg.server.host]).filter((h) => h && h !== '0.0.0.0' && h !== '::');
|
|
81
|
+
const sans = ['localhost', os.hostname(), '127.0.0.1', '::1', ...bound, ...localAddresses().map((a) => a.address)];
|
|
82
|
+
const made = ensureSelfSigned(path.join(cfg.store.dir, 'tls'), { sans, mustName: bound });
|
|
83
|
+
cfg.server.tls.cert = made.certFile; cfg.server.tls.key = made.keyFile;
|
|
84
|
+
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` });
|
|
85
|
+
inspectTls(cfg, cfg.server.tls); // fingerprint, expiry note, self-signed flag for the log line below
|
|
86
|
+
}
|
|
69
87
|
app.tls = Boolean(cfg.server.tls?.cert && cfg.server.tls?.key);
|
|
70
88
|
if (app.tls) {
|
|
71
89
|
app.tlsOptions = {
|
|
@@ -77,7 +95,8 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
|
77
95
|
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
96
|
}
|
|
79
97
|
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) {
|
|
98
|
+
} else if (cfg.auth.enabled && !(cfg.server.hosts ?? [cfg.server.host]).every((h) => LOOPBACK.has(h))) {
|
|
99
|
+
// (not said for a loopback-only bind -- the default now -- where nothing crosses the LAN)
|
|
81
100
|
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
101
|
}
|
|
83
102
|
app.scheme = app.tls ? 'https' : 'http';
|
|
@@ -128,7 +147,7 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
|
128
147
|
const where = (cfg.server.hosts ?? [cfg.server.host]).join(', ') || '(wildcard)';
|
|
129
148
|
app.log({
|
|
130
149
|
level: 'warn',
|
|
131
|
-
msg: `NO SIGN-IN (auth.enabled=false
|
|
150
|
+
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
151
|
});
|
|
133
152
|
}
|
|
134
153
|
|
|
@@ -251,7 +270,12 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
|
251
270
|
// which are not secrets, and an audit trail full of [redacted] where the useful fields were is
|
|
252
271
|
// its own kind of failure.
|
|
253
272
|
const SECRETISH = /pass(word|phrase)?|secret|cookie|token|priv(ate)?_?key|seed|mnemonic|authorization|credential/i;
|
|
273
|
+
// AND CLAMP BY LENGTH (audit 2026-09-16, M6). A caller-chosen string went into the trail whole:
|
|
274
|
+
// a 900 KB RPC method name, sixty times over, rotated every retained file and took the sign-ins and
|
|
275
|
+
// user changes with it. No field the trail needs is anywhere near this long.
|
|
276
|
+
const AUDIT_STRING_MAX = 1024;
|
|
254
277
|
const redact = (v, depth = 0) => {
|
|
278
|
+
if (typeof v === 'string' && v.length > AUDIT_STRING_MAX) return `${v.slice(0, AUDIT_STRING_MAX)}…[${v.length - AUDIT_STRING_MAX} more chars]`;
|
|
255
279
|
if (v == null || depth > 6) return v;
|
|
256
280
|
if (Array.isArray(v)) return v.map((x) => redact(x, depth + 1));
|
|
257
281
|
if (typeof v !== 'object') return v;
|
|
@@ -342,7 +366,10 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
|
342
366
|
}
|
|
343
367
|
const served = plan.bindable.map((h) => `${app.scheme}://${h}:${cfg.server.port}`);
|
|
344
368
|
app.log({ level: 'info', msg: `BlockYard ${VERSION} listening on ${served.join(' and ')}` });
|
|
345
|
-
|
|
369
|
+
const loopbackOnly = plan.bindable.every((h) => LOOPBACK.has(h));
|
|
370
|
+
if (loopbackOnly) {
|
|
371
|
+
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' });
|
|
372
|
+
} else if (!plan.bindable.includes('0.0.0.0') && !plan.bindable.includes('::')) {
|
|
346
373
|
const v4 = localAddresses().filter((a) => a.family === 'IPv4' && !plan.bindable.includes(a.address) && !a.internal);
|
|
347
374
|
const v6 = localAddresses().filter((a) => a.family === 'IPv6' && !plan.bindable.includes(a.address) && !a.internal).length;
|
|
348
375
|
// warn, not info: when loopback is not among the bound addresses, "connection
|
|
@@ -352,7 +379,7 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
|
352
379
|
app.log({
|
|
353
380
|
level: 'warn',
|
|
354
381
|
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
|
|
382
|
+
+ `${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
383
|
});
|
|
357
384
|
if (plan.missing.length) {
|
|
358
385
|
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,27 +470,44 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
|
443
470
|
// when its RPC slows past the monitor's own threshold (rpc.slowLatencyMs, 5 s) the next file waits
|
|
444
471
|
// until it recovers (2026-09-14, the first Mac install: 18 s answers and 90 s timeouts while the
|
|
445
472
|
// build ran flat out -- which turned out to be gettxoutsetinfo, not the build, but the pacing stays)
|
|
473
|
+
let phase = null, phaseAt = Date.now(), lastFlag = 0, lastProgressAt = Date.now();
|
|
474
|
+
const flagLine = () => {
|
|
475
|
+
const pct = status.total ? Math.round((100 * status.done) / status.total) : 0;
|
|
476
|
+
const quiet = Date.now() - lastProgressAt;
|
|
477
|
+
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` : ''}`;
|
|
478
|
+
};
|
|
446
479
|
const pace = rpcPacer(m.rpc, { slowMs: cfg.rpc?.slowLatencyMs ?? 5000, onChange: (held, t) => {
|
|
447
480
|
status.paused = held;
|
|
481
|
+
lastFlag = Date.now(); m.flagQuality?.('address-index-building', flagLine(), 'info'); // on the flag the moment it changes
|
|
448
482
|
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
483
|
} });
|
|
450
484
|
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
485
|
say(`address index: building ${dir} from ${m.id}'s block files with ${workers} workers -- the Overview shows the progress`);
|
|
452
|
-
|
|
486
|
+
// THE FLAG IS REWRITTEN ON A CLOCK, NOT ONLY ON PROGRESS (2026-09-15: "scan 5,720 of 5,721,
|
|
487
|
+
// about 1 s left (88m ago)" -- a build that had stopped moving showed its last good line,
|
|
488
|
+
// and a pause showed nothing at all until the next file finished). Every 30 s it says how
|
|
489
|
+
// long since anything happened, and a pause is on it the moment it begins.
|
|
490
|
+
const heartbeat = setInterval(() => { if (Date.now() - lastFlag > 25_000) { lastFlag = Date.now(); m.flagQuality?.('address-index-building', flagLine(), 'info'); } }, 30_000);
|
|
491
|
+
heartbeat.unref?.();
|
|
453
492
|
buildIndex({
|
|
454
493
|
rpc, blocksDir: path.join(m.cfg.datadir, 'blocks'), out: dir, workers, pace,
|
|
494
|
+
// AN INTERRUPTED BUILD RESUMES (build.js, THE BUILD JOURNAL): where it resumed, or why the
|
|
495
|
+
// unfinished work was thrown away, is an event like the start
|
|
496
|
+
log: (text) => say(text),
|
|
455
497
|
onProgress: (p) => {
|
|
456
498
|
if (p.phase !== phase) { phase = p.phase; phaseAt = Date.now(); }
|
|
457
499
|
const elapsed = (Date.now() - phaseAt) / 1000;
|
|
458
|
-
|
|
500
|
+
// a resumed build starts the phase part way (`from`): the rate is this run's, not the earlier one's
|
|
501
|
+
const rate = elapsed > 0 && p.done > (p.from ?? 0) ? (p.done - (p.from ?? 0)) / elapsed : 0;
|
|
459
502
|
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 });
|
|
503
|
+
lastProgressAt = Date.now();
|
|
460
504
|
if (Date.now() - lastFlag > 5000) {
|
|
461
505
|
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');
|
|
506
|
+
m.flagQuality?.('address-index-building', flagLine(), 'info');
|
|
464
507
|
}
|
|
465
508
|
},
|
|
466
509
|
}).then((manifest) => {
|
|
510
|
+
clearInterval(heartbeat);
|
|
467
511
|
registerIndexBuild(dir, null);
|
|
468
512
|
m.indexBuild = null;
|
|
469
513
|
m.clearQuality?.('address-index-building');
|
|
@@ -471,6 +515,7 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
|
471
515
|
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
516
|
try { follow(dir, m); } catch (err) { say(`address index ${dir}: built, but the follower could not start: ${err.message}`, 'warn'); }
|
|
473
517
|
}).catch((err) => {
|
|
518
|
+
clearInterval(heartbeat);
|
|
474
519
|
status.error = err.message;
|
|
475
520
|
registerIndexBuild(dir, null);
|
|
476
521
|
m.indexBuild = null;
|
|
@@ -593,6 +638,8 @@ function installShutdown(app) {
|
|
|
593
638
|
});
|
|
594
639
|
}
|
|
595
640
|
|
|
641
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost']);
|
|
642
|
+
|
|
596
643
|
export function banner(app) {
|
|
597
644
|
const lines = [];
|
|
598
645
|
const host = app.cfg.server.host;
|
|
@@ -608,7 +655,15 @@ export function banner(app) {
|
|
|
608
655
|
// Same content as the boot warning, in the banner: the first thing on screen
|
|
609
656
|
// after `npm start` should be the sentence about who can read the node.
|
|
610
657
|
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');
|
|
658
|
+
lines.push(' user admin, the audit trail and node writes stay closed; BLOCKYARD_AUTH=1 turns accounts back on');
|
|
659
|
+
}
|
|
660
|
+
// LOOPBACK IS THE DEFAULT (2026-09-15): say how to reach it from anywhere else, because "it
|
|
661
|
+
// works on the box and nowhere else" is the first thing a new install runs into now
|
|
662
|
+
const hosts = app.cfg.server.hosts ?? [host];
|
|
663
|
+
if (hosts.every((h) => LOOPBACK.has(h))) {
|
|
664
|
+
const p = app.cfg.server.port;
|
|
665
|
+
lines.push(` reach this machine only. From elsewhere: ssh -L ${p}:127.0.0.1:${p} you@this-host, then ${app.scheme}://localhost:${p}`);
|
|
666
|
+
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
667
|
}
|
|
613
668
|
lines.push(` nodes ${[...app.monitors.values()].map((m) => `${m.id} -> ${m.rpc.url}`).join(', ')}`);
|
|
614
669
|
if (app.cfg.server.allowCidrs.length) lines.push(` CIDRs ${app.cfg.server.allowCidrs.join(', ')}`);
|
package/server/rpc/allowlist.js
CHANGED
|
@@ -48,8 +48,33 @@ const ALLOW_PREFIXES = ['get', 'list', 'estimate', 'verify', 'estimat', 'help',
|
|
|
48
48
|
// transactions, which is a write in every way that matters.
|
|
49
49
|
const DENY_PREFIXES = ['generate', 'invalidate', 'reconsider', 'import', 'send', 'set', 'unload', 'load', 'sign'];
|
|
50
50
|
|
|
51
|
+
// EVERY WALLET RPC, READ-SHAPED OR NOT (audit 2026-09-16, M4). The `get` and `list` prefixes let
|
|
52
|
+
// `listdescriptors true` and `gethdkeys {"private":true}` through, and both return private keys
|
|
53
|
+
// from an unlocked wallet -- to anyone the console serves, which in open mode is anyone who can
|
|
54
|
+
// reach the port. The monitor has no use for a wallet, so the whole category is refused by name.
|
|
55
|
+
// The list is Bitcoin Core's own "== Wallet ==" section of `help` (Core 29/30, taken 2026-09-16),
|
|
56
|
+
// plus the legacy-wallet methods older nodes still answer.
|
|
57
|
+
export const WALLET_METHODS = new Set([
|
|
58
|
+
'abandontransaction', 'abortrescan', 'addhdkey', 'backupwallet', 'bumpfee', 'createwallet',
|
|
59
|
+
'createwalletdescriptor', 'encryptwallet', 'exportwatchonlywallet', 'getaddressesbylabel',
|
|
60
|
+
'getaddressinfo', 'getbalance', 'getbalances', 'gethdkeys', 'getnewaddress', 'getrawchangeaddress',
|
|
61
|
+
'getreceivedbyaddress', 'getreceivedbylabel', 'gettransaction', 'getwalletinfo', 'importdescriptors',
|
|
62
|
+
'importprunedfunds', 'keypoolrefill', 'listaddressgroupings', 'listdescriptors', 'listlabels',
|
|
63
|
+
'listlockunspent', 'listreceivedbyaddress', 'listreceivedbylabel', 'listsinceblock', 'listtransactions',
|
|
64
|
+
'listunspent', 'listwalletdir', 'listwallets', 'loadwallet', 'lockunspent', 'migratewallet',
|
|
65
|
+
'psbtbumpfee', 'removeprunedfunds', 'rescanblockchain', 'restorewallet', 'send', 'sendall', 'sendmany',
|
|
66
|
+
'sendtoaddress', 'setlabel', 'setwalletflag', 'signmessage', 'signrawtransactionwithwallet',
|
|
67
|
+
'simulaterawtransaction', 'unloadwallet', 'walletcreatefundedpsbt', 'walletdisplayaddress', 'walletlock',
|
|
68
|
+
'walletpassphrase', 'walletpassphrasechange', 'walletprocesspsbt',
|
|
69
|
+
// legacy (pre-descriptor) wallets
|
|
70
|
+
'dumpprivkey', 'dumpwallet', 'importprivkey', 'importaddress', 'importpubkey', 'importmulti',
|
|
71
|
+
'importwallet', 'sethdseed', 'upgradewallet', 'getunconfirmedbalance', 'listaccounts', 'getaccount',
|
|
72
|
+
'getaccountaddress', 'getaddressesbyaccount', 'getreceivedbyaccount', 'listreceivedbyaccount',
|
|
73
|
+
]);
|
|
74
|
+
|
|
51
75
|
export function classifyMethod(method) {
|
|
52
76
|
if (typeof method !== 'string' || !method) return { allowed: false, kind: 'unknown', reason: 'method name must be a string' };
|
|
77
|
+
if (WALLET_METHODS.has(method)) return { allowed: false, kind: 'wallet', reason: 'wallet RPCs are not exposed: some of them return private keys, and the monitor has no use for a wallet' };
|
|
53
78
|
if (DENY_EXACT.has(method)) return { allowed: false, kind: 'write-or-heavy', reason: 'this method mutates state or monopolises the node\'s single-threaded RPC server' };
|
|
54
79
|
for (const p of DENY_PREFIXES) if (method.startsWith(p) && !ALLOW_PREFIXES.includes(method)) return { allowed: false, kind: 'write', reason: `method starts with "${p}" and is treated as a mutation` };
|
|
55
80
|
if (method === 'help' || method === 'uptime' || method === 'stop') {
|
|
@@ -73,6 +98,7 @@ export const NODE_REFUSES = new Set([
|
|
|
73
98
|
export function allowlistSummary() {
|
|
74
99
|
return {
|
|
75
100
|
denyExactCount: DENY_EXACT.size,
|
|
101
|
+
walletDenied: WALLET_METHODS.size,
|
|
76
102
|
allowPrefixes: [...new Set(ALLOW_PREFIXES)].sort(),
|
|
77
103
|
denyPrefixes: [...new Set(DENY_PREFIXES)].sort(),
|
|
78
104
|
defaultDecision: 'deny',
|
package/server/rpc/client.js
CHANGED
|
@@ -34,6 +34,34 @@ import http from 'node:http';
|
|
|
34
34
|
import https from 'node:https';
|
|
35
35
|
import { resolveCookie } from '../config.js';
|
|
36
36
|
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A file path as it may be shown to a viewer (audit 2026-09-16, L11): its last two parts, which say
|
|
40
|
+
* which cookie or log it is (`main/.cookie`, `bitcoin/debug.log`) without the directories above --
|
|
41
|
+
* a home directory in them names the account the node runs as, and in open mode anyone who can
|
|
42
|
+
* reach the port reads these responses.
|
|
43
|
+
*/
|
|
44
|
+
export function shortPath(p) {
|
|
45
|
+
if (typeof p !== 'string' || !p) return p ?? null;
|
|
46
|
+
const parts = p.split(/[\\/]+/).filter(Boolean);
|
|
47
|
+
return parts.length <= 2 ? parts.join('/') : `…/${parts.slice(-2).join('/')}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The RPC URL as it may be shown to a viewer (audit 2026-09-16, L3): never with a username or
|
|
52
|
+
* password in it. `http://user:pass@host:8332` is a valid rpcUrl, and the endpoint is shown on the
|
|
53
|
+
* Node page and in the node picker's tooltip to everyone who can read the monitor.
|
|
54
|
+
*/
|
|
55
|
+
export function displayUrl(u) {
|
|
56
|
+
if (typeof u !== 'string' || !u) return u ?? null;
|
|
57
|
+
try {
|
|
58
|
+
const url = new URL(u);
|
|
59
|
+
if (!url.username && !url.password) return u;
|
|
60
|
+
url.username = ''; url.password = '';
|
|
61
|
+
return url.toString();
|
|
62
|
+
} catch { return u.replace(/\/\/[^@/]*@/, '//'); }
|
|
63
|
+
}
|
|
64
|
+
|
|
37
65
|
export class RpcError extends Error {
|
|
38
66
|
constructor(message, { code = null, httpStatus = null, kind = 'rpc' } = {}) {
|
|
39
67
|
super(message);
|
|
@@ -399,8 +427,8 @@ export class RpcClient {
|
|
|
399
427
|
telemetry() {
|
|
400
428
|
return {
|
|
401
429
|
nodeId: this.id,
|
|
402
|
-
url: this.node.rpcUrl,
|
|
403
|
-
cookieSource: this._cookieSource,
|
|
430
|
+
url: displayUrl(this.node.rpcUrl),
|
|
431
|
+
cookieSource: shortPath(this._cookieSource),
|
|
404
432
|
online: !!this.lastGoodAt && !this.lane.breakerOpen && (!this.lastError || (this.lastGoodAt > this.lastError.at)),
|
|
405
433
|
lastGoodAt: this.lastGoodAt,
|
|
406
434
|
lastError: this.lastError,
|
package/server/store/audit.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import fsp from 'node:fs/promises';
|
|
15
15
|
import fs from 'node:fs';
|
|
16
16
|
import path from 'node:path';
|
|
17
|
-
import { appendJsonl } from './history.js';
|
|
17
|
+
import { appendJsonl, fchmodOwnerOnly } from './history.js';
|
|
18
18
|
|
|
19
19
|
export class AuditLog {
|
|
20
20
|
constructor(file, { maxBytes = 8 * 1024 * 1024, keep = 5, log = () => {} } = {}) {
|
|
@@ -129,6 +129,11 @@ export class AuditLog {
|
|
|
129
129
|
|
|
130
130
|
/** Adopt an existing file's size so the first append after a restart is correct. */
|
|
131
131
|
async adopt() {
|
|
132
|
+
// THE MODE IS TIGHTENED ON FILES THAT ALREADY EXIST (audit 2026-09-16, L10): appendJsonl passes
|
|
133
|
+
// 0o600, which applies only when it creates the file, so a trail written before 2026-09-14 kept
|
|
134
|
+
// whatever the umask gave it. Opened without following a symlink where the platform allows, and
|
|
135
|
+
// fchmod'ed through the descriptor.
|
|
136
|
+
for (const f of this.chain()) await fchmodOwnerOnly(f);
|
|
132
137
|
try {
|
|
133
138
|
this.bytes = (await fsp.stat(this.file)).size;
|
|
134
139
|
return { adopted: this.bytes };
|
package/server/store/history.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// whole"), and it is the only sane answer when the process can be SIGKILLed
|
|
8
8
|
// mid-flush by a system OOM killer this box has actually triggered before.
|
|
9
9
|
import fsp from 'node:fs/promises';
|
|
10
|
+
import fs from 'node:fs';
|
|
10
11
|
import path from 'node:path';
|
|
11
12
|
import { Ring } from './ring.js';
|
|
12
13
|
|
|
@@ -141,7 +142,7 @@ export class History {
|
|
|
141
142
|
if (this.saving) return { skipped: true };
|
|
142
143
|
this.saving = true;
|
|
143
144
|
try {
|
|
144
|
-
await fsp.mkdir(this.dir, { recursive: true });
|
|
145
|
+
await fsp.mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
145
146
|
const payload = {
|
|
146
147
|
version: 1,
|
|
147
148
|
savedAt: Date.now(),
|
|
@@ -151,7 +152,10 @@ export class History {
|
|
|
151
152
|
eventsSeq: this.eventsSeq,
|
|
152
153
|
};
|
|
153
154
|
const tmp = `${this.file}.tmp`;
|
|
154
|
-
|
|
155
|
+
// node-derived detail: owner-only, like users and sessions. The temporary name is unlinked and
|
|
156
|
+
// created with O_EXCL, so a symlink planted there is never followed (audit 2026-09-16, L10)
|
|
157
|
+
await fsp.unlink(tmp).catch((err) => { if (err.code !== 'ENOENT') throw err; });
|
|
158
|
+
const fh = await fsp.open(tmp, 'wx', 0o600);
|
|
155
159
|
await fh.writeFile(JSON.stringify(payload));
|
|
156
160
|
await fh.sync();
|
|
157
161
|
await fh.close();
|
|
@@ -170,6 +174,7 @@ export class History {
|
|
|
170
174
|
}
|
|
171
175
|
|
|
172
176
|
async load() {
|
|
177
|
+
await fchmodOwnerOnly(this.file); // a snapshot written before the mode was passed (audit 2026-09-16, L10)
|
|
173
178
|
let raw;
|
|
174
179
|
try {
|
|
175
180
|
raw = await fsp.readFile(this.file, 'utf8');
|
|
@@ -215,6 +220,17 @@ export class History {
|
|
|
215
220
|
|
|
216
221
|
// Atomic append-only text sink, used by the audit log.
|
|
217
222
|
export async function appendJsonl(file, row) {
|
|
218
|
-
await fsp.mkdir(path.dirname(file), { recursive: true });
|
|
223
|
+
await fsp.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
219
224
|
await fsp.appendFile(file, JSON.stringify(row) + '\n', { encoding: 'utf8', mode: 0o600 }); // who did what: owner-only (audit 2026-09-14, L4)
|
|
220
225
|
}
|
|
226
|
+
|
|
227
|
+
// An existing file made owner-only through its own descriptor (audit 2026-09-16, L10). O_NOFOLLOW
|
|
228
|
+
// where the platform has it, so a symlink's target is never re-moded; a missing file, or a platform
|
|
229
|
+
// that keeps no modes (Windows), is not an error.
|
|
230
|
+
export async function fchmodOwnerOnly(file) {
|
|
231
|
+
let fh;
|
|
232
|
+
try {
|
|
233
|
+
fh = await fsp.open(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
234
|
+
await fh.chmod(0o600);
|
|
235
|
+
} catch { /* absent, a symlink, or modes not kept here */ } finally { await fh?.close().catch(() => {}); }
|
|
236
|
+
}
|
package/server/store/ledger.js
CHANGED
|
@@ -54,7 +54,7 @@ async function sqliteEngine() {
|
|
|
54
54
|
|
|
55
55
|
export async function openLedger({ file, engine = 'auto', keepHeights = 52_594, log = () => {} } = {}) {
|
|
56
56
|
if (!file) throw new Error('openLedger needs a file path');
|
|
57
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
57
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); // owner-only (audit 2026-09-16, L10)
|
|
58
58
|
let chosen = engine;
|
|
59
59
|
if (engine === 'auto') {
|
|
60
60
|
chosen = (process.env.BLOCKYARD_LEDGER_ENGINE ?? 'sqlite').trim().toLowerCase();
|
|
@@ -90,6 +90,9 @@ class SqliteLedger {
|
|
|
90
90
|
height INTEGER PRIMARY KEY, pool_key TEXT, pool_label TEXT, seen_at INTEGER, row TEXT
|
|
91
91
|
)`);
|
|
92
92
|
this.db.exec('CREATE INDEX IF NOT EXISTS attribution_seen ON attribution(seen_at DESC)');
|
|
93
|
+
// SQLite creates its files by the umask: tightened to owner-only, and an older file with them
|
|
94
|
+
// (audit 2026-09-16, L10)
|
|
95
|
+
for (const f of [file, `${file}-wal`, `${file}-shm`]) ownerOnly(f);
|
|
93
96
|
this.insert = this.db.prepare('INSERT OR REPLACE INTO attribution VALUES (?,?,?,?,?)');
|
|
94
97
|
}
|
|
95
98
|
|
|
@@ -164,7 +167,8 @@ class JsonlLedger {
|
|
|
164
167
|
if (Number.isInteger(row?.height)) this.byHeight.set(row.height, row);
|
|
165
168
|
} catch { /* the incomplete final write; ignored, then overwritten */ }
|
|
166
169
|
}
|
|
167
|
-
this.fd = fs.openSync(file, 'a');
|
|
170
|
+
this.fd = fs.openSync(file, 'a', 0o600);
|
|
171
|
+
try { fs.fchmodSync(this.fd, 0o600); } catch { /* not every filesystem keeps modes */ } // one created before (audit 2026-09-16, L10)
|
|
168
172
|
}
|
|
169
173
|
|
|
170
174
|
put(row) { return this.putMany([row]); }
|
|
@@ -188,13 +192,15 @@ class JsonlLedger {
|
|
|
188
192
|
#compact() {
|
|
189
193
|
const rows = [...this.byHeight.values()].sort((a, b) => a.height - b.height);
|
|
190
194
|
const tmp = `${this.file}.tmp`;
|
|
191
|
-
|
|
195
|
+
// never a planted symlink followed: unlinked, then created afresh and owner-only (audit 2026-09-16, L10)
|
|
196
|
+
try { fs.unlinkSync(tmp); } catch (err) { if (err.code !== 'ENOENT') throw err; }
|
|
197
|
+
const fd = fs.openSync(tmp, 'wx', 0o600);
|
|
192
198
|
for (const r of rows) fs.writeSync(fd, JSON.stringify(r) + '\n');
|
|
193
199
|
fs.fsyncSync(fd);
|
|
194
200
|
fs.closeSync(fd);
|
|
195
201
|
fs.closeSync(this.fd);
|
|
196
202
|
fs.renameSync(tmp, this.file);
|
|
197
|
-
this.fd = fs.openSync(this.file, 'a');
|
|
203
|
+
this.fd = fs.openSync(this.file, 'a', 0o600);
|
|
198
204
|
this.dirty = 0;
|
|
199
205
|
}
|
|
200
206
|
|
|
@@ -288,3 +294,8 @@ export function aggregate(rows = []) {
|
|
|
288
294
|
}))
|
|
289
295
|
.sort((a, b) => b.blocks - a.blocks || b.lastHeight - a.lastHeight);
|
|
290
296
|
}
|
|
297
|
+
|
|
298
|
+
// chmod 0o600 when the file exists; a missing file or a filesystem without modes is not an error
|
|
299
|
+
function ownerOnly(file) {
|
|
300
|
+
try { fs.chmodSync(file, 0o600); } catch { /* absent, or modes not kept here */ }
|
|
301
|
+
}
|