blockyard 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +929 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +191 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +41 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1577 -0
- package/docs/ARCHITECTURE.md +1394 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +847 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +205 -0
- package/docs/INSTALL.md +547 -0
- package/docs/MEASUREMENTS.md +1401 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +212 -0
- package/docs/TROUBLESHOOTING.md +332 -0
- package/docs/USER-GUIDE.md +1262 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +2009 -0
- package/public/donate-qr.png +0 -0
- package/public/index.html +1085 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +1141 -0
- package/public/js/app.js +1386 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2830 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +315 -0
- package/public/js/details3d.js +4342 -0
- 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 +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +88 -0
- package/public/js/markets.js +395 -0
- package/public/js/mining.js +1416 -0
- package/public/js/panels.js +970 -0
- package/public/js/pricechart.js +189 -0
- package/public/js/quake.js +20 -0
- package/public/js/settings.js +1096 -0
- package/public/js/soundcard.js +459 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +23 -0
- package/scripts/dos-bench.js +56 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +432 -0
- package/scripts/shots.mjs +278 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/tls.js +31 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +210 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2545 -0
- package/server/collect/network.js +295 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +644 -0
- package/server/http/api.js +1319 -0
- package/server/http/explorer.js +418 -0
- package/server/http/games.js +77 -0
- package/server/http/server.js +420 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +673 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/tls/selfsigned.js +160 -0
- package/server/util/fmt.js +29 -0
- package/systemd/blockyard.service +102 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Static file serving with a strict containment rule.
|
|
2
|
+
//
|
|
3
|
+
// The containment check resolves the symlink and compares against the real root,
|
|
4
|
+
// not the requested string: `/assets/../../../etc/passwd` and a symlink planted
|
|
5
|
+
// inside public/ are different attacks and only the realpath test stops both.
|
|
6
|
+
import fsp from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
const MIME = {
|
|
11
|
+
'.html': 'text/html; charset=utf-8',
|
|
12
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
13
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
14
|
+
'.css': 'text/css; charset=utf-8',
|
|
15
|
+
'.json': 'application/json; charset=utf-8',
|
|
16
|
+
'.svg': 'image/svg+xml',
|
|
17
|
+
'.png': 'image/png',
|
|
18
|
+
'.jpg': 'image/jpeg',
|
|
19
|
+
'.webp': 'image/webp',
|
|
20
|
+
'.ico': 'image/x-icon',
|
|
21
|
+
'.woff2': 'font/woff2',
|
|
22
|
+
'.map': 'application/json; charset=utf-8',
|
|
23
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// A page that can read node state must not be embeddable anywhere, and must not
|
|
27
|
+
// be able to reach off-origin scripts. Everything here is same-origin.
|
|
28
|
+
//
|
|
29
|
+
// style-src: no `'unsafe-inline'` anywhere, and none for `style-src-attr` either.
|
|
30
|
+
// That allowance existed because this UI drew bars and progress widths with
|
|
31
|
+
// data-driven `style="width:42%"` attributes injected through innerHTML, which
|
|
32
|
+
// `style-src 'self'` refuses. The correct fix was never to widen the policy: the
|
|
33
|
+
// markup now carries `data-w`/`data-left` and the width is written through the
|
|
34
|
+
// CSSOM (app.js `applyDataSizes`), which CSP permits, and every static `style=`
|
|
35
|
+
// became a class. See docs/DEFECTS.md and test/csp.test.js — a test now fails if a
|
|
36
|
+
// `style="` comes back into the shipped HTML or JS.
|
|
37
|
+
export const CSP_BASE = [
|
|
38
|
+
"default-src 'self'",
|
|
39
|
+
"script-src 'self'", // + ' nonce-…' per response, see securityHeaders()
|
|
40
|
+
"style-src 'self'",
|
|
41
|
+
"img-src 'self' data:",
|
|
42
|
+
"font-src 'self'",
|
|
43
|
+
"connect-src 'self'",
|
|
44
|
+
// SSE is same-origin EventSource; no third-party anything.
|
|
45
|
+
"frame-ancestors 'none'",
|
|
46
|
+
"base-uri 'self'",
|
|
47
|
+
"form-action 'self'",
|
|
48
|
+
"object-src 'none'",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
export const SECURITY_HEADERS = {
|
|
52
|
+
'X-Content-Type-Options': 'nosniff',
|
|
53
|
+
'X-Frame-Options': 'DENY',
|
|
54
|
+
'Referrer-Policy': 'no-referrer',
|
|
55
|
+
'Permissions-Policy': 'geolocation=(), microphone=(), camera=(), payment=(), usb=()',
|
|
56
|
+
'Content-Security-Policy': CSP_BASE.join('; '),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Headers for one response.
|
|
61
|
+
*
|
|
62
|
+
* `nonce` — a per-response script nonce, so an inline `<script>` in a page is
|
|
63
|
+
* possible at all. Until now `script-src 'self'` meant that adding one would fail
|
|
64
|
+
* in the browser with no server-side trace, which is the same "silently broken"
|
|
65
|
+
* shape as the module-level ReferenceError that blanked this UI for a day. Pages
|
|
66
|
+
* are rewritten through `renderHtml()` so a placeholder can receive the nonce.
|
|
67
|
+
*
|
|
68
|
+
* `tls` — HSTS is only meaningful, and only safe, over TLS. Sending it over plain
|
|
69
|
+
* HTTP instructs a browser to pin an upgrade path that the server cannot serve.
|
|
70
|
+
*/
|
|
71
|
+
export function securityHeaders({ nonce = null, tls = false, hstsMs = 0 } = {}) {
|
|
72
|
+
const csp = nonce
|
|
73
|
+
? [...CSP_BASE.slice(0, 1), `script-src 'self' 'nonce-${nonce}'`, ...CSP_BASE.slice(2)]
|
|
74
|
+
: CSP_BASE;
|
|
75
|
+
const h = { ...SECURITY_HEADERS, 'Content-Security-Policy': csp.join('; ') };
|
|
76
|
+
if (tls && hstsMs > 0) h['Strict-Transport-Security'] = `max-age=${Math.floor(hstsMs / 1000)}`;
|
|
77
|
+
return h;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One usable nonce per response. 16 bytes is what the CSP spec's example uses. */
|
|
81
|
+
export function newNonce() {
|
|
82
|
+
return crypto.randomBytes(16).toString('base64');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Rewrite an HTML document: nonce into the placeholders, build id into the asset
|
|
87
|
+
* URLs and the `data-blockyard-build` attribute.
|
|
88
|
+
*
|
|
89
|
+
* The cache-busting is the point of the build stamp, not a side effect: a tab that
|
|
90
|
+
* quietly runs yesterday's app.js was indistinguishable from one running today's,
|
|
91
|
+
* and every "did my fix land?" cost fifteen minutes for want of it (DEFECTS). The
|
|
92
|
+
* footer now compares the build this page loaded against what the server says is
|
|
93
|
+
* current, and says so in words when they differ.
|
|
94
|
+
*/
|
|
95
|
+
export function renderHtml(src, { nonce, build }) {
|
|
96
|
+
return String(src)
|
|
97
|
+
.replace(/%BLOCKYARD_NONCE%/g, nonce ?? '')
|
|
98
|
+
.replace(/%BLOCKYARD_BUILD%/g, build ?? 'dev')
|
|
99
|
+
// Only assets get a version. Pages are `Cache-Control: no-cache` already, and
|
|
100
|
+
// stamping `href="/"` would put `?v=` on the dashboard link, where the query
|
|
101
|
+
// string is reserved for `?node=` and `?range=` -- a cosmetic rewrite that
|
|
102
|
+
// touches the app's own parameter space is not cosmetic.
|
|
103
|
+
.replace(/\b(href|src)="(\/[^"?]*\/[^"?]*\.(?:js|css|svg|png|jpe?g|webp|ico|woff2|map))"/g,
|
|
104
|
+
(m, attr, url) => `${attr}="${url}?v=${build}"`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A build id: version + a digest over the asset file sizes and mtimes.
|
|
109
|
+
*
|
|
110
|
+
* Deliberately not a git SHA. `data/` and this tree are deployed by copy on this
|
|
111
|
+
* box, and an id derived from files answers the actual question — "is the code in
|
|
112
|
+
* this tab the code on disk?" — where a SHA would be a lie about a working tree
|
|
113
|
+
* that was never committed at the moment of boot.
|
|
114
|
+
*/
|
|
115
|
+
export async function computeBuildId(root, version = '0.0.0') {
|
|
116
|
+
const h = crypto.createHash('sha256');
|
|
117
|
+
const walk = async (dir, prefix = '') => {
|
|
118
|
+
for (const ent of await fsp.readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
119
|
+
const p = path.join(dir, ent.name);
|
|
120
|
+
if (ent.isDirectory()) { await walk(p, `${prefix}${ent.name}/`); continue; }
|
|
121
|
+
const st = await fsp.stat(p).catch(() => null);
|
|
122
|
+
if (!st) continue;
|
|
123
|
+
h.update(`${prefix}${ent.name}:${st.size}:${Math.floor(st.mtimeMs)}`);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
await walk(root);
|
|
127
|
+
return `${version}-${h.digest('hex').slice(0, 10)}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export class StaticFiles {
|
|
131
|
+
constructor(root, { version = '0.0.0', tls = false, hstsMs = 0 } = {}) {
|
|
132
|
+
this.root = path.resolve(root);
|
|
133
|
+
this.version = version;
|
|
134
|
+
this.tls = tls;
|
|
135
|
+
this.hstsMs = hstsMs;
|
|
136
|
+
this._build = null;
|
|
137
|
+
this._buildAt = 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Cached for 2 s: a page load resolves index.html, then its assets, in a burst. */
|
|
141
|
+
async buildId() {
|
|
142
|
+
if (!this._build || Date.now() - this._buildAt > 2000) {
|
|
143
|
+
this._build = await computeBuildId(this.root, this.version);
|
|
144
|
+
this._buildAt = Date.now();
|
|
145
|
+
}
|
|
146
|
+
return this._build;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async resolve(urlPath) {
|
|
150
|
+
let decoded;
|
|
151
|
+
try { decoded = decodeURIComponent(urlPath.split('?')[0]); } catch { return { error: 400 }; }
|
|
152
|
+
if (decoded.includes('\0')) return { error: 400 };
|
|
153
|
+
if (decoded === '/') decoded = '/index.html';
|
|
154
|
+
if (decoded === '/login') decoded = '/login.html';
|
|
155
|
+
const candidate = path.join(this.root, path.normalize(decoded));
|
|
156
|
+
if (candidate !== this.root && !candidate.startsWith(this.root + path.sep)) return { error: 403 };
|
|
157
|
+
let st;
|
|
158
|
+
try {
|
|
159
|
+
st = await fsp.stat(candidate);
|
|
160
|
+
} catch {
|
|
161
|
+
return { error: 404 };
|
|
162
|
+
}
|
|
163
|
+
if (st.isDirectory()) return { error: 404 };
|
|
164
|
+
// The decisive check: the resolved target must still be inside the root.
|
|
165
|
+
let real;
|
|
166
|
+
try { real = await fsp.realpath(candidate); } catch { return { error: 404 }; }
|
|
167
|
+
const realRoot = await fsp.realpath(this.root).catch(() => this.root);
|
|
168
|
+
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return { error: 403 };
|
|
169
|
+
return { file: real, stat: st, ext: path.extname(candidate).toLowerCase() };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async serve(req, res, urlPath, { send = true, status = 200 } = {}) {
|
|
173
|
+
const r = await this.resolve(urlPath);
|
|
174
|
+
if (r.error) return r;
|
|
175
|
+
const etag = `W/"${r.stat.size.toString(16)}-${Math.floor(r.stat.mtimeMs).toString(16)}"`;
|
|
176
|
+
// HTML is rewritten per response (nonce + build id), so its body is not the
|
|
177
|
+
// file on disk: no ETag conditional, and Content-Length must be recomputed.
|
|
178
|
+
const isHtml = r.ext === '.html';
|
|
179
|
+
const nonce = isHtml ? newNonce() : null;
|
|
180
|
+
const headers = {
|
|
181
|
+
...securityHeaders({ nonce, tls: this.tls, hstsMs: this.hstsMs }),
|
|
182
|
+
'Content-Type': MIME[r.ext] ?? 'application/octet-stream',
|
|
183
|
+
'Cache-Control': 'no-cache',
|
|
184
|
+
ETag: etag,
|
|
185
|
+
'Last-Modified': new Date(r.stat.mtimeMs).toUTCString(),
|
|
186
|
+
};
|
|
187
|
+
if (!isHtml && req.headers['if-none-match'] === etag) {
|
|
188
|
+
res.writeHead(304, { ETag: etag, 'Cache-Control': headers['Cache-Control'], ...securityHeaders({ tls: this.tls, hstsMs: this.hstsMs }) });
|
|
189
|
+
res.end();
|
|
190
|
+
return { status: 304, file: r.file };
|
|
191
|
+
}
|
|
192
|
+
if (isHtml) {
|
|
193
|
+
const raw = await fsp.readFile(r.file).catch(() => null);
|
|
194
|
+
if (!raw) return { error: 404 };
|
|
195
|
+
const body = renderHtml(raw.toString('utf8'), { nonce, build: await this.buildId() });
|
|
196
|
+
headers['Content-Length'] = Buffer.byteLength(body);
|
|
197
|
+
if (!send) { res.writeHead(status, headers); res.end(); return { status, file: r.file }; }
|
|
198
|
+
res.writeHead(status, headers);
|
|
199
|
+
res.end(req.method === 'HEAD' ? undefined : body);
|
|
200
|
+
return { status, file: r.file, etag };
|
|
201
|
+
}
|
|
202
|
+
// The caller may be serving a real 404/403 *page*; the body being a file
|
|
203
|
+
// that exists does not make the request successful.
|
|
204
|
+
if (!send) { res.writeHead(status, headers); res.end(); return { status, file: r.file }; }
|
|
205
|
+
res.writeHead(status, headers);
|
|
206
|
+
const stream = (await import('node:fs')).createReadStream(r.file);
|
|
207
|
+
stream.on('error', () => res.destroy());
|
|
208
|
+
stream.pipe(res);
|
|
209
|
+
return { status: 200, file: r.file, etag };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|