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/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
|
+
}
|
|
@@ -84,15 +84,46 @@ Environment=BLOCKYARD_LOG_LEVEL=info
|
|
|
84
84
|
#Environment=BLOCKYARD_ENABLE_ACTIONS=1
|
|
85
85
|
#Environment=BLOCKYARD_ACTIONS=testmempoolaccept,savemempool
|
|
86
86
|
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
# ProtectSystem=strict would
|
|
87
|
+
# SANDBOX (audit 2026-09-16, M8). This block used to be "modest hardening", and its comment said
|
|
88
|
+
# NoNewPrivileges plus a read-only /home was enough -- but no read-only /home was ever set, and it
|
|
89
|
+
# claimed ProtectSystem=strict would stop the service reading the node's cookie. It would not:
|
|
90
|
+
# strict makes paths READ-ONLY, and reading is all the monitor does with the node. Everything below
|
|
91
|
+
# was started and checked against this app (tests/unit-sandbox note in docs/INSTALL.md section 6).
|
|
92
|
+
#
|
|
93
|
+
# The whole filesystem is read-only to the service except what it writes: its data directory
|
|
94
|
+
# (sessions, history, audit trail, TLS certificate), its config directory (the node form and the
|
|
95
|
+
# Display settings save there), and the address index if you configured one. EDIT ME: these paths
|
|
96
|
+
# follow WorkingDirectory above; add the index directory (`addressIndex` in config/local.json) as a
|
|
97
|
+
# third ReadWritePaths line. The leading "-" skips a path that does not exist yet.
|
|
98
|
+
ReadWritePaths=-/storage/blockyard/data
|
|
99
|
+
ReadWritePaths=-/storage/blockyard/config
|
|
100
|
+
#ReadWritePaths=/var/lib/blockyard-index
|
|
101
|
+
ProtectSystem=strict
|
|
102
|
+
# The node's cookie and log are usually under a home directory: readable, never writable.
|
|
103
|
+
ProtectHome=read-only
|
|
104
|
+
PrivateTmp=true
|
|
105
|
+
PrivateDevices=true
|
|
106
|
+
UMask=0077
|
|
90
107
|
NoNewPrivileges=true
|
|
108
|
+
CapabilityBoundingSet=
|
|
109
|
+
AmbientCapabilities=
|
|
91
110
|
ProtectKernelTunables=true
|
|
92
111
|
ProtectKernelModules=true
|
|
112
|
+
ProtectKernelLogs=true
|
|
93
113
|
ProtectControlGroups=true
|
|
114
|
+
ProtectClock=true
|
|
115
|
+
ProtectHostname=true
|
|
116
|
+
ProtectProc=invisible
|
|
117
|
+
RestrictNamespaces=true
|
|
118
|
+
RestrictRealtime=true
|
|
94
119
|
RestrictSUIDSGID=true
|
|
95
120
|
LockPersonality=true
|
|
121
|
+
# AF_NETLINK: the boot check that a configured address exists reads the interfaces through it.
|
|
122
|
+
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
|
123
|
+
SystemCallArchitectures=native
|
|
124
|
+
SystemCallFilter=@system-service
|
|
125
|
+
SystemCallErrorNumber=EPERM
|
|
126
|
+
# NOT MemoryDenyWriteExecute: V8's JIT needs writable-executable memory, and node aborts under it.
|
|
96
127
|
|
|
97
128
|
StandardOutput=journal
|
|
98
129
|
StandardError=journal
|