#!/usr/bin/env node // ============================================================================= // The Dojo Bay — directory updater // // Probes every node's .onion pairing endpoint over Tor and rewrites the two // JSON databases the website reads: // // data/dojos.json current snapshot -> node.status + node.checked_at // data/history.json rolling history -> one {t, up} per node, per run // // dojos.json is also the source of truth for the node LIST. To add or remove a // node, edit dojos.json (name, paynym, payload, etc.); this script only fills // in status/checked_at and appends to the history. New nodes get a fresh // history series automatically; removed nodes are retired under a grace stamp // and only pruned HISTORY_GRACE_DAYS (default 14) after leaving the list. // // Health is checked through Tor's SOCKS5 proxy (no external npm deps). For a // node whose pairing payload carries an apikey, the check logs in to the Dojo // API and reads info.latest_block.height from GET /v2/wallet: the node is // "active" only if it returns a chain tip, which proves the whole stack (Tor, // nginx, Dojo API, bitcoind) is serving block data, and the height is recorded // on the node. Nodes without an apikey fall back to a plain HTTP reachability // probe (active if the onion returns an HTTP response line). // // Every Dojo response carries its running version in the X-Dojo-Version header; // the probe reads it and records node.detected_version, so a card can show the // live version rather than the one frozen into the pairing payload at signing // time. build-public.mjs decides the effective version an operator override // still wins over it. // // Run once (intended to be driven by cron/systemd every 10 minutes): // node scripts/update.mjs // // Config via environment variables (all optional): // TOR_SOCKS_HOST default 127.0.0.1 // TOR_SOCKS_PORT default 9050 // DATA_DIR default /data // TIMEOUT_MS default 45000 per-node Tor timeout // CONCURRENCY default 3 simultaneous Tor circuits // WINDOW_CHECKS default 144 history length kept per node (24h @ 10min) // RETENTION_DAYS default 90 daily-rollup days kept per node (~3 months) // CONNECT_ONLY default 0 "1" = treat a successful Tor connect as up // without waiting for an HTTP response line // DOJO_VERSION_HEADER default X-Dojo-Version response header carrying the // node's running Dojo version // ============================================================================= import net from "node:net"; import { retireUnlisted } from "../server/build-public.ts"; import { readFile, writeFile, rename, stat as fsStat, mkdir as fsMkdir } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Chosen for a home connection as much as a VPS, because the unit that would // override them lives in /etc and no update can reach it. A node answering at // 23 seconds was being recorded as down against a 30 second ceiling, and six // circuits at once through one Tor client on a domestic line makes every probe // slow together, which reads as every node being down. export const DEFAULT_TIMEOUT_MS = 45000; export const DEFAULT_CONCURRENCY = 3; const CFG = { proxyHost: process.env.TOR_SOCKS_HOST || "127.0.0.1", proxyPort: +(process.env.TOR_SOCKS_PORT || 9050), dataDir: process.env.DATA_DIR || path.resolve(__dirname, "..", "data"), timeoutMs: +(process.env.TIMEOUT_MS || DEFAULT_TIMEOUT_MS), concurrency: +(process.env.CONCURRENCY || DEFAULT_CONCURRENCY), windowChecks: +(process.env.WINDOW_CHECKS || 144), retentionDays: +(process.env.RETENTION_DAYS || 90), connectOnly: process.env.CONNECT_ONLY === "1", // The Dojo API stamps its running version on every response via this header // (Dojo's http-server appends X-Dojo-Version: as global // middleware). Read it during the probe so a node's displayed version tracks // what it is actually running, instead of the value frozen into its pairing // payload at submission time. Overridable in case a fork renames the header. dojoVersionHeader: (process.env.DOJO_VERSION_HEADER || "X-Dojo-Version").toLowerCase(), }; // ---- SOCKS5 reply codes (RFC 1928 §6) --------------------------------------- const SOCKS_ERR = { 0x01: "general failure", 0x02: "connection not allowed", 0x03: "network unreachable", 0x04: "host unreachable", // Tor: onion descriptor not found / service down 0x05: "connection refused", 0x06: "TTL expired", 0x07: "command not supported", 0x08: "address type not supported", }; class SocksError extends Error { constructor(code) { super("SOCKS " + (SOCKS_ERR[code] || "error 0x" + code.toString(16))); this.code = code; } } // ----------------------------------------------------------------------------- // Open a TCP stream to host:port THROUGH a SOCKS5 proxy (Tor), using a remote // hostname so the .onion is resolved by Tor, not locally. Resolves with a // connected socket on success; rejects on any handshake/connect failure. // ----------------------------------------------------------------------------- export function socks5Connect(proxyHost, proxyPort, host, port, timeoutMs) { return new Promise((resolve, reject) => { const socket = net.connect(proxyPort, proxyHost); let stage = "greet"; let buf = Buffer.alloc(0); let settled = false; const fail = (e) => { if (settled) return; settled = true; clearTimeout(timer); socket.destroy(); reject(e instanceof Error ? e : new Error(String(e))); }; const timer = setTimeout(() => fail(new Error("timeout")), timeoutMs); socket.once("connect", () => { // greeting: VER=5, NMETHODS=1, METHOD=0 (no auth) socket.write(Buffer.from([0x05, 0x01, 0x00])); }); socket.on("error", fail); socket.on("close", () => fail(new Error("proxy closed"))); socket.on("data", (d) => { buf = Buffer.concat([buf, d]); if (stage === "greet") { if (buf.length < 2) return; if (buf[0] !== 0x05 || buf[1] !== 0x00) return fail(new Error("proxy refused no-auth handshake")); buf = buf.subarray(2); stage = "reply"; // CONNECT request with ATYP=3 (domain name), so Tor resolves the onion const hb = Buffer.from(host, "utf8"); socket.write(Buffer.concat([ Buffer.from([0x05, 0x01, 0x00, 0x03, hb.length]), hb, Buffer.from([(port >> 8) & 0xff, port & 0xff]), ])); } if (stage === "reply") { if (buf.length < 4) return; if (buf[1] !== 0x00) return fail(new SocksError(buf[1])); const atyp = buf[3]; const addrLen = atyp === 0x01 ? 4 : atyp === 0x04 ? 16 : atyp === 0x03 ? (buf.length >= 5 ? 1 + buf[4] : Infinity) : 0; if (buf.length < 4 + addrLen + 2) return; // wait for the full bound-addr // success: hand the live stream back to the caller settled = true; clearTimeout(timer); socket.removeAllListeners("data"); socket.removeAllListeners("error"); socket.removeAllListeners("close"); resolve(socket); } }); }); } // Well-formed dummy extended keys, used only to elicit info.latest_block from // the Dojo /wallet endpoint. They are passed as `new` so the node performs no // rescan or historical import; they derive from a throwaway seed and can never // receive funds. One per network so the Dojo never rejects them on format. const DUMMY_XPUB = "xpub661MyMwAqRbcFhv1kNXxwyGrJUVPrmiBNTVDYAtpzF5zu9ceuhn5yV6oaSdveis14LSeBLzpWb58pDNN6hC59TTDyiN7iJR7kUQgXNMfZCL"; const DUMMY_TPUB = "tpubD6NzVbkrYhZ4XW6sCZX49tcDdbb3rADEv65WtiwyL9qteSHMyvdB7vmdpUiiBDpErEyYnvWh3guBWPryVZ3K2tuX3K7RPq5MLS16HN9awey"; // The most bytes a response may accumulate before the read is abandoned. // // Every caller of httpOverTor is talking to a machine somebody else controls: // that is the point of the probe. Without a ceiling the reader accumulates // whatever arrives until the socket closes or the timeout fires, so a listed // node that simply never stops sending can push thirty seconds of Tor // throughput into the heap, times CONCURRENCY parallel probes, on a VPS whose // documented minimum is 1 GB. Nothing about that requires malice: a Dojo // misconfigured to return a file rather than JSON does it by accident. // // 2 MiB is chosen against the largest legitimate response any probe path sees, // which is a Dojo /wallet reply for two dummy xpubs, single-digit kilobytes. // A PayNym avatar is a small PNG and sits under the same ceiling comfortably; // it does not get a tighter limit of its own, because a second constant would // have to be kept in a sensible relationship with this one, and 2 MiB already // bounds the disk that syncAvatars can consume to a few tens of megabytes // across every listed code. The one caller that legitimately needs more is // self-update fetching a peer's source zip, and it passes its own value. export const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; // The unauthenticated probe reads only until it recognises an HTTP status line, // so it needs a far smaller ceiling than a full response: this bounds how long // it will listen to something that is not speaking HTTP at all. export const MAX_STATUS_LINE_BYTES = 64 * 1024; // Send one HTTP/1.0 request over a fresh Tor stream and read the whole reply // (Connection: close means the server ends the body by closing). Resolves with // { status, body } or rejects on connect failure, read timeout, or a reply that // runs past maxBytes. export function httpOverTor(cfg, host, port, rawRequest, timeoutMs, maxBytes = MAX_RESPONSE_BYTES) { return new Promise(async (resolve, reject) => { let socket; try { socket = await socks5Connect(cfg.proxyHost, cfg.proxyPort, host, port, timeoutMs); } catch (e) { return reject(e); } let buf = Buffer.alloc(0); let settled = false; const done = (fn, v) => { if (settled) return; settled = true; clearTimeout(timer); try { socket.destroy(); } catch {} fn(v); }; const timer = setTimeout(() => done(reject, new Error("read-timeout")), timeoutMs); socket.on("data", (d) => { buf = Buffer.concat([buf, d]); // Rejected the moment the ceiling is crossed rather than at close, so the // socket is destroyed and the memory released now. Waiting would mean a // node that never closes still occupies the full timeout while holding // everything it has sent. done() destroys the socket, so no further data // events arrive and the partial buffer goes out of scope with this call. if (buf.length > maxBytes) { done(reject, new Error(`response exceeded ${maxBytes} bytes`)); } }); socket.on("error", (e) => done(reject, e)); socket.on("close", () => { const s = buf.toString("latin1"); const m = s.match(/^HTTP\/1\.[01] (\d{3})/); const i = s.indexOf("\r\n\r\n"); done(resolve, { status: m ? +m[1] : 0, body: i >= 0 ? s.slice(i + 4) : "", rawHead: i >= 0 ? s.slice(0, i + 2) : s, // headers incl. trailing CRLF bodyBuf: i >= 0 ? buf.subarray(i + 4) : Buffer.alloc(0), // exact bytes for binary payloads }); }); socket.write(rawRequest); }); } // ---- Dojo version from response headers ------------------------------------- // The Dojo API sets its running version on every response (X-Dojo-Version). We // read it opportunistically while probing so the card can show the live value. // A node is only semi-trusted, so the value is validated and length-capped // before it can reach a data file: a version looks like 1, 1.28, 1.28.0 or // 1.28.0-rc1, with an optional leading v that we strip. Anything else -> null. export function normaliseVersion(raw) { if (typeof raw !== "string") return null; const v = raw.trim().replace(/^v/i, "").trim(); if (!v || v.length > 32) return null; return /^\d+(\.\d+){0,3}([-+][0-9A-Za-z.]+)?$/.test(v) ? v : null; } // Pull the version out of a raw header block (the CRLF-joined header lines from // httpOverTor's rawHead, or the accumulated first bytes of a plain probe). // Header names are case-insensitive; the first occurrence wins. export function parseDojoVersion(rawHead, headerName = CFG.dojoVersionHeader) { if (typeof rawHead !== "string" || !rawHead) return null; const name = String(headerName).toLowerCase(); for (const line of rawHead.split(/\r?\n/)) { const idx = line.indexOf(":"); if (idx < 0) continue; if (line.slice(0, idx).trim().toLowerCase() !== name) continue; return normaliseVersion(line.slice(idx + 1)); } return null; } // ---- Electrum (indexer) endpoint from /support/services --------------------- // Dojo v1.27.0 added GET /support/services (ordinary apikey auth, not admin), // which returns { services: [ { type, kind, url }, … ] }. The "indexer" entry // is the node's Electrum server, published by the Dojo as // "://:" and present only when the operator exposes a // local indexer. Older Dojos have no such route, so absence is normal and is // reported as "not found" rather than an error. export function parseIndexerUrl(body) { let doc; try { doc = JSON.parse(body); } catch { return null; } const list = Array.isArray(doc?.services) ? doc.services : null; if (!list) return null; const hit = list.find((s) => s && s.type === "indexer" && typeof s.url === "string"); return hit ? normaliseIndexerUrl(hit.url) : null; } // A listed node is only semi-trusted, so the URL is validated and length-capped // before it can reach a data file or be rendered as a copyable string. Same // shape the card already accepts: tcp/ssl, v3 onion, explicit port. export function normaliseIndexerUrl(raw) { if (typeof raw !== "string") return null; const u = raw.trim(); if (!u || u.length > 120) return null; return /^(tcp|ssl):\/\/[a-z2-7]{56}\.onion:\d{2,5}$/i.test(u) ? u : null; } // ---- PayNym avatars --------------------------------------------------------- // Cards embed each node's PayNym avatar in the centre of its pairing QR. The // front end never fetches from third parties, so the avatar is mirrored here: // downloaded over Tor from the paynym.rs onion and served locally from // data/avatars/.png. Missing files are fetched every cycle (which // also covers newly approved nodes within ten minutes) and existing ones are // refreshed weekly. Only verified PNG bytes are written; anything else -- an // error page, a redirect chain, an empty body -- is skipped without touching // the file, and failures are logged, never fatal. const PAYNYM_ONION = process.env.PAYNYM_ONION_HOST || "paynym25chftmsywv4v2r67agbrr62lcxagsf4tymbzpeeucucy2ivad.onion"; const AVATAR_MAX_AGE_MS = 7 * 86400000; const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47]); /** * @param {string} paymentCode * @param {{ proxyHost?: string, proxyPort?: number, destDir?: string, * timeoutMs?: number, host?: string, port?: number }} [opts] */ export async function fetchAvatar(paymentCode, { proxyHost, proxyPort, destDir, timeoutMs = 25000, host = PAYNYM_ONION, port = 80 } = {}) { const cfg = { proxyHost, proxyPort }; let pathPart = `/${encodeURIComponent(paymentCode)}/avatar`; for (let hop = 0; hop < 2; hop++) { // follow at most one same-host redirect const req = `GET ${pathPart} HTTP/1.0\r\nHost: ${host}\r\nUser-Agent: dojobay-checker\r\nConnection: close\r\n\r\n`; const res = await httpOverTor(cfg, host, port, req, timeoutMs); if ([301, 302, 307, 308].includes(res.status)) { const m = res.rawHead && res.rawHead.match(/\r\nlocation:\s*([^\r\n]+)/i); if (!m) throw new Error("redirect without location"); const loc = m[1].trim(); if (/^https?:\/\//i.test(loc)) { const u = new URL(loc); if (u.hostname !== host) throw new Error("cross-host redirect"); pathPart = u.pathname + u.search; } else pathPart = loc; continue; } if (res.status !== 200) throw new Error(`HTTP ${res.status || "no-response"}`); const bytes = res.bodyBuf || Buffer.from(res.body, "latin1"); if (bytes.length < 8 || !bytes.subarray(0, 4).equals(PNG_MAGIC)) throw new Error("not a PNG"); await fsMkdir(destDir, { recursive: true }); const dest = path.join(destDir, `${paymentCode}.png`); const atmp = tmpName(dest); await writeFile(atmp, bytes); await rename(atmp, dest); return dest; } throw new Error("too many redirects"); } // Ensure a local avatar exists (and is reasonably fresh) for every listed // payment code. Small concurrency; per-code failures are logged and skipped. async function syncAvatars(nodes, destDir) { const codes = [...new Set(nodes.map((n) => n.paymentCode).filter(Boolean))]; const wanted = []; for (const code of codes) { try { const st = await fsStat(path.join(destDir, `${code}.png`)); if (Date.now() - st.mtimeMs < AVATAR_MAX_AGE_MS) continue; } catch { /* missing -> fetch */ } wanted.push(code); } let i = 0; const worker = async () => { for (;;) { const code = wanted[i++]; if (!code) return; try { await fetchAvatar(code, { proxyHost: CFG.proxyHost, proxyPort: CFG.proxyPort, destDir }); console.error(`[avatar] fetched ${code.slice(0, 12)}…`); } catch (e) { console.error(`[avatar] ${code.slice(0, 12)}…: ${e.message}`); } } }; await Promise.all(Array.from({ length: Math.min(3, wanted.length) }, worker)); } // Authenticated health check: log in with the node's apikey, then read the // chain tip from GET /v2/wallet. The Dojo stamps X-Dojo-Version on every // response, so we harvest it from the first response that carries it (the login // reply always does) even on an otherwise-down cycle. Returns // { up, reason, ms, height?, blockTime?, detectedVersion? }. async function probeHeight(url, cfg) { const t0 = Date.now(); const u = new URL(url); const host = u.hostname; const port = u.port ? +u.port : 80; const base = (u.pathname || "/v2").replace(/\/+$/, "") || "/v2"; // e.g. /v2 const dummy = cfg.network === "testnet" ? DUMMY_TPUB : DUMMY_XPUB; let detectedVersion = null; // 1) login -> access token let token; try { const body = `apikey=${encodeURIComponent(cfg.apikey)}`; const req = `POST ${base}/auth/login HTTP/1.0\r\nHost: ${host}\r\n` + `Content-Type: application/x-www-form-urlencoded\r\nContent-Length: ${Buffer.byteLength(body)}\r\n` + `User-Agent: dojobay-checker\r\nConnection: close\r\n\r\n${body}`; const res = await httpOverTor(cfg, host, port, req, cfg.timeoutMs); detectedVersion = parseDojoVersion(res.rawHead, cfg.dojoVersionHeader) || detectedVersion; if (res.status !== 200) return { up: false, reason: `login HTTP ${res.status || "no-response"}`, ms: Date.now() - t0, detectedVersion }; token = JSON.parse(res.body)?.authorizations?.access_token; if (!token) return { up: false, reason: "login: no token", ms: Date.now() - t0, detectedVersion }; } catch (e) { return { up: false, reason: "login: " + e.message, ms: Date.now() - t0, detectedVersion }; } // 2) wallet -> info.latest_block.height try { const q = `active=${dummy}&new=${dummy}`; const req = `GET ${base}/wallet?${q} HTTP/1.0\r\nHost: ${host}\r\n` + `Authorization: Bearer ${token}\r\nUser-Agent: dojobay-checker\r\nConnection: close\r\n\r\n`; const res = await httpOverTor(cfg, host, port, req, cfg.timeoutMs); detectedVersion = detectedVersion || parseDojoVersion(res.rawHead, cfg.dojoVersionHeader); if (res.status !== 200) return { up: false, reason: `wallet HTTP ${res.status || "no-response"}`, ms: Date.now() - t0, detectedVersion }; const info = JSON.parse(res.body)?.info?.latest_block; const height = info?.height; if (typeof height !== "number") return { up: false, reason: "wallet: no block height", ms: Date.now() - t0, detectedVersion }; // 3) services -> Electrum (indexer) endpoint. Best-effort and strictly // additive: the node is already known up, so a missing route (pre-1.27.0), // a node that exposes no indexer, or any error here must never downgrade // the result. Absence simply means the card shows N/A. let detectedIndexer = null; try { const sreq = `GET ${base}/support/services HTTP/1.0\r\nHost: ${host}\r\n` + `Authorization: Bearer ${token}\r\nUser-Agent: dojobay-checker\r\nConnection: close\r\n\r\n`; const sres = await httpOverTor(cfg, host, port, sreq, cfg.timeoutMs); detectedVersion = detectedVersion || parseDojoVersion(sres.rawHead, cfg.dojoVersionHeader); if (sres.status === 200) detectedIndexer = parseIndexerUrl(sres.body); } catch { /* leave null */ } return { up: true, reason: "height", height, blockTime: info.time ?? null, ms: Date.now() - t0, detectedVersion, detectedIndexer }; } catch (e) { return { up: false, reason: "wallet: " + e.message, ms: Date.now() - t0, detectedVersion }; } } // ----------------------------------------------------------------------------- // Probe a single onion URL. Returns { up, reason, ms }. // up = Tor connected AND (CONNECT_ONLY, or an HTTP status line came back) // ----------------------------------------------------------------------------- // Fill in the transport settings a probe cannot work without. Callers pass a // partial config (an apikey and a network, say) and it is easy to forget to // spread PROBE_CFG or CFG alongside it; without these, net.connect is handed an // undefined port and Node reports 'The "options" or "port" or "path" argument // must be specified', which says nothing about the real mistake. The defaults // are the same ones PROBE_CFG uses, so a partial config now behaves rather than // failing obscurely. Explicitly supplied values always win. /** * @param {Partial} [cfg] * @returns {import("../types.js").ProbeCfg} */ export function probeCfg(cfg = {}) { return { ...cfg, proxyHost: cfg.proxyHost ?? (process.env.TOR_SOCKS_HOST || "127.0.0.1"), proxyPort: cfg.proxyPort ?? +(process.env.TOR_SOCKS_PORT || 9050), // Same default as CFG below, from one place. These were separate literals // and had already diverged: the cron path waited 45 seconds while anything // going through this helper waited 30, so the same node could be up for one // caller and down for the other. timeoutMs: cfg.timeoutMs ?? +(process.env.TIMEOUT_MS || DEFAULT_TIMEOUT_MS), concurrency: cfg.concurrency ?? +(process.env.CONCURRENCY || DEFAULT_CONCURRENCY), }; } /** * @param {string} url * @param {Partial} [cfgIn] * @returns {Promise} */ export async function probe(url, cfgIn = CFG) { const cfg = probeCfg(cfgIn); // Preferred path: authenticated chain-tip check when an apikey is available. if (cfg.apikey) return probeHeight(url, cfg); const u = new URL(url); const host = u.hostname; const port = u.port ? +u.port : (u.protocol === "https:" ? 443 : 80); const reqPath = (u.pathname || "/") + (u.search || ""); const t0 = Date.now(); let socket; try { socket = await socks5Connect(cfg.proxyHost, cfg.proxyPort, host, port, cfg.timeoutMs); } catch (e) { return { up: false, reason: e.message, ms: Date.now() - t0 }; } // TLS onions or connect-only mode: a successful Tor stream is the signal. if (cfg.connectOnly || u.protocol === "https:") { socket.destroy(); return { up: true, reason: u.protocol === "https:" ? "tls-connect" : "connect", ms: Date.now() - t0 }; } // Otherwise confirm the Dojo HTTP server actually answers. return await new Promise((resolve) => { let got = ""; let settled = false; const finish = (up, reason) => { if (settled) return; settled = true; clearTimeout(timer); socket.destroy(); // A code-less node has no apikey, so this is the only chance to read its // version; the header rides in the same first packet as the status line // often enough to be worth a look. Absent -> null, harmless. resolve({ up, reason, ms: Date.now() - t0, detectedVersion: parseDojoVersion(got, cfg.dojoVersionHeader) }); }; const timer = setTimeout(() => finish(got.length > 0, got ? "partial" : "read-timeout"), cfg.timeoutMs); socket.on("data", (d) => { got += d.toString("latin1"); if (/^HTTP\//i.test(got)) finish(true, "http"); // The same unbounded accumulation httpOverTor had, reached by a different // door. A well-behaved server puts its status line in the first packet // and the test above ends the read immediately, but a node that sends // anything NOT starting with "HTTP/" is never matched, so before this // guard `got` grew until the timeout with no ceiling at all. A status // line is a few dozen bytes; 64 KiB without one means this is not an HTTP // server, which is the answer the probe wanted anyway. else if (got.length > MAX_STATUS_LINE_BYTES) finish(false, "no-http-response"); }); socket.on("error", () => finish(got.length > 0, "socket-error")); socket.on("close", () => finish(got.length > 0, "closed")); socket.write( `HEAD ${reqPath} HTTP/1.0\r\nHost: ${host}\r\nUser-Agent: dojobay-checker\r\nConnection: close\r\n\r\n` ); }); } // ---- date helpers (UTC, matching the formats already in the JSON) ----------- const p2 = (n) => String(n).padStart(2, "0"); function stamps(d = new Date()) { const Y = d.getUTCFullYear(), M = p2(d.getUTCMonth() + 1), D = p2(d.getUTCDate()); const h = p2(d.getUTCHours()), m = p2(d.getUTCMinutes()), s = p2(d.getUTCSeconds()); return { isoSec: `${Y}-${M}-${D}T${h}:${m}:${s}Z`, // generated_at isoMin: `${Y}-${M}-${D}T${h}:${m}Z`, // history check timestamp dateTime: `${Y}-${M}-${D} ${h}:${m}:${s}`, // node.checked_at }; } // ---- small concurrency pool ------------------------------------------------- async function pool(items, limit, fn) { const out = new Array(items.length); let i = 0; const worker = async () => { while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx], idx); } }; await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); return out; } async function readJSON(file, fallback) { try { return JSON.parse(await readFile(file, "utf8")); } catch (e) { if (e.code === "ENOENT" && fallback !== undefined) return fallback; throw e; } } // A temporary name no other writer can take. // // Every atomic write here was `.tmp`, which is not atomic between // processes: two writers produce the same path, the first rename consumes it, // and the second fails with ENOENT on a file it had just written. That is not // hypothetical. The installer enables the update timer and then runs its own // first probe cycle, and once the timer gained a calendar schedule with // Persistent=true, enabling it fired a catch-up run immediately rather than // after two minutes. Two updaters wrote data/dojos.json.tmp at once and the // install ended by announcing failures on a directory that was already // updating. // // The pid and a counter are enough: the collision is between processes on one // machine, and the rename is what makes the swap atomic for readers. function tmpName(file) { return `${file}.${process.pid}.${(tmpSeq = (tmpSeq + 1) % 1e6)}.tmp`; } let tmpSeq = 0; // Write atomically: a reader (the website) never sees a half-written file. async function writeJSONAtomic(file, obj) { const tmp = tmpName(file); await writeFile(tmp, JSON.stringify(obj, null, 2) + "\n"); await rename(tmp, file); } // Merge seed + approved submissions into the public list (delegates to // server/build-public.mjs, which preserves live statuses and histories). // Exported so the self-test can drive it against isolated data directories. export async function reconcilePublicList() { if (!process.env.PUBLIC_DATA_DIR) process.env.PUBLIC_DATA_DIR = CFG.dataDir; const { rebuild } = await import("../server/build-public.ts"); return rebuild(); } // ----------------------------------------------------------------------------- async function main() { const dojosPath = path.join(CFG.dataDir, "dojos.json"); // Reconcile FIRST: fold the curated seed and every APPROVED submission into // dojos.json before this cycle reads it. The admin approve does its own // rebuild, but that write is lost if it lands while a probe cycle (minutes // long over Tor) is in flight, because the cycle writes back the node list // it read at the start. Rebuilding here means an approved node can be absent // for at most one cycle, never indefinitely. try { const r = await reconcilePublicList(); console.error(`[reconcile] ${r.msg}`); } catch (e) { console.error(`[reconcile] skipped: ${e.message}`); } const historyPath = path.join(CFG.dataDir, "history.json"); const dojos = await readJSON(dojosPath); if (!dojos || !Array.isArray(dojos.nodes)) throw new Error(`bad or missing ${dojosPath}`); // Keep the self-hosted source download current: regenerate the zip when it // is missing or older than data/version.json (i.e. after any code deploy). try { const zipPath = path.join(CFG.dataDir, "dojobay-src.zip"); const verPath = path.join(CFG.dataDir, "version.json"); const zipSt = await fsStat(zipPath).catch(() => null); const verSt = await fsStat(verPath).catch(() => null); if (!zipSt || (verSt && verSt.mtimeMs > zipSt.mtimeMs)) { const { packSource } = await import("./pack-source.mjs"); const r = await packSource({ outDir: CFG.dataDir }); console.error(`[src-zip] repacked: ${r.files} files, ${(r.bytes / 1024).toFixed(0)} KiB`); } } catch (e) { console.error(`[src-zip] skipped: ${e.message}`); } // Mirror PayNym avatars for every listed code (non-blocking for the probes). const operatorDoc = await readJSON(path.join(CFG.dataDir, "operator.json")).catch(() => null) ?? {}; const avatarSubjects = dojos.nodes.concat(operatorDoc.paymentCode ? [{ paymentCode: operatorDoc.paymentCode }] : []); const avatarsDone = syncAvatars(avatarSubjects, path.join(CFG.dataDir, "avatars")).catch((e) => console.error("[avatar]", e.message)); const history = await readJSON(historyPath, { interval_minutes: 10, window_checks: CFG.windowChecks, nodes: {} }); const window = history.window_checks || CFG.windowChecks; const now = new Date(); const ts = stamps(now); console.error(`[${ts.isoSec}] probing ${dojos.nodes.length} nodes via socks5h://${CFG.proxyHost}:${CFG.proxyPort} (timeout ${CFG.timeoutMs}ms, concurrency ${CFG.concurrency})`); const results = await pool(dojos.nodes, CFG.concurrency, async (n) => { const url = n?.payload?.pairing?.url; if (!url) return { up: false, reason: "no pairing url", ms: 0 }; return probe(url, { ...CFG, apikey: n?.payload?.pairing?.apikey, network: n.network }); }); // ---- did this cycle learn anything? ---- // // Fifteen independently operated nodes on different continents do not fail in // the same ten-minute window. When every one of them fails, the cause is here: // Tor rebuilding circuits after a suspend, a home connection renegotiating, // the daemon restarted underneath us. Recording that would write a DOWN check // against every operator in the directory and pull down reliability figures // this instance publishes about other people's machines, for a fault of its // own. So it is not recorded. // // The threshold is zero rather than a proportion. A cycle where some nodes // answer proves the local path works, and the ones that did not answer really // did not; only a clean sweep is evidence about this machine instead of about // them. A directory with one listing would trip this on a genuine outage, and // that is the right trade: withholding one node's bad cycle costs far less // than publishing a false one against everybody. const allFailed = dojos.nodes.length > 0 && results.every((r) => !r.up); // ---- update current snapshot ---- let up = 0; dojos.nodes.forEach((n, i) => { const r = results[i]; if (r.up) up++; n.status = r.up ? "active" : "inactive"; n.checked_at = ts.dateTime; // Record the tip height when we read one; keep the last known height on a // down cycle so the card can still show where the node last was. if (typeof r.height === "number") n.block_height = r.height; else if (!("block_height" in n)) n.block_height = null; // Same sticky rule for the version read from X-Dojo-Version: update it when // this cycle saw one, otherwise leave the last known value in place. The // effective card version (operator override > detected > pairing default) // is computed by build-public.mjs, which carries this field across the // reconcile rebuild that opens every cycle. if (r.detectedVersion) n.detected_version = r.detectedVersion; else if (!("detected_version" in n)) n.detected_version = null; // Same sticky rule for the Electrum endpoint read from /support/services: // keep the last known value when a cycle didn't read one, so a node that is // merely down for a cycle doesn't flip its card to N/A. build-public.mjs // computes the published value and carries this field across the rebuild. if (r.detectedIndexer) n.detected_indexer = r.detectedIndexer; else if (!("detected_indexer" in n)) n.detected_indexer = null; }); dojos.interval_minutes = dojos.interval_minutes || 10; if (allFailed) { // Publish the fault and nothing else. Statuses, heights and checked_at stay // as the last cycle that actually reached something left them, and // generated_at is deliberately not advanced, so the staleness banner keeps // measuring the age of real data rather than the age of a failure. const fresh = await readJSON(dojosPath, null); if (fresh) { fresh.probe_fault = { at: ts.isoSec, nodes: dojos.nodes.length }; await writeJSONAtomic(dojosPath, fresh); } console.error(`[${ts.isoSec}] every one of ${dojos.nodes.length} nodes failed, which is` + " almost certainly a fault here rather than all of them at once."); console.error(" Nothing was recorded: no statuses changed and no history written."); console.error(" Check Tor on this machine (systemctl status tor@default), and the clock."); return; } dojos.generated_at = ts.isoSec; delete dojos.probe_fault; // ---- update rolling history (append + trim, retire stale ids) ---- const listed = new Set(dojos.nodes.map((n) => n.id)); const histNodes = {}; dojos.nodes.forEach((n, i) => { const prev = (history.nodes?.[n.id]?.checks) || []; const checks = prev.concat([{ t: ts.isoMin, up: results[i].up }]); if (checks.length > window) checks.splice(0, checks.length - window); histNodes[n.id] = { checks }; }); // Unlisted ids are kept under a `retired` stamp for HISTORY_GRACE_DAYS (same // rule as build-public.mjs), so a bad or transient node list cannot destroy // accumulated history; a resurrected id resumes where it left off. for (const id of Object.keys(history.nodes || {})) if (!histNodes[id]) histNodes[id] = history.nodes[id]; retireUnlisted(histNodes, (id) => listed.has(id), ts.isoSec); await writeJSONAtomic(dojosPath, dojos); await writeJSONAtomic(historyPath, { generated_at: ts.isoSec, interval_minutes: history.interval_minutes || 10, window_checks: window, nodes: histNodes, }); // ---- update 90-day daily rollup (per-day uptime + closing block height) ---- // One record per node per UTC day; `close` is the last height read that day, // so at day's end it holds the closing height. Retained RETENTION_DAYS days. const dailyPath = path.join(CFG.dataDir, "history-daily.json"); const daily = await readJSON(dailyPath, { retention_days: CFG.retentionDays, nodes: {} }); const today = ts.dateTime.slice(0, 10); // YYYY-MM-DD (UTC) const dailyNodes = {}; dojos.nodes.forEach((n, i) => { const r = results[i]; const days = ((daily.nodes?.[n.id]?.days) || []).map((d) => ({ ...d })); let rec = days.length && days[days.length - 1].d === today ? days[days.length - 1] : null; if (!rec) { rec = { d: today, up: 0, total: 0, pct: 0, close: null }; days.push(rec); } rec.total += 1; if (r.up) rec.up += 1; rec.pct = Math.round((rec.up / rec.total) * 1000) / 10; if (typeof r.height === "number") rec.close = r.height; if (days.length > CFG.retentionDays) days.splice(0, days.length - CFG.retentionDays); dailyNodes[n.id] = { days }; }); for (const id of Object.keys(daily.nodes || {})) if (!dailyNodes[id]) dailyNodes[id] = daily.nodes[id]; retireUnlisted(dailyNodes, (id) => listed.has(id), ts.isoSec); await writeJSONAtomic(dailyPath, { generated_at: ts.isoSec, retention_days: CFG.retentionDays, nodes: dailyNodes, }); // ---- probe PENDING submissions so the operator sees uptime before approving // Results are written server-side only (server/data/pending-probe.json), never // to the public data/, so an unapproved submission is not exposed over Tor. try { const { store } = await import("../server/store.ts"); const serverDataDir = process.env.SERVER_DATA_DIR || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "server", "data"); const pendingPath = path.join(serverDataDir, "pending-probe.json"); const subs = (await store.listSubmissions()).filter((s) => s.status === "pending"); if (subs.length) { const prevDoc = await readJSON(pendingPath, { window_checks: window, nodes: {} }); const presults = await pool(subs, CFG.concurrency, async (s) => { const url = s?.payload?.pairing?.url; if (!url) return { up: false, reason: "no pairing url", ms: 0 }; return probe(url, { ...CFG, apikey: s?.payload?.pairing?.apikey, network: s.network }); }); const pnodes = {}; subs.forEach((s, i) => { const r = presults[i]; const prev = (prevDoc.nodes?.[s.id]?.checks) || []; const checks = prev.concat([{ t: ts.isoMin, up: r.up }]); if (checks.length > window) checks.splice(0, checks.length - window); pnodes[s.id] = { status: r.up ? "active" : "inactive", checked_at: ts.dateTime, block_height: typeof r.height === "number" ? r.height : (prevDoc.nodes?.[s.id]?.block_height ?? null), detected_version: r.detectedVersion || (prevDoc.nodes?.[s.id]?.detected_version ?? null), detected_indexer: r.detectedIndexer || (prevDoc.nodes?.[s.id]?.detected_indexer ?? null), checks, }; }); await writeJSONAtomic(pendingPath, { generated_at: ts.isoSec, window_checks: window, nodes: pnodes }); console.error(`[${ts.isoSec}] probed ${subs.length} pending submission(s)`); } } catch (e) { console.error(`[${ts.isoSec}] pending probe skipped: ${e.message}`); } console.error(`[${ts.isoSec}] done: ${up}/${dojos.nodes.length} active`); for (const [i, n] of dojos.nodes.entries()) { const r = results[i]; console.error(` ${r.up ? "UP " : "DOWN"} ${n.id.padEnd(28)} ${String(r.ms).padStart(6)}ms ${r.reason || ""}`); } await avatarsDone; // let in-flight avatar mirrors finish before the timer unit exits } if (import.meta.url === pathToFileURL(process.argv[1] || "").href) { main().catch((e) => { console.error("fatal:", e.message); process.exit(1); }); }