blockyard 0.1.0 → 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 +108 -0
- package/README.md +13 -11
- package/SECURITY.md +2 -2
- package/docs/API.md +1 -1
- package/docs/ARCHITECTURE.md +36 -5
- package/docs/CONFIGURATION.md +6 -4
- package/docs/DEFECTS.md +4 -1
- package/docs/GETTING-STARTED.md +14 -7
- package/docs/INSTALL.md +7 -4
- 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 +26 -7
- package/docs/TROUBLESHOOTING.md +10 -5
- package/docs/USER-GUIDE.md +239 -9
- package/package.json +4 -2
- package/public/css/app.css +87 -0
- package/public/index.html +58 -6
- package/public/js/app.js +60 -19
- package/public/js/blockanoid.js +15 -7
- package/public/js/blockout.js +15 -7
- package/public/js/blockscene3d.js +51 -11
- package/public/js/depthchart.js +1 -1
- package/public/js/details3d.js +25 -2
- package/public/js/explorer.js +7 -1
- package/public/js/livingsky.js +494 -0
- package/public/js/login.js +3 -2
- package/public/js/mining.js +4 -4
- package/public/js/panels.js +27 -18
- 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 +266 -80
- package/public/js/tetrust.js +15 -6
- package/public/js/tetsound.js +35 -5
- package/scripts/check.js +46 -0
- package/scripts/index-build.js +9 -2
- package/scripts/pool-map.js +152 -36
- package/scripts/setup.js +108 -10
- package/scripts/shots.mjs +21 -0
- package/scripts/smoke.sh +6 -5
- 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 +432 -56
- 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 +24 -11
- package/server/collect/network.js +19 -9
- package/server/config.js +7 -0
- package/server/http/api.js +70 -13
- package/server/http/server.js +22 -5
- package/server/http/sse.js +53 -7
- package/server/main.js +13 -3
- 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/systemd/blockyard.service +34 -3
package/scripts/setup.js
CHANGED
|
@@ -5,10 +5,16 @@
|
|
|
5
5
|
// monitor -- so a new machine goes from a clone to running and indexing in one sitting.
|
|
6
6
|
//
|
|
7
7
|
// npm run setup # interactive
|
|
8
|
-
// node scripts/setup.js --yes [--rpc-url URL] [--datadir DIR] [--label L]
|
|
8
|
+
// node scripts/setup.js --yes [--rpc-url URL] [--datadir DIR] [--label L]
|
|
9
|
+
// [--rpc-user U (--rpc-password-file PATH | --rpc-password - | --rpc-password P)]
|
|
9
10
|
// [--host 127.0.0.1] [--port 21000] [--index-dir DIR] [--workers N]
|
|
10
11
|
// [--build-here | --build-later] [--start] [--force]
|
|
11
12
|
//
|
|
13
|
+
// The RPC password (2026-09-16, audit L12): --rpc-password-file PATH reads the first line of a
|
|
14
|
+
// file, --rpc-password - reads the first line of stdin; --rpc-password P still works but warns,
|
|
15
|
+
// because P is then visible to every user of the machine in `ps` and stays in shell history.
|
|
16
|
+
// Typed at the prompt, it is not echoed.
|
|
17
|
+
//
|
|
12
18
|
// --yes takes every default without asking (a scripted install); --force replaces an existing
|
|
13
19
|
// config/local.json (a backup is kept either way); --start boots the monitor at the end without
|
|
14
20
|
// asking. The index is built by BlockYard itself, in the background, once it starts (the Overview shows
|
|
@@ -16,7 +22,7 @@
|
|
|
16
22
|
// --build-later writes addressIndexBuild: "manual" so nothing builds until you run index-build.js.
|
|
17
23
|
// The written file is mode 0600: it may carry an RPC password. Nothing here touches the
|
|
18
24
|
// node: every call is a read. (operator, 2026-09-14: "Make this npm installer absolutely beautiful")
|
|
19
|
-
import { existsSync, statSync, writeFileSync,
|
|
25
|
+
import { existsSync, statSync, writeFileSync, chmodSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
20
26
|
import os from 'node:os';
|
|
21
27
|
import net from 'node:net';
|
|
22
28
|
import http from 'node:http';
|
|
@@ -58,18 +64,94 @@ export function localConfig(a) {
|
|
|
58
64
|
if (a.indexDir) node.addressIndex = a.indexDir;
|
|
59
65
|
if (a.indexBuild === 'manual') node.addressIndexBuild = 'manual'; // the server builds a missing index on start unless told not to
|
|
60
66
|
if (Number.isInteger(a.workers) && a.workers > 0) node.addressIndexWorkers = a.workers; // the server's background build uses the same number
|
|
67
|
+
// What the node told us it can do, from bmcgetcapabilities (scripts/check.js).
|
|
68
|
+
// A SNAPSHOT, written so the server knows on its first tick without a probe --
|
|
69
|
+
// not a source of truth. These are live settings on the node: an operator can
|
|
70
|
+
// enable the journal or the facade tomorrow, so the server re-reads them and
|
|
71
|
+
// this only saves the first round trip. Absent for a Core node, which is how
|
|
72
|
+
// the server tells the two apart.
|
|
73
|
+
if (a.nodeKind && a.nodeKind !== 'core') {
|
|
74
|
+
node.nodeKind = a.nodeKind;
|
|
75
|
+
if (a.nodeCapabilities) node.nodeCapabilities = a.nodeCapabilities;
|
|
76
|
+
}
|
|
61
77
|
return { server: { host: a.host, port: Number(a.port) }, nodes: [node] };
|
|
62
78
|
}
|
|
63
79
|
|
|
64
80
|
/** Write it, keeping a dated copy of whatever was there. Refuses an existing file unless `force`. */
|
|
65
|
-
export function writeLocalConfig(file, cfg, { force = false, now = new Date() } = {}) {
|
|
81
|
+
export function writeLocalConfig(file, cfg, { force = false, now = new Date(), platform = process.platform } = {}) {
|
|
66
82
|
if (existsSync(file)) {
|
|
67
83
|
if (!force) throw new Error(`${file} exists; pass --force (or answer yes) to replace it`);
|
|
68
84
|
const bak = `${file}.bak-${now.toISOString().replace(/[:.]/g, '').slice(0, 15)}`;
|
|
69
|
-
|
|
85
|
+
// THE BACKUP IS OWNER-ONLY TOO (2026-09-16, audit I5): it holds the same RPC password, and
|
|
86
|
+
// copyFileSync kept whatever mode the old file had -- 0644 on a config written by hand. It is
|
|
87
|
+
// written fresh at 0600, and chmod'ed in case a backup of that name was already there.
|
|
88
|
+
writeFileSync(bak, readFileSync(file), { mode: 0o600 });
|
|
89
|
+
if (platform !== 'win32') chmodSync(bak, 0o600);
|
|
70
90
|
}
|
|
71
91
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
72
92
|
writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
|
|
93
|
+
// `mode` above applies only when the file is CREATED (2026-09-16, audit L12): --force over an
|
|
94
|
+
// existing 0644 local.json kept 0644 while putting a password in it. Windows has no such bits.
|
|
95
|
+
if (platform !== 'win32') chmodSync(file, 0o600);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* WHERE THE RPC PASSWORD COMES FROM ON THE COMMAND LINE (2026-09-16, audit L12). `--rpc-password P`
|
|
100
|
+
* puts P in `ps` for every user of the machine and in the shell's history, so two ways that do
|
|
101
|
+
* not: `--rpc-password-file PATH` (the first line of the file) and `--rpc-password -` (the first
|
|
102
|
+
* line of stdin; needs --yes, since stdin is then not a terminal to ask on). The plain form still
|
|
103
|
+
* works, with a warning. Returns { password, source, warning } -- password null when none is named.
|
|
104
|
+
*/
|
|
105
|
+
export function rpcPasswordFrom(argv, { readFile = (p) => readFileSync(p, 'utf8'), readStdin = () => readFileSync(0, 'utf8') } = {}) {
|
|
106
|
+
const val = (name) => { const i = argv.indexOf(`--${name}`); return i >= 0 && argv[i + 1] != null && (argv[i + 1] === '-' || !argv[i + 1].startsWith('--')) ? argv[i + 1] : null; };
|
|
107
|
+
const firstLine = (text) => String(text).split(/\r?\n/)[0];
|
|
108
|
+
const file = val('rpc-password-file'), plain = val('rpc-password');
|
|
109
|
+
if (file != null && plain != null) throw new Error('--rpc-password and --rpc-password-file name the password twice; give one');
|
|
110
|
+
if (file != null) {
|
|
111
|
+
let text;
|
|
112
|
+
try { text = readFile(expand(file)); } catch (err) { throw new Error(`--rpc-password-file ${file}: ${err.code ?? err.message}`); }
|
|
113
|
+
return { password: firstLine(text), source: 'file', warning: null };
|
|
114
|
+
}
|
|
115
|
+
if (plain === '-') return { password: firstLine(readStdin()), source: 'stdin', warning: null };
|
|
116
|
+
if (plain != null) {
|
|
117
|
+
return { password: plain, source: 'argv',
|
|
118
|
+
warning: '--rpc-password on the command line is visible to other users of this machine (ps) and stays in your shell history; use --rpc-password-file PATH, or --rpc-password - to read it from stdin' };
|
|
119
|
+
}
|
|
120
|
+
return { password: null, source: null, warning: null };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A PROMPT THAT DOES NOT ECHO (2026-09-16, audit L12): readline's question() shows every character
|
|
125
|
+
* typed, so the "secret" rpcPassword prompt used to hide only its default. The same approach as
|
|
126
|
+
* scripts/manage-users.js: raw mode on a terminal, the characters collected by hand, nothing
|
|
127
|
+
* written back but the newline. Backspace edits, Ctrl-C calls `onInterrupt`. The caller closes
|
|
128
|
+
* any readline interface on `input` first, so it does not echo the keys itself.
|
|
129
|
+
*/
|
|
130
|
+
export function readHidden(question, { input = stdin, output = stdout, onInterrupt = () => process.exit(130) } = {}) {
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
output.write(question);
|
|
133
|
+
const raw = Boolean(input.isTTY && input.setRawMode);
|
|
134
|
+
if (raw) input.setRawMode(true);
|
|
135
|
+
let buf = '';
|
|
136
|
+
input.setEncoding?.('utf8');
|
|
137
|
+
const finish = () => {
|
|
138
|
+
input.removeListener('data', onData);
|
|
139
|
+
if (raw) input.setRawMode(false);
|
|
140
|
+
input.pause();
|
|
141
|
+
output.write('\n');
|
|
142
|
+
};
|
|
143
|
+
// a chunk may hold many characters (a paste, or a pipe), so each is looked at on its own
|
|
144
|
+
const onData = (chunk) => {
|
|
145
|
+
for (const ch of String(chunk)) {
|
|
146
|
+
if (ch === '\n' || ch === '\r') { finish(); resolve(buf); return; }
|
|
147
|
+
if (ch === '\u007f' || ch === '\b') buf = buf.slice(0, -1); // DEL is what backspace sends on a tty
|
|
148
|
+
else if (ch === '\u0003') { finish(); onInterrupt(); return; }
|
|
149
|
+
else buf += ch;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
input.on('data', onData);
|
|
153
|
+
input.resume();
|
|
154
|
+
});
|
|
73
155
|
}
|
|
74
156
|
|
|
75
157
|
// ------------------------------------------------------------------------- what an answer must be
|
|
@@ -201,8 +283,12 @@ export async function portInUse(host, port) {
|
|
|
201
283
|
|
|
202
284
|
// ------------------------------------------------------------------------------------ the flow
|
|
203
285
|
async function main() {
|
|
204
|
-
if (!YES && !stdin.isTTY) { console.error('no terminal to ask on: pass --yes with the --rpc-url/--datadir flags (see the header of scripts/setup.js)'); process.exit(2); }
|
|
205
|
-
|
|
286
|
+
if (!YES && !stdin.isTTY) { console.error('no terminal to ask on: pass --yes with the --rpc-url/--datadir flags (see the header of scripts/setup.js; --rpc-password - reads stdin only with --yes)'); process.exit(2); }
|
|
287
|
+
// read once, before anything else touches stdin (2026-09-16, audit L12)
|
|
288
|
+
let pw;
|
|
289
|
+
try { pw = rpcPasswordFrom(argv); } catch (err) { console.error(c.bad(err.message)); process.exit(2); }
|
|
290
|
+
if (pw.warning) console.error(`${c.warn('!')} ${pw.warning}`);
|
|
291
|
+
let rl = YES ? null : readline.createInterface({ input: stdin, output: stdout });
|
|
206
292
|
const out = (s = '') => stdout.write(`${s}\n`);
|
|
207
293
|
// every line the installer says fits the terminal (operator: 80 columns, "Standard CRT")
|
|
208
294
|
const say = (s) => out(` ${wrapText(s, cols() - 4, 4)}`);
|
|
@@ -215,7 +301,14 @@ async function main() {
|
|
|
215
301
|
if (YES) raw = def;
|
|
216
302
|
else {
|
|
217
303
|
const shown = def != null && def !== '' && !secret ? ` ${c.dim(`(${def})`)}` : '';
|
|
218
|
-
|
|
304
|
+
const prompt = ` ${q} ${label}${shown} ${c.dim('›')} `;
|
|
305
|
+
if (secret) {
|
|
306
|
+
// not echoed (2026-09-16, audit L12): readline would echo the keys itself, so it is
|
|
307
|
+
// closed for the one answer and opened again after
|
|
308
|
+
rl.close();
|
|
309
|
+
raw = (await readHidden(`${prompt}${c.dim('(not shown) ')}`, { onInterrupt: () => process.emit('SIGINT') })).trim();
|
|
310
|
+
rl = readline.createInterface({ input: stdin, output: stdout });
|
|
311
|
+
} else raw = (await rl.question(prompt)).trim();
|
|
219
312
|
if (raw === '') raw = def;
|
|
220
313
|
}
|
|
221
314
|
const r = check ? check(raw ?? '') : { value: raw };
|
|
@@ -238,7 +331,7 @@ async function main() {
|
|
|
238
331
|
let building = null;
|
|
239
332
|
process.on('SIGINT', () => {
|
|
240
333
|
out(); out();
|
|
241
|
-
if (building) say(c.warn(`stopped. The index in ${building} is unfinished: run the build again (it
|
|
334
|
+
if (building) say(c.warn(`stopped. The index in ${building} is unfinished: run the build again (it resumes where it stopped) before pointing BlockYard at it.`));
|
|
242
335
|
else say(c.dim('stopped; nothing written.'));
|
|
243
336
|
process.exit(130);
|
|
244
337
|
});
|
|
@@ -273,7 +366,7 @@ async function main() {
|
|
|
273
366
|
const port = conf.values.rpcport ?? RPC_PORT[conf.chain] ?? 8332;
|
|
274
367
|
a.rpcUrl = await ask('RPC URL', arg('rpc-url', `http://${host}:${port}`), validate.rpcUrl);
|
|
275
368
|
a.label = await ask('a label for the node', arg('label', conf.chain === 'main' ? 'Bitcoin Core' : `Bitcoin Core (${conf.chain})`), validate.label);
|
|
276
|
-
a.rpcUser = arg('rpc-user'); a.rpcPassword =
|
|
369
|
+
a.rpcUser = arg('rpc-user'); a.rpcPassword = pw.password;
|
|
277
370
|
a.cookieFile = conf.values.rpccookiefile ? (path.isAbsolute(conf.values.rpccookiefile) ? conf.values.rpccookiefile : path.join(a.datadir, conf.values.rpccookiefile)) : null;
|
|
278
371
|
const cookie = resolveCookie({ datadir: a.datadir, chainHint: a.chain, cookieFile: a.cookieFile ?? undefined });
|
|
279
372
|
if (cookie && cookie.source !== 'config') say(`${c.ok('✓')} cookie found: ${c.dim(cookie.source)}`);
|
|
@@ -284,7 +377,7 @@ async function main() {
|
|
|
284
377
|
const who = conf.rpcauthUsers[0] ?? '';
|
|
285
378
|
say(`${c.warn('!')} no .cookie readable under ${a.datadir}${who ? `; ${shortPath(conf.file)} has rpcauth for "${who}", whose password is not in the file` : ': a node authenticating with rpcauth needs a user and password'}`);
|
|
286
379
|
a.rpcUser = await ask('rpcUser', who, null);
|
|
287
|
-
if (a.rpcUser) a.rpcPassword = await ask('rpcPassword', '', null, { secret: true });
|
|
380
|
+
if (a.rpcUser && a.rpcPassword == null) a.rpcPassword = await ask('rpcPassword', '', null, { secret: true });
|
|
288
381
|
}
|
|
289
382
|
|
|
290
383
|
out(step(2, STEPS, 'Checking the node'));
|
|
@@ -293,6 +386,10 @@ async function main() {
|
|
|
293
386
|
result = await runChecks(node, { rpc: clientFor(node, defaults) });
|
|
294
387
|
if (result.facts.chain && result.facts.chain !== a.chain) { a.chain = result.facts.chain; node.chainHint = a.chain; result = await runChecks(node, { rpc: clientFor(node, defaults) }); }
|
|
295
388
|
spin.stop();
|
|
389
|
+
// Carry what the node said about itself into the answers, so localConfig
|
|
390
|
+
// can record it below. A Core node reports nodeKind 'core' and nothing is
|
|
391
|
+
// written -- absence is the signal.
|
|
392
|
+
if (result.facts.nodeKind) { a.nodeKind = result.facts.nodeKind; a.nodeCapabilities = result.facts.nodeCapabilities ?? null; }
|
|
296
393
|
for (const ch of result.checks) out(checkLine(ch.status, ch.name, ch.detail));
|
|
297
394
|
out();
|
|
298
395
|
if (result.ok) { say(c.ok(c.bold('everything this needs is there'))); break; }
|
|
@@ -385,6 +482,7 @@ async function main() {
|
|
|
385
482
|
const manifest = await buildIndex({
|
|
386
483
|
rpc, blocksDir: path.join(node.datadir, 'blocks'), out: a.indexDir, workers: a.workers,
|
|
387
484
|
pace: rpcPacer(rpc, { onChange: (held) => bar.done(held ? c.dim(' paused while the node\'s RPC is slow or failing') : c.dim(' resumed')) }),
|
|
485
|
+
log: (text) => bar.done(c.dim(` ${text}`)),
|
|
388
486
|
onProgress: (p) => {
|
|
389
487
|
if (p.phase !== phase) {
|
|
390
488
|
if (phase) bar.done(strip(progressLine({ phase, done: 1, total: 1, elapsed: (Date.now() - phaseStart) / 1000 })));
|
package/scripts/shots.mjs
CHANGED
|
@@ -230,6 +230,27 @@ if (doing('tetrust')) {
|
|
|
230
230
|
} catch (e) { fail('tetrust', e); }
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
// ------------------------------------------------------------- scorched yard
|
|
234
|
+
// a game against the computer, a nuke on its way in from You (the shot is set up through the
|
|
235
|
+
// screen's currentGame hook so the picture has a blast in it rather than an empty field)
|
|
236
|
+
if (doing('scorched')) {
|
|
237
|
+
try {
|
|
238
|
+
await evl(`document.getElementById('navDivBtn')?.click()`);
|
|
239
|
+
await sleep(700);
|
|
240
|
+
const ok = await evl(`(() => { const b=document.querySelector('button[data-page="scorched"]'); if(!b) return false; b.click(); return true; })()`);
|
|
241
|
+
if (!ok) throw new Error('no scorched button behind Diversions');
|
|
242
|
+
await sleep(2500);
|
|
243
|
+
await metrics(1000);
|
|
244
|
+
await evl(`document.getElementById('syResume')?.click()`);
|
|
245
|
+
await sleep(1500);
|
|
246
|
+
await evl(`import('/js/scorchedyard.js').then(async (m) => { const g = m.currentGame(); const t = g.tanks[0]; const ai = await import('/js/scorchedai.js'); const target = ai.nearest(g, t); t.inventory.nuke = 1; t.weapon = 'nuke'; const s = ai.spoiler(g, t, () => 0.9, target); t.angle = s.angle; t.power = s.power; })`);
|
|
247
|
+
await sleep(300);
|
|
248
|
+
await evl(`document.getElementById('syFire')?.click()`);
|
|
249
|
+
await sleep(2400);
|
|
250
|
+
done.push(await shoot('scorched', { settle: 0, height: 1000 }));
|
|
251
|
+
} catch (e) { fail('scorched', e); }
|
|
252
|
+
}
|
|
253
|
+
|
|
233
254
|
// -------------------------------------------------- neon: LAST, and it is restored
|
|
234
255
|
if (doing('block-space-neon')) {
|
|
235
256
|
let toggled = false;
|
package/scripts/smoke.sh
CHANGED
|
@@ -59,8 +59,8 @@ echo "== booting server on :${PORT} (fake node :${FAKE_PORT}) =="
|
|
|
59
59
|
# choices -- including which addresses to bind -- and inheriting them made this script
|
|
60
60
|
# curl 127.0.0.1 against a server listening elsewhere (54 failures, 2 passes).
|
|
61
61
|
# BLOCKYARD_BIND: pin it explicitly anyway, so a future default cannot repeat that.
|
|
62
|
-
# BLOCKYARD_AUTH=1: accounts are
|
|
63
|
-
# signed-in contract (sessions, CSRF, per-user audit, RBAC). The open posture gets its
|
|
62
|
+
# BLOCKYARD_AUTH=1: accounts are ON by default (since 2026-09-15); set explicitly anyway, since
|
|
63
|
+
# this script's bulk is the signed-in contract (sessions, CSRF, per-user audit, RBAC). The open posture gets its
|
|
64
64
|
# own instance further down, on its own port, so both are asserted rather than one
|
|
65
65
|
# replacing the other.
|
|
66
66
|
BLOCKYARD_CONFIG=none BLOCKYARD_BIND=127.0.0.1 BLOCKYARD_AUTH=1 BLOCKYARD_TLS=0 \
|
|
@@ -282,8 +282,9 @@ check "the breaker names its threshold" "$(echo "$TEL" | grep -c '"threshold":3'
|
|
|
282
282
|
check "telemetry reports the audit log too" "$(echo "$TEL" | grep -c '"rotationError"')" "1"
|
|
283
283
|
check "the peer identity table has data to draw" "$(curl -s -b "$DIR/ck" "$BASE/api/peers" | grep -c '"identity"')" "1"
|
|
284
284
|
|
|
285
|
-
echo "== open access: the
|
|
286
|
-
# Accounts are
|
|
285
|
+
echo "== open access: the opt-in posture, no sign-in =="
|
|
286
|
+
# Accounts are ON by default (since 2026-09-15); open access is chosen with BLOCKYARD_AUTH=0,
|
|
287
|
+
# which this second instance sets. On its own port it asserts what that
|
|
287
288
|
# grants (reads, the read-only RPC console, the stream) and, more importantly, what it
|
|
288
289
|
# does NOT: user admin, the audit trail, node writes. Keeping this a separate boot is
|
|
289
290
|
# the point -- the signed-in checks above must not be quietly replaced by open ones.
|
|
@@ -291,7 +292,7 @@ OPEN_PORT="${BLOCKYARD_SMOKE_OPEN_PORT:-18199}"
|
|
|
291
292
|
OPEN_DIR="$(mktemp -d /tmp/blockyard-open.XXXXXX)"
|
|
292
293
|
OPID=""
|
|
293
294
|
# OPID is reaped by the single cleanup trap at the top
|
|
294
|
-
BLOCKYARD_CONFIG=none BLOCKYARD_BIND=127.0.0.1 \
|
|
295
|
+
BLOCKYARD_CONFIG=none BLOCKYARD_BIND=127.0.0.1 BLOCKYARD_AUTH=0 BLOCKYARD_TLS=0 \
|
|
295
296
|
BLOCKYARD_DATA="$OPEN_DIR" BLOCKYARD_FAKE_NODE=1 BLOCKYARD_PORT="$OPEN_PORT" \
|
|
296
297
|
BLOCKYARD_LOG_LEVEL=warn FAKE_PORT="$((FAKE_PORT + 1))" node server/main.js >"$OPEN_DIR/server.log" 2>&1 &
|
|
297
298
|
OPID=$!
|
package/scripts/ui.js
CHANGED
|
@@ -143,12 +143,14 @@ const G = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(2)} B` : n >= 1e6 ? `${(n / 1e
|
|
|
143
143
|
* One line of a progress bar: `phase ████░░ done/total · rows · rate · ETA`. `elapsed` in seconds
|
|
144
144
|
* since the phase began; the ETA assumes the rate so far holds. Pure, so it is tested.
|
|
145
145
|
*/
|
|
146
|
-
|
|
146
|
+
// `from` is where the phase started this run: a resumed build begins part way, and its rate (and so
|
|
147
|
+
// the time left) is what this run has done, not what an earlier run did
|
|
148
|
+
export function progressLine({ phase, done, total, from = 0, rows = null, elapsed = 0 }, width = cols()) {
|
|
147
149
|
const frac = total > 0 ? Math.min(1, done / total) : 0;
|
|
148
150
|
const barW = Math.max(10, Math.min(30, width - 62));
|
|
149
151
|
const filled = Math.round(frac * barW);
|
|
150
152
|
const bar = c.accent('█'.repeat(filled)) + c.dim('░'.repeat(barW - filled));
|
|
151
|
-
const rate = elapsed > 0 && done >
|
|
153
|
+
const rate = elapsed > 0 && done > from ? (done - from) / elapsed : 0;
|
|
152
154
|
const eta = rate > 0 && total > done ? HMS((total - done) / rate) : null;
|
|
153
155
|
const bits = [`${String(done).padStart(String(total).length)}/${total}`, `${Math.round(frac * 100)}%`];
|
|
154
156
|
if (rows != null) bits.push(`${G(rows)} rows`);
|
package/server/auth/sessions.js
CHANGED
|
@@ -140,9 +140,18 @@ export class RateLimiter {
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
// Failed-login tracking
|
|
144
|
-
//
|
|
145
|
-
//
|
|
143
|
+
// Failed-login tracking. Two attacks need to be visible: a grind on one account, and a spray across
|
|
144
|
+
// many accounts from one host.
|
|
145
|
+
//
|
|
146
|
+
// THE LOCK IS PER (USERNAME, ADDRESS) PAIR, NOT PER USERNAME (audit 2026-09-16, L14). The first cut
|
|
147
|
+
// locked the username itself after 8 failures from anywhere, and the address after 8 failures for
|
|
148
|
+
// any account: so one person behind a shared NAT or proxy could lock every account for everyone
|
|
149
|
+
// behind it, and anyone who knew a username could lock its owner out from any address. Now:
|
|
150
|
+
// * 8 failures for one username from one address lock that pair;
|
|
151
|
+
// * 5x that from one address, across every username, lock the address (a spray);
|
|
152
|
+
// * 10x that for one username, across every address, lock the username (a distributed grind) --
|
|
153
|
+
// a threshold only a real attack reaches, where locking the account is the right trade.
|
|
154
|
+
// The per-address throttle in front of the KDF (main.js loginLimiter) still paces all of it.
|
|
146
155
|
export class LoginGuard {
|
|
147
156
|
constructor({ maxAttempts = 8, windowMs = 300000, lockoutMs = 600000 } = {}) {
|
|
148
157
|
this.maxAttempts = maxAttempts;
|
|
@@ -159,24 +168,35 @@ export class LoginGuard {
|
|
|
159
168
|
return e;
|
|
160
169
|
}
|
|
161
170
|
|
|
171
|
+
_keys(username, ip) {
|
|
172
|
+
return [
|
|
173
|
+
[`p:${username}\u0000${ip}`, this.maxAttempts],
|
|
174
|
+
[`i:${ip}`, this.maxAttempts * 5],
|
|
175
|
+
[`u:${username}`, this.maxAttempts * 10],
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
|
|
162
179
|
status(username, ip) {
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
180
|
+
let locked = 0, remaining = Infinity, worst = 0;
|
|
181
|
+
for (const [key, max] of this._keys(username, ip)) {
|
|
182
|
+
const e = this._entry(key);
|
|
183
|
+
locked = Math.max(locked, e.lockedUntil);
|
|
184
|
+
worst = Math.max(worst, e.hits.length);
|
|
185
|
+
remaining = Math.min(remaining, Math.max(0, max - e.hits.length));
|
|
186
|
+
}
|
|
166
187
|
if (locked > Date.now()) return { blocked: true, retryAfterMs: locked - Date.now() };
|
|
167
|
-
|
|
168
|
-
return { blocked: false, attempts: worst, remaining: Math.max(0, this.maxAttempts - worst) };
|
|
188
|
+
return { blocked: false, attempts: worst, remaining };
|
|
169
189
|
}
|
|
170
190
|
|
|
171
191
|
noteFailure(username, ip) {
|
|
172
192
|
const out = [];
|
|
173
|
-
for (const key of
|
|
193
|
+
for (const [key, max] of this._keys(username, ip)) {
|
|
174
194
|
const e = this._entry(key);
|
|
175
195
|
e.hits.push(Date.now());
|
|
176
|
-
if (e.hits.length >=
|
|
196
|
+
if (e.hits.length >= max) {
|
|
177
197
|
e.lockedUntil = Date.now() + this.lockoutMs;
|
|
178
198
|
e.hits = [];
|
|
179
|
-
out.push(key);
|
|
199
|
+
out.push(key.startsWith('p:') ? `u:${username}@i:${ip}` : key);
|
|
180
200
|
}
|
|
181
201
|
this.attempts.set(key, e);
|
|
182
202
|
}
|
|
@@ -184,8 +204,8 @@ export class LoginGuard {
|
|
|
184
204
|
}
|
|
185
205
|
|
|
186
206
|
noteSuccess(username, ip) {
|
|
187
|
-
this
|
|
188
|
-
this.attempts.delete(`
|
|
207
|
+
// success clears this pair only: it proves nothing about other addresses guessing this account
|
|
208
|
+
this.attempts.delete(`p:${username}\u0000${ip}`);
|
|
189
209
|
}
|
|
190
210
|
}
|
|
191
211
|
|
|
@@ -77,15 +77,49 @@ export function* records(buf, magic = MAGIC.main, trailer = 0, key = null) {
|
|
|
77
77
|
|
|
78
78
|
// --- undo data -----------------------------------------------------------
|
|
79
79
|
// Core's VARINT (serialize.h), which is NOT CompactSize: MSB base-128 with an offset per byte.
|
|
80
|
-
|
|
80
|
+
// Core reads each field at a fixed width and throws "ReadVarInt(): size too large" past it. The
|
|
81
|
+
// height code, the version placeholder and the script size are uint32, so a longer run of
|
|
82
|
+
// continuation bytes is corruption, not a large number -- and refusing it keeps every value an exact
|
|
83
|
+
// double (a flipped byte used to hand back heights and amounts past 2^53, rounded).
|
|
84
|
+
function readVarInt(buf, st, max = 0xffffffff) {
|
|
85
|
+
const limit = Math.floor(max / 128);
|
|
81
86
|
let n = 0;
|
|
82
87
|
for (;;) {
|
|
83
88
|
const b = buf[st.pos++];
|
|
84
89
|
if (b === undefined) throw new RangeError('truncated VARINT');
|
|
90
|
+
if (n > limit) throw new RangeError('VARINT too large');
|
|
91
|
+
n = n * 128 + (b & 0x7f);
|
|
92
|
+
if (b & 0x80) { if (n === max) throw new RangeError('VARINT too large'); n += 1; } else return n;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// THE AMOUNT IS A uint64 VARINT, AND IT CAN PASS 2^53 FOR A LEGAL AMOUNT. CompressAmount makes an
|
|
96
|
+
// amount that does not end in a zero about 9x larger, so from ~1e15 satoshis (10M BTC in one output,
|
|
97
|
+
// never seen, but in range) the compressed value no longer fits a double: 2,099,999,999,999,999 sat
|
|
98
|
+
// compresses to 18,899,999,999,999,991, which rounds to ...992, and came back as 209,999,999,999,999,940
|
|
99
|
+
// -- a hundred times too much, because the rounding moved the exponent digit
|
|
100
|
+
// (test/chain-decode-property.test.js). Every real amount stays on the number path; past 2^46,
|
|
101
|
+
// where one more byte could round, the rest is read in BigInt, to Core's uint64 limit.
|
|
102
|
+
const U64 = 0xffffffffffffffffn;
|
|
103
|
+
function readAmountVarInt(buf, st) {
|
|
104
|
+
let n = 0;
|
|
105
|
+
for (;;) {
|
|
106
|
+
const b = buf[st.pos];
|
|
107
|
+
if (b === undefined) throw new RangeError('truncated VARINT');
|
|
108
|
+
if (n > 0x3fffffffffff) return readBigVarInt(buf, st, BigInt(n));
|
|
109
|
+
st.pos++;
|
|
85
110
|
n = n * 128 + (b & 0x7f);
|
|
86
111
|
if (b & 0x80) n += 1; else return n;
|
|
87
112
|
}
|
|
88
113
|
}
|
|
114
|
+
function readBigVarInt(buf, st, n) {
|
|
115
|
+
for (;;) {
|
|
116
|
+
const b = buf[st.pos++];
|
|
117
|
+
if (b === undefined) throw new RangeError('truncated VARINT');
|
|
118
|
+
if (n > U64 >> 7n) throw new RangeError('VARINT too large');
|
|
119
|
+
n = (n << 7n) | BigInt(b & 0x7f);
|
|
120
|
+
if (b & 0x80) { if (n === U64) throw new RangeError('VARINT too large'); n += 1n; } else return n;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
89
123
|
function readCompactSize(buf, st) {
|
|
90
124
|
const b = buf[st.pos++];
|
|
91
125
|
if (b < 0xfd) return b;
|
|
@@ -94,8 +128,21 @@ function readCompactSize(buf, st) {
|
|
|
94
128
|
const v = Number(buf.readBigUInt64LE(st.pos)); st.pos += 8; return v;
|
|
95
129
|
}
|
|
96
130
|
|
|
97
|
-
// compressor.cpp DecompressAmount
|
|
131
|
+
// compressor.cpp DecompressAmount. `x` is a number, or a BigInt when its VARINT ran past 2^46 (see
|
|
132
|
+
// readAmountVarInt). The result is an exact number of satoshis, or a RangeError past 2^53 -- far
|
|
133
|
+
// beyond 21M BTC, so only corrupt data gets there, and Reader.u64 refuses such an amount the same way.
|
|
98
134
|
export function decompressAmount(x) {
|
|
135
|
+
if (typeof x === 'bigint') {
|
|
136
|
+
if (x === 0n) return 0;
|
|
137
|
+
x -= 1n;
|
|
138
|
+
let e = x % 10n, n;
|
|
139
|
+
x /= 10n;
|
|
140
|
+
if (e < 9n) { const d = (x % 9n) + 1n; x /= 9n; n = x * 10n + d; } else { n = x + 1n; }
|
|
141
|
+
while (e > 0n) { n *= 10n; e--; }
|
|
142
|
+
if (n > BigInt(Number.MAX_SAFE_INTEGER)) throw new RangeError(`amount ${n} is past the safe integer range`);
|
|
143
|
+
return Number(n);
|
|
144
|
+
}
|
|
145
|
+
if (x > Number.MAX_SAFE_INTEGER) return decompressAmount(BigInt(x));
|
|
99
146
|
if (x === 0) return 0;
|
|
100
147
|
x -= 1;
|
|
101
148
|
let e = x % 10;
|
|
@@ -103,6 +150,7 @@ export function decompressAmount(x) {
|
|
|
103
150
|
let n;
|
|
104
151
|
if (e < 9) { const d = (x % 9) + 1; x = Math.floor(x / 9); n = x * 10 + d; } else { n = x + 1; }
|
|
105
152
|
while (e > 0) { n *= 10; e--; }
|
|
153
|
+
if (n > Number.MAX_SAFE_INTEGER) throw new RangeError(`amount ${n} is past the safe integer range`);
|
|
106
154
|
return n;
|
|
107
155
|
}
|
|
108
156
|
|
|
@@ -119,7 +167,12 @@ function decompressPubkey(prefix, x32) {
|
|
|
119
167
|
// compressor.cpp: sizes 0-5 are special script templates, 6+ a raw script of (size - 6) bytes
|
|
120
168
|
function readCompressedScript(buf, st) {
|
|
121
169
|
const size = readVarInt(buf, st);
|
|
122
|
-
|
|
170
|
+
// bounds-checked: a slice past the end used to come back short and silently, the record failing
|
|
171
|
+
// only later with a nonsense "-N bytes left" (or, for templates 4 and 5, a key rebuilt from a short x)
|
|
172
|
+
const take = (n) => {
|
|
173
|
+
if (st.pos + n > buf.length) throw new RangeError(`truncated script: need ${n} bytes at ${st.pos} of ${buf.length}`);
|
|
174
|
+
const v = buf.subarray(st.pos, st.pos + n); st.pos += n; return v;
|
|
175
|
+
};
|
|
123
176
|
switch (size) {
|
|
124
177
|
case 0: return Buffer.concat([Buffer.from([0x76, 0xa9, 0x14]), take(20), Buffer.from([0x88, 0xac])]);
|
|
125
178
|
case 1: return Buffer.concat([Buffer.from([0xa9, 0x14]), take(20), Buffer.from([0x87])]);
|
|
@@ -144,7 +197,7 @@ export function decodeBlockUndo(body) {
|
|
|
144
197
|
const code = readVarInt(body, st);
|
|
145
198
|
const height = Math.floor(code / 2);
|
|
146
199
|
if (height > 0) readVarInt(body, st); // the legacy nVersion placeholder, always 0
|
|
147
|
-
const value_sat = decompressAmount(
|
|
200
|
+
const value_sat = decompressAmount(readAmountVarInt(body, st));
|
|
148
201
|
const script = readCompressedScript(body, st);
|
|
149
202
|
coins[j] = { height, coinbase: (code & 1) === 1, value_sat, script };
|
|
150
203
|
}
|
|
@@ -165,11 +218,17 @@ export function undoShape(body) {
|
|
|
165
218
|
for (let j = 0; j < nin; j++) {
|
|
166
219
|
const height = Math.floor(readVarInt(body, st) / 2);
|
|
167
220
|
if (height > 0) readVarInt(body, st);
|
|
168
|
-
|
|
221
|
+
readAmountVarInt(body, st);
|
|
169
222
|
const size = readVarInt(body, st);
|
|
170
223
|
st.pos += size === 0 || size === 1 ? 20 : size < 6 ? 32 : size - 6;
|
|
171
224
|
}
|
|
172
225
|
}
|
|
226
|
+
// SKIPPING IS NOT READING: script bytes are stepped over, never touched, so a record cut in the
|
|
227
|
+
// middle of its last script returned the full counts as if whole (found by truncating random
|
|
228
|
+
// records at every offset). pairBlocksWithUndo matches on this shape, so the end is checked here
|
|
229
|
+
// exactly as decodeBlockUndo checks it.
|
|
230
|
+
if (st.pos > body.length) throw new RangeError(`truncated undo record: need ${st.pos} bytes, have ${body.length}`);
|
|
231
|
+
if (st.pos !== body.length) throw new RangeError(`${body.length - st.pos} bytes left after the undo record`);
|
|
173
232
|
return counts;
|
|
174
233
|
}
|
|
175
234
|
|