807 lines
39 KiB
JavaScript
807 lines
39 KiB
JavaScript
#!/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 <repo>/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: <DOJO_VERSION_TAG> 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
|
||
|
|
// "<tcp|ssl>://<onion>:<port>" 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/<paymentCode>.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<import("../types.js").ProbeCfg>} [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<import("../types.js").ProbeCfg>} [cfgIn]
|
||
|
|
* @returns {Promise<import("../types.js").ProbeResult>}
|
||
|
|
*/
|
||
|
|
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 `<file>.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); });
|
||
|
|
}
|