feat(dojobay): add DojoBay app (manifest, image, catalog, ports)
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
// Maintainer moderation CLI (run on the server by a maintainer over SSH).
|
||||
// node admin.mjs list show pending/approved/rejected
|
||||
// node admin.mjs approve <id> [paynym] approve a submission (optionally set its PayNym)
|
||||
// node admin.mjs reject <id> reject a submission
|
||||
// node admin.mjs remove <id> delete a submission outright
|
||||
// After approving/rejecting, run build-public.mjs to regenerate the public list.
|
||||
import { store } from "./store.ts";
|
||||
import { resolvePayNym } from "./paynym.mjs";
|
||||
|
||||
const [cmd, id, extra] = process.argv.slice(2);
|
||||
|
||||
function line(r) {
|
||||
return `${r.status.padEnd(8)} ${r.id.padEnd(26)} ${r.network.padEnd(7)} ${(r.paynym || "-").padEnd(18)} ${(r.name || "-").padEnd(18)} ${r.payload?.pairing?.url || ""}`;
|
||||
}
|
||||
|
||||
const cmds = {
|
||||
async list() {
|
||||
const subs = await store.listSubmissions();
|
||||
if (!subs.length) return console.log("(no submissions)");
|
||||
for (const r of subs.sort((a, b) => (a.status).localeCompare(b.status))) console.log(line(r));
|
||||
},
|
||||
async approve() {
|
||||
const r = await store.getSubmission(id);
|
||||
if (!r) return console.error("no such submission:", id);
|
||||
r.status = "approved";
|
||||
if (extra) {
|
||||
r.paynym = extra.startsWith("+") ? extra : "+" + extra; // maintainer override
|
||||
} else if (!r.paynym) {
|
||||
const resolved = await resolvePayNym((r.paymentCodes || [])[0]).catch(() => null);
|
||||
if (resolved) r.paynym = resolved;
|
||||
}
|
||||
r.updated_at = new Date().toISOString();
|
||||
await store.putSubmission(r);
|
||||
console.log("approved:", id, "paynym:", r.paynym || "(none set — pass one as the 3rd arg)");
|
||||
console.log("now run: node build-public.mjs");
|
||||
},
|
||||
async reject() {
|
||||
const r = await store.getSubmission(id);
|
||||
if (!r) return console.error("no such submission:", id);
|
||||
r.status = "rejected"; r.updated_at = new Date().toISOString();
|
||||
await store.putSubmission(r);
|
||||
console.log("rejected:", id, "(run build-public.mjs to drop it from the public list)");
|
||||
},
|
||||
async remove() {
|
||||
await store.deleteSubmission(id);
|
||||
console.log("removed:", id);
|
||||
},
|
||||
};
|
||||
|
||||
(cmds[cmd] || (async () => { console.log("usage: node admin.mjs [list|approve <id> [paynym]|reject <id>|remove <id>]"); }))()
|
||||
.then(() => process.exit(0))
|
||||
.catch((e) => { console.error("error:", e.message); process.exit(1); });
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — apply operator-signed pairing payload updates.
|
||||
//
|
||||
// Takes signed blocks an operator has sent out of band (a re-signed pairing
|
||||
// payload, a new apikey, a moved onion) and applies them to the store, doing
|
||||
// exactly what the submission gate would have done had they gone through the
|
||||
// site:
|
||||
//
|
||||
// 1. the block must parse, and its signature must be valid over its own text;
|
||||
// 2. the BIP47 code inside the signed text must derive the signing address;
|
||||
// 3. that code must already own a record here, which is how the update is
|
||||
// matched to a listing;
|
||||
// 4. the payload written is the one INSIDE the signed block, so what is
|
||||
// published is exactly what the operator attested to.
|
||||
//
|
||||
// The record's id is never changed, so its reliability history survives. Status
|
||||
// is left alone: an approved listing stays approved, a pending one stays pending.
|
||||
//
|
||||
// Usage, on the box:
|
||||
// cd /var/www/dojobay/server
|
||||
// node apply-signed-payload.ts blocks/*.txt # dry run
|
||||
// sudo systemctl stop dojobay-server.service
|
||||
// node apply-signed-payload.ts --apply blocks/*.txt
|
||||
// sudo systemctl start dojobay-server.service
|
||||
// node audit-signed.mjs
|
||||
//
|
||||
// Each file holds one signed block. `--id <record-id>` pins the target when a
|
||||
// payment code owns more than one listing. As with fix-payload-version, --apply
|
||||
// refuses to run while the service is up, because store.ts holds the store in
|
||||
// memory as a single writer and would overwrite the edit.
|
||||
// =============================================================================
|
||||
import { readFile, writeFile, rename, copyFile } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
// canonicalPairing is imported, never reimplemented: this tool must accept
|
||||
// exactly what the submission gate accepts, and a second definition of the
|
||||
// canonical message would diverge silently. server/selftest.mjs enforces it.
|
||||
import { parseSignedBlock, verifySignedPayload, notificationAddresses, repairSignedBlock, canonicalPairing } from "./crypto.ts";
|
||||
import type { StoreRecord } from "../types.js";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const APPLY = argv.includes("--apply");
|
||||
const FORCE = argv.includes("--force");
|
||||
const idFlag = argv.indexOf("--id");
|
||||
const PINNED_ID = idFlag >= 0 ? argv[idFlag + 1] : null;
|
||||
const FILES = argv.filter((a, i) =>
|
||||
!a.startsWith("--") && !(idFlag >= 0 && i === idFlag + 1));
|
||||
|
||||
const DIR = process.env.SERVER_DATA_DIR
|
||||
|| path.resolve(path.dirname(fileURLToPath(import.meta.url)), "data");
|
||||
const FILE = path.join(DIR, "store.json");
|
||||
|
||||
if (!FILES.length) {
|
||||
console.error("Usage: node apply-signed-payload.ts [--apply] [--id <record-id>] <file>…\n" +
|
||||
"Each file contains one BEGIN BITCOIN SIGNED MESSAGE block.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (APPLY && !FORCE) {
|
||||
let active = "";
|
||||
try { active = execFileSync("systemctl", ["is-active", "dojobay-server.service"], { encoding: "utf8" }).trim(); }
|
||||
catch (e: any) { active = (e.stdout || "").trim(); }
|
||||
if (active === "active") {
|
||||
console.error("REFUSING: dojobay-server.service is running.\n" +
|
||||
"The store is held in memory by the server and would overwrite this edit.\n" +
|
||||
" sudo systemctl stop dojobay-server.service\n" +
|
||||
" node apply-signed-payload.ts --apply <files…>\n" +
|
||||
" sudo systemctl start dojobay-server.service");
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const doc = JSON.parse(await readFile(FILE, "utf8"));
|
||||
const records: StoreRecord[] = Object.values(doc.submissions || {});
|
||||
|
||||
interface Planned { file: string; rec: StoreRecord; payload: any; signed: string; before: string; after: string; note: string | null }
|
||||
const planned: Planned[] = [];
|
||||
const refused: [string, string][] = [];
|
||||
|
||||
for (const file of FILES) {
|
||||
let signed: string;
|
||||
try { signed = await readFile(file, "utf8"); }
|
||||
catch (e: any) { refused.push([file, "cannot read: " + e.message]); continue; }
|
||||
|
||||
// Copying a block through chat, a form or a mail client routinely eats the
|
||||
// blank line before the BIP47 line, which the signature covers. Repair it if
|
||||
// a reconstruction verifies cryptographically; nothing is taken on trust.
|
||||
let note: string | null = null;
|
||||
const repaired = repairSignedBlock(signed);
|
||||
if (repaired) { signed = repaired.block; note = repaired.note; }
|
||||
|
||||
const parsed = parseSignedBlock(signed);
|
||||
if (!parsed) { refused.push([file, "not a recognisable signed block"]); continue; }
|
||||
if (!parsed.paymentCode) { refused.push([file, "the signed text has no BIP47 line, so it cannot be matched to an operator"]); continue; }
|
||||
|
||||
// The payload published is the one inside the signed block, never a
|
||||
// hand-copied version of it.
|
||||
let payload: any;
|
||||
try { payload = JSON.parse(parsed.pairingText); }
|
||||
catch { refused.push([file, "the signed text is not a bare pairing JSON"]); continue; }
|
||||
if (!payload?.pairing?.url || !payload?.pairing?.type) {
|
||||
refused.push([file, "the signed payload has no pairing.url/type"]); continue;
|
||||
}
|
||||
|
||||
const addrs = notificationAddresses(parsed.paymentCode);
|
||||
const v = verifySignedPayload({
|
||||
signedText: signed,
|
||||
expectedMessage: canonicalPairing(payload),
|
||||
expectedAddress: addrs,
|
||||
});
|
||||
if (!v.ok) { refused.push([file, v.error]); continue; }
|
||||
|
||||
const owned = records.filter((r) => (r.paymentCodes || []).includes(parsed.paymentCode!));
|
||||
const target = PINNED_ID ? owned.find((r) => r.id === PINNED_ID) : (owned.length === 1 ? owned[0] : undefined);
|
||||
if (!owned.length) {
|
||||
refused.push([file, `signature is valid, but ${parsed.paymentCode.slice(0, 12)}… owns no record here`]); continue;
|
||||
}
|
||||
if (!target) {
|
||||
refused.push([file, `that code owns ${owned.length} records (${owned.map((r) => r.id).join(", ")}); re-run with --id`]); continue;
|
||||
}
|
||||
|
||||
planned.push({
|
||||
file, rec: target, payload, signed: signed.trim(), note,
|
||||
before: target.payload?.pairing?.url || "(none)",
|
||||
after: payload.pairing.url,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Store: ${FILE}`);
|
||||
console.log(`Blocks read: ${FILES.length}\n`);
|
||||
|
||||
if (planned.length) {
|
||||
console.log(`Will update (${planned.length}):`);
|
||||
for (const p of planned) {
|
||||
console.log(` ${p.rec.id} (${p.rec.status}) from ${path.basename(p.file)}`);
|
||||
console.log(` url ${p.before}`);
|
||||
console.log(` -> ${p.after}`);
|
||||
const bv = p.rec.payload?.pairing?.version, av = p.payload.pairing.version;
|
||||
if (bv !== av) console.log(` version ${bv || "(none)"} -> ${av || "(none)"}`);
|
||||
if (!p.rec.signed) console.log(" (record was UNSIGNED; it gains a verified signature)");
|
||||
if (p.note) console.log(` note: ${p.note}, and the repaired block verifies`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
if (refused.length) {
|
||||
console.log(`Refused (${refused.length}):`);
|
||||
for (const [f, why] of refused) console.log(` ${path.basename(f)}: ${why}`);
|
||||
console.log("");
|
||||
}
|
||||
if (!planned.length) { console.log("Nothing to apply."); process.exit(refused.length ? 1 : 0); }
|
||||
|
||||
if (!APPLY) {
|
||||
console.log("DRY RUN — nothing written. Re-run with --apply (service stopped) to make these changes.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backup = `${FILE}.bak-${stamp}`;
|
||||
await copyFile(FILE, backup);
|
||||
const nowIso = new Date().toISOString();
|
||||
for (const p of planned) {
|
||||
const rec = doc.submissions[p.rec.id];
|
||||
rec.payload = p.payload; // exactly what was signed
|
||||
rec.signed = p.signed;
|
||||
rec.updated_at = nowIso;
|
||||
}
|
||||
// A temporary name no other writer can take; see build-public.ts. This tool
|
||||
// refuses to run while the service holds the store, so a collision needs two
|
||||
// maintenance tools at once, which is exactly the case nobody plans for.
|
||||
const tmp = `${FILE}.${process.pid}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(doc, null, 2) + "\n");
|
||||
await rename(tmp, FILE);
|
||||
|
||||
console.log(`Backup written: ${backup}`);
|
||||
console.log(`Applied ${planned.length} update(s).`);
|
||||
console.log("Start the service again, then run audit-signed.mjs; each updated record\n" +
|
||||
"should now read VERIFIED. The published dojos.json follows on the next\n" +
|
||||
"updater cycle, or immediately if you run build-public.mjs.");
|
||||
process.exit(refused.length ? 1 : 0);
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — audit stored signed pairing blocks.
|
||||
//
|
||||
// READ-ONLY. Walks every record in the submission store and re-checks its
|
||||
// stored `signed` block with exactly the gate the submit endpoint uses
|
||||
// (verifySignedPayload over canonicalPairing(payload), against the notification
|
||||
// address of the record's own payment code). Nothing is written, no network is
|
||||
// touched, and the store is only ever read.
|
||||
//
|
||||
// Why this exists: records approved before the signed-message parser was fixed
|
||||
// were checked by a parser that excised the BIP47 tail before verifying, so the
|
||||
// verdict they received then is not the verdict they would receive now. This
|
||||
// tells you whether anything was left behind.
|
||||
//
|
||||
// Run on the box as the deploy user:
|
||||
// cd /var/www/dojobay/server && node audit-signed.mjs
|
||||
// SERVER_DATA_DIR defaults to ./data, the same path the server uses; set it
|
||||
// only if your store lives elsewhere.
|
||||
//
|
||||
// Buckets:
|
||||
// VERIFIED the stored signature is valid for one of the record's codes
|
||||
// FAILED a signature is present but verifies for none of them
|
||||
// UNSIGNED no signature stored (pre-gate migration, or a code-less record)
|
||||
// ERROR the record could not be evaluated at all
|
||||
// Exits non-zero if anything is FAILED, ERROR or UNSIGNED, so it can back a
|
||||
// cron check. UNSIGNED counted as a failure since the signature became a
|
||||
// structural requirement: the store refuses to write such a record and the
|
||||
// rebuild withholds it, so one showing up here is not awaiting a decision.
|
||||
// =============================================================================
|
||||
import { store } from "./store.ts";
|
||||
import { verifySignedPayload, notificationAddresses, canonicalPairing } from "./crypto.ts";
|
||||
|
||||
const networkOf = (rec) => (rec.network === "testnet" ? "testnet" : "bitcoin");
|
||||
|
||||
// Exported so the test suite can assert this reproduces the gate's verdict.
|
||||
// This MUST mirror server/index.mjs's signature gate exactly: same canonical
|
||||
// message, and the same set of acceptable signing addresses. An earlier version
|
||||
// derived the notification address for the record's own network, which meant
|
||||
// every testnet listing was reported as failing even though the gate accepted
|
||||
// it, because a PayNym signs from its mainnet address whatever the node is.
|
||||
export function auditRecord(rec) {
|
||||
const codes = Array.isArray(rec.paymentCodes) ? rec.paymentCodes : [];
|
||||
if (!rec.signed) {
|
||||
return { bucket: "UNSIGNED", detail: codes.length ? "record has a payment code but no signed block" : "no signed block and no payment code" };
|
||||
}
|
||||
const net = networkOf(rec);
|
||||
const expectedMessage = canonicalPairing(rec.payload);
|
||||
const tried = [];
|
||||
// A PayNym may have signed with either BIP47 variant, so every code on the
|
||||
// record is a legitimate candidate; the first that verifies wins.
|
||||
for (const code of codes) {
|
||||
const addrs = notificationAddresses(code);
|
||||
if (!addrs.length) { tried.push(`${code.slice(0, 12)}…: undecodable code`); continue; }
|
||||
const r = verifySignedPayload({ signedText: rec.signed, expectedMessage, expectedAddress: addrs, network: net });
|
||||
if (r.ok) return { bucket: "VERIFIED", detail: `${code.slice(0, 12)}… → ${addrs[0]}` };
|
||||
tried.push(`${code.slice(0, 12)}… (${addrs.join(" / ")}): ${r.error}`);
|
||||
}
|
||||
if (!codes.length) {
|
||||
const r = verifySignedPayload({ signedText: rec.signed, expectedMessage, network: net });
|
||||
return r.ok
|
||||
? { bucket: "FAILED", detail: "signature is internally valid but the record carries no payment code to bind it to" }
|
||||
: { bucket: "FAILED", detail: r.error };
|
||||
}
|
||||
return { bucket: "FAILED", detail: tried.join("\n ") };
|
||||
}
|
||||
|
||||
// ---- CLI ---------------------------------------------------------------
|
||||
// Only runs when executed directly, so tests can import auditRecord.
|
||||
const isMain = process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href;
|
||||
if (!isMain) { /* imported for testing */ } else {
|
||||
|
||||
const recs = (await store.listSubmissions())
|
||||
.sort((a, b) => (a.network + a.name).localeCompare(b.network + b.name));
|
||||
|
||||
const buckets = { VERIFIED: [], FAILED: [], UNSIGNED: [], ERROR: [] };
|
||||
for (const rec of recs) {
|
||||
let res;
|
||||
try { res = auditRecord(rec); } catch (e) { res = { bucket: "ERROR", detail: e.message }; }
|
||||
buckets[res.bucket].push({ rec, detail: res.detail });
|
||||
}
|
||||
|
||||
console.log(`Audited ${recs.length} record(s) in the store.\n`);
|
||||
for (const b of ["FAILED", "ERROR", "UNSIGNED", "VERIFIED"]) {
|
||||
if (!buckets[b].length) continue;
|
||||
console.log(`${b}: ${buckets[b].length}`);
|
||||
for (const { rec, detail } of buckets[b]) {
|
||||
// Show the name as well as the id. Ids are immutable (reliability history
|
||||
// keys on them), so a record created before operator naming keeps its
|
||||
// payment-code-derived id even after its operator sets a name, and the id
|
||||
// alone is then unrecognisable.
|
||||
const label = rec.name && `${rec.network}-${rec.name}` !== rec.id ? `${rec.id} ("${rec.name}")` : rec.id;
|
||||
console.log(` [${b}] ${label} (${rec.status})${detail ? "\n " + detail : ""}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// An UNSIGNED record is now a failure, not a decision. Until the signature rule
|
||||
// existed there was a legitimate answer to "this record predates the gate" and
|
||||
// the audit deliberately left the judgement to a maintainer. The store now
|
||||
// refuses to write such a record and the rebuild withholds it, so one appearing
|
||||
// here means something got in around those rules or predates them, and either
|
||||
// way it is not being published and needs dealing with.
|
||||
const bad = buckets.FAILED.length + buckets.ERROR.length + buckets.UNSIGNED.length;
|
||||
console.log(
|
||||
`Summary: ${buckets.VERIFIED.length} verified, ${buckets.FAILED.length} failed, ` +
|
||||
`${buckets.UNSIGNED.length} unsigned, ${buckets.ERROR.length} error.` +
|
||||
(buckets.UNSIGNED.length ? "\nUNSIGNED records are withheld from the public list. Ask the operator to sign their\npairing payload and resubmit, or remove the listing with server/remove-listing.ts." : "") +
|
||||
(bad ? `\nNON-ZERO EXIT: ${bad} record(s) need attention.` : "\nEvery record carries a signature and every signature verifies under the current gate."));
|
||||
process.exit(bad ? 1 : 0);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Launcher for the public-list rebuild, which lives in build-public.ts.
|
||||
//
|
||||
// Kept as plain JavaScript, and kept under this name, for the same reasons as
|
||||
// index.mjs:
|
||||
//
|
||||
// 1. It parses on any Node, so an operator on an older runtime gets the
|
||||
// message below rather than a syntax error from a file their Node cannot
|
||||
// execute. The check must precede the import, hence the dynamic import.
|
||||
// 2. A lot of things outside this file invoke it by name: the deploy workflow,
|
||||
// `npm run build-public`, scripts/install.mjs, and — importantly —
|
||||
// scripts/apply-update.mjs, which spawns it during a self-update. That
|
||||
// helper is the OLD copy still running while new files are swapped in, so
|
||||
// an instance updating ACROSS a rename would spawn a file that no longer
|
||||
// exists and its rebuild would fail.
|
||||
//
|
||||
// New in-process callers should import ./build-public.ts directly.
|
||||
const major = Number(process.versions.node.split(".")[0]);
|
||||
if (Number.isNaN(major) || major < 24) {
|
||||
console.error(
|
||||
`The Dojo Bay rebuild needs Node 24 or newer (found ${process.versions.node}).\n` +
|
||||
"It runs TypeScript directly, which relies on type stripping added in Node 24.\n" +
|
||||
"Upgrade Node, then re-run the rebuild.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mod = await import("./build-public.ts");
|
||||
export const rebuild = mod.rebuild;
|
||||
export const displayPaymentCode = mod.displayPaymentCode;
|
||||
export const effectiveVersion = mod.effectiveVersion;
|
||||
export const effectiveIndexer = mod.effectiveIndexer;
|
||||
export const retireUnlisted = mod.retireUnlisted;
|
||||
|
||||
// Run the rebuild when invoked directly (the .ts module's own check does not
|
||||
// fire in that case, because argv[1] is this launcher).
|
||||
import { pathToFileURL } from "node:url";
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
||||
const r = await mod.rebuild();
|
||||
console.log(r.msg);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env node
|
||||
// Merge the curated seed list with APPROVED self-service submissions into the
|
||||
// public data/dojos.json that the front-end and the 10-minute updater consume.
|
||||
// The seed list (data/seed.json) stays under maintainer control; only approved
|
||||
// submissions are added. A newly-approved node inherits the status, block
|
||||
// height and reliability history the updater already recorded for it while it
|
||||
// was pending (see scripts/update.mjs and server/data/pending-probe.json), so
|
||||
// it appears active with its uptime intact the moment it is published.
|
||||
//
|
||||
// Exposes rebuild() for in-process use by the admin API; runs it when invoked
|
||||
// directly from the CLI.
|
||||
import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { store, hasSignedBlock } from "./store.ts";
|
||||
import { urlOnDomain } from "./domains.ts";
|
||||
import type { PublicNode, PairingPayload, StoreRecord } from "../types.js";
|
||||
|
||||
/** The generated data/dojos.json. */
|
||||
interface PublicDoc {
|
||||
generated_at?: string;
|
||||
interval_minutes?: number;
|
||||
nodes: PublicNode[];
|
||||
}
|
||||
/** A history file: per-node check lists or daily rollups, keyed by record id. */
|
||||
type HistoryMap = Record<string, any>;
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
async function readJSON<T>(p: string, fallback: T): Promise<T> {
|
||||
try { return JSON.parse(await readFile(p, "utf8")); }
|
||||
catch (e) { if ((e as NodeJS.ErrnoException).code === "ENOENT") return fallback; throw e; }
|
||||
}
|
||||
// A temporary name no other writer can take. `<file>.tmp` 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.
|
||||
// See scripts/update.mjs for the install that did exactly that.
|
||||
let tmpSeq = 0;
|
||||
async function writeAtomic(p, obj) {
|
||||
await mkdir(path.dirname(p), { recursive: true });
|
||||
const tmp = `${p}.${process.pid}.${(tmpSeq = (tmpSeq + 1) % 1e6)}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(obj, null, 2) + "\n");
|
||||
await rename(tmp, p);
|
||||
}
|
||||
|
||||
// The payment code shown on a card. A PayNym commonly has two BIP47 variants
|
||||
// and records store every variant; the canonical one people share (and the one
|
||||
// shown on paynym.rs profiles) is the NON-segwit code, so prefer that when the
|
||||
// paynym-codes mapping can identify it, falling back to the record's first.
|
||||
// Exported for the self-test.
|
||||
/** Only the two fields it actually reads, so callers need not build a whole
|
||||
* record to ask which variant to display. */
|
||||
type CodeBearing = { paymentCodes?: string[] | null; paynym?: string | null };
|
||||
|
||||
export function displayPaymentCode(sub: CodeBearing, mapping: any): string | null {
|
||||
const codes = Array.isArray(sub.paymentCodes) ? sub.paymentCodes : [];
|
||||
if (!codes.length) return null;
|
||||
const entry = sub.paynym && mapping && mapping[sub.paynym];
|
||||
const legacy = entry && (entry.codes || []).find((c) => !c.segwit && codes.includes(c.code));
|
||||
return (legacy && legacy.code) || codes[0];
|
||||
}
|
||||
|
||||
// The version shown on a card is derived entirely from the node's API, never
|
||||
// set by an operator. In priority order:
|
||||
// 1. the version the updater last read live from the node's X-Dojo-Version
|
||||
// response header (detected_version, carried in dojos.json),
|
||||
// 2. the version in the pairing payload, used only as a bootstrap fallback
|
||||
// until the first probe reads a live header (and for older nodes that do
|
||||
// not emit the header). It is itself an API value, captured from the
|
||||
// Dojo's pairing output at submission time.
|
||||
// There is deliberately no operator override: the version always reflects what
|
||||
// the node reports. To show nothing until a live header is read, drop the
|
||||
// pairing fallback.
|
||||
export function effectiveVersion(detected: string | null | undefined, pairing: string | null | undefined): string | null {
|
||||
return detected || pairing || null;
|
||||
}
|
||||
|
||||
// The Electrum endpoint shown on a card. Only what the node reported about
|
||||
// itself: the updater reads it from the Dojo's /support/services each cycle,
|
||||
// over the API onion the operator's signature fixes.
|
||||
//
|
||||
// A URL declared in a submitted payload is NOT a fallback and must not become
|
||||
// one: nothing signs it, and a node that is healthy but exposes no indexer
|
||||
// never acquires a detected value, so a declared URL would be published for
|
||||
// good. docs/decisions.md, entry 00d07ae, has the reasoning.
|
||||
//
|
||||
// Null means the card shows N/A, which is a real answer (no exposed indexer)
|
||||
// rather than an omission, and is now reachable for every node.
|
||||
export function effectiveIndexer(detected: string | null | undefined): string | null {
|
||||
return detected || null;
|
||||
}
|
||||
|
||||
// Every key the published dojos.json may contain for a node. Exported so the
|
||||
// suite can assert on it rather than restating it, and so that adding a field
|
||||
// to toPublicNode without adding it here fails the gate: publishing a new field
|
||||
// should be a decision somebody makes, not a consequence of editing a record
|
||||
// shape somewhere else.
|
||||
export const PUBLIC_NODE_KEYS = Object.freeze([
|
||||
"id", "network", "name", "status", "paynym", "paymentCode",
|
||||
"jurisdiction", "country", "hardware", "version", "detected_version",
|
||||
"detected_indexer", "operator_domain", "operator_domain_proof",
|
||||
"block_height", "indexer_url", "checked_at", "payload", "signed",
|
||||
]);
|
||||
|
||||
// The allowlist itself, and the only producer of a published node.
|
||||
//
|
||||
// It names every field rather than deleting the ones it does not want, which is
|
||||
// the distinction that matters: a redaction list is wrong by default and has to
|
||||
// be updated whenever the store gains a field, whereas this is right by default
|
||||
// and has to be updated whenever the PUBLIC shape should change. The store
|
||||
// holds things that must never be published (moderation status, the owning
|
||||
// payment codes, submission timestamps, the probe result recorded at
|
||||
// submission, import provenance) and it will hold more in future.
|
||||
//
|
||||
// One field is copied wholesale rather than picked apart: `payload`. That is
|
||||
// deliberate, since the pairing payload including its API key is the entire
|
||||
// point of a listing and a visitor needs it byte for byte to pair. It does mean
|
||||
// the allowlist has a nested edge: anything added inside payload is published.
|
||||
// The store gate is what keeps that honest, since payload is what the operator
|
||||
// signed and the signature covers its exact contents.
|
||||
function toPublicNode(sub: StoreRecord, paymentCode: string | null): PublicNode {
|
||||
return {
|
||||
id: sub.id,
|
||||
network: sub.network,
|
||||
name: sub.name || sub.paynym || sub.id,
|
||||
status: "inactive",
|
||||
paynym: sub.paynym || null,
|
||||
paymentCode: paymentCode || null,
|
||||
jurisdiction: sub.jurisdiction || null,
|
||||
country: sub.country || null,
|
||||
hardware: sub.hardware || null,
|
||||
// Initial version is the pairing-payload fallback; rebuild() recomputes it
|
||||
// via effectiveVersion once the live-detected value is known.
|
||||
version: sub.payload?.pairing?.version || null,
|
||||
detected_version: null,
|
||||
detected_indexer: null,
|
||||
operator_domain: null,
|
||||
operator_domain_proof: null,
|
||||
block_height: null,
|
||||
indexer_url: null,
|
||||
checked_at: null,
|
||||
payload: sub.payload,
|
||||
signed: sub.signed || null,
|
||||
};
|
||||
}
|
||||
|
||||
// Grace-period retirement for history entries. Deleting history the instant an
|
||||
// id leaves the node list turned a transient list mistake into permanent data
|
||||
// loss (the seed-migration deploy wiped every migrated node's history seconds
|
||||
// after rsync, via the post-deploy rebuild, before the migration could run on
|
||||
// the box). Instead: an unlisted id is STAMPED `retired` and kept; it is only
|
||||
// deleted after HISTORY_GRACE_DAYS (default 14); if the id is listed again
|
||||
// within the window, the stamp is cleared and its history resumes untouched.
|
||||
// Exported because scripts/update.mjs rewrites the same two files every cycle
|
||||
// and must apply identical rules.
|
||||
export function retireUnlisted(nodesMap: HistoryMap, isListed: (id: string) => boolean,
|
||||
nowIso: string, graceDays: number = Number(process.env.HISTORY_GRACE_DAYS || 14)): boolean {
|
||||
let touched = false;
|
||||
const cutoffMs = Date.parse(nowIso) - graceDays * 86400000;
|
||||
for (const id of Object.keys(nodesMap)) {
|
||||
const entry = nodesMap[id];
|
||||
if (isListed(id)) {
|
||||
if (entry.retired) { delete entry.retired; touched = true; }
|
||||
} else if (!entry.retired) {
|
||||
entry.retired = nowIso; touched = true;
|
||||
} else if (Date.parse(entry.retired) < cutoffMs) {
|
||||
delete nodesMap[id]; touched = true;
|
||||
}
|
||||
}
|
||||
return touched;
|
||||
}
|
||||
|
||||
export async function rebuild(): Promise<{ nodes: number; approved: number; msg: string }> {
|
||||
const DATA_DIR = process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data");
|
||||
const SERVER_DATA = process.env.SERVER_DATA_DIR || path.join(ROOT, "server", "data");
|
||||
const SEED = path.join(DATA_DIR, "seed.json");
|
||||
const OUT = path.join(DATA_DIR, "dojos.json");
|
||||
const HIST = path.join(DATA_DIR, "history.json");
|
||||
const DAILY = path.join(DATA_DIR, "history-daily.json");
|
||||
const PENDING_PROBE = path.join(SERVER_DATA, "pending-probe.json");
|
||||
|
||||
const seed = await readJSON(SEED, { nodes: [] });
|
||||
// Optional: identifies each PayNym's non-segwit code variant for display.
|
||||
const codesDoc = await readJSON(path.join(DATA_DIR, "paynym-codes.json"), { mapping: {} });
|
||||
// The operator binding is REQUIRED: an instance must prove who runs it.
|
||||
// Warn (unmissably) rather than fail, so a malformed signature nags the
|
||||
// operator without taking the directory down for its visitors. The crypto
|
||||
// import is lazy so the dependency-free scripts/ chain can still import
|
||||
// this module on a box where server/node_modules is not installed yet.
|
||||
try {
|
||||
const opDoc = await readJSON(path.join(DATA_DIR, "operator.json"), null);
|
||||
if (!opDoc) {
|
||||
console.error("[rebuild] REQUIRED: data/operator.json is missing. Sign your onion URL with your wallet and install the binding (the installer does this); see README.");
|
||||
} else {
|
||||
try {
|
||||
const { verifyOperatorDoc } = await import("./crypto.ts");
|
||||
const v = verifyOperatorDoc(opDoc);
|
||||
if (!v.ok) console.error(`[rebuild] REQUIRED: data/operator.json does not verify: ${v.error}`);
|
||||
} catch { console.error("[rebuild] note: cannot verify operator.json (server dependencies not installed)."); }
|
||||
}
|
||||
} catch (e) { console.error(`[rebuild] operator.json check skipped: ${e.message}`); }
|
||||
|
||||
// Anchor-model checks (warnings, never fatal: a fresh instance mid-setup or
|
||||
// mid-transition should build, just noisily). The seed should hold exactly
|
||||
// one node -- the instance operator's own, carrying their payment code --
|
||||
// and every listed node should carry a BIP47 code; code-less records are
|
||||
// grandfathered exceptions managed from /admin.
|
||||
if ((seed.nodes || []).length !== 1) {
|
||||
console.error(`[rebuild] note: seed carries ${(seed.nodes || []).length} node(s); the anchor model expects exactly one (the instance operator's own node).`);
|
||||
} else if (!seed.nodes[0].paymentCode) {
|
||||
console.error(`[rebuild] REFUSING to publish the anchor seed node ${seed.nodes[0].id}: it has no BIP47 payment code.`);
|
||||
}
|
||||
// A record with no payment code and no signed pairing block is not published.
|
||||
// The store refuses to write either, so this only fires for something that
|
||||
// predates those rules or was edited by hand — and in that case it is
|
||||
// withheld rather than shown, because a listing nobody can be held to, or
|
||||
// whose details nobody has attested to, is exactly what this directory must
|
||||
// not carry. Withheld, not deleted: the record stays for a maintainer to look
|
||||
// at. The two are reported separately because the remedies differ: a missing
|
||||
// code cannot be supplied by anyone but the operator, while a missing
|
||||
// signature usually means asking them to sign what they already gave us.
|
||||
const allApproved = (await store.listSubmissions()).filter((s) => s.status === "approved");
|
||||
const codeless = allApproved.filter((s) => !(s.paymentCodes || []).length);
|
||||
if (codeless.length) {
|
||||
console.error(`[rebuild] REFUSING to publish ${codeless.length} listing(s) with no BIP47 payment code: ${codeless.map((s) => s.id).join(", ")}. A listing must carry a payment code; remove it with server/remove-listing.ts, or give it one.`);
|
||||
}
|
||||
const unsigned = allApproved.filter((s) => (s.paymentCodes || []).length && !hasSignedBlock(s));
|
||||
if (unsigned.length) {
|
||||
console.error(`[rebuild] REFUSING to publish ${unsigned.length} listing(s) with no signed pairing block: ${unsigned.map((s) => s.id).join(", ")}. Ask the operator to sign their pairing payload and resubmit, or remove the listing with server/remove-listing.ts.`);
|
||||
}
|
||||
const approvedSubs = allApproved.filter((s) => (s.paymentCodes || []).length && hasSignedBlock(s));
|
||||
const approved = approvedSubs.map((s) => toPublicNode(s, displayPaymentCode(s, codesDoc.mapping)));
|
||||
const approvedIds = new Set(approved.map((n) => n.id));
|
||||
|
||||
const byId = new Map();
|
||||
// The seed anchor is held to the same rules as any other listing.
|
||||
const seedNodes = (seed.nodes || []).filter((n) => {
|
||||
if (!n || !n.paymentCode) {
|
||||
console.error(`[rebuild] withholding seed node ${n?.id}: no BIP47 payment code.`);
|
||||
return false;
|
||||
}
|
||||
if (!hasSignedBlock(n)) {
|
||||
console.error(`[rebuild] withholding seed node ${n?.id}: no signed pairing block.`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// Seed nodes go through the SAME allowlist as store records. They used to be
|
||||
// published as they sit in data/seed.json, which meant the public file had two
|
||||
// producers and only one of them filtered anything. Nothing has ever leaked
|
||||
// that way, because seed.json is written by the installer and its fields
|
||||
// happen to be a subset of what toPublicNode emits, but "happens to be a
|
||||
// subset" is not a property anybody was maintaining: seed.json is
|
||||
// instance-owned and documented as hand-editable, so a field added there went
|
||||
// straight to the published file unread. One producer, one allowlist.
|
||||
//
|
||||
// The cast is safe because toPublicNode reads only fields a seed node has;
|
||||
// the owning code is passed as an argument rather than read from the record,
|
||||
// which is why a seed node's singular paymentCode needs no reshaping.
|
||||
for (const n of seedNodes) byId.set(n.id, toPublicNode(n as unknown as StoreRecord, n.paymentCode || null));
|
||||
for (const n of approved) byId.set(n.id, n);
|
||||
const nodes = [...byId.values()];
|
||||
|
||||
// Per-id pairing version, the bootstrap fallback used until a live version is
|
||||
// detected. The card version is never operator-set (see effectiveVersion).
|
||||
const pairingById = new Map();
|
||||
for (const n of seedNodes) pairingById.set(n.id, n.payload?.pairing?.version || null);
|
||||
for (const s of approvedSubs) pairingById.set(s.id, s.payload?.pairing?.version || null);
|
||||
|
||||
// Owner payment codes per node, for the verified-domain lookup below. The seed
|
||||
// anchor carries a single paymentCode; store records carry paymentCodes[].
|
||||
const ownerCodesById = new Map();
|
||||
for (const n of seedNodes) ownerCodesById.set(n.id, [n.paymentCode]);
|
||||
for (const sub of approvedSubs) ownerCodesById.set(sub.id, sub.paymentCodes || []);
|
||||
|
||||
// Carry over the live status the updater last wrote, so a rebuild does not
|
||||
// blank a node for a probe cycle.
|
||||
const prior = await readJSON(OUT, { nodes: [] });
|
||||
const priorById = new Map((prior.nodes || []).map((n) => [n.id, n]));
|
||||
// Pending-probe results (updater-owned): seed a just-approved node's status
|
||||
// and height from what was observed while it was pending.
|
||||
const pending = await readJSON(PENDING_PROBE, { nodes: {} });
|
||||
// Verified operator domains: published per node so the card can show the badge
|
||||
// without another lookup, and used to filter the card-title link. A link that
|
||||
// is not on the operator's verified domain is withheld rather than deleted, so
|
||||
// an operator who verifies later gets their link back untouched.
|
||||
const domainByCode = await store.verifiedDomainMap();
|
||||
// The proof is published alongside the badge so a reader can check it with
|
||||
// their own tools instead of taking our tick on trust: the TXT record proves
|
||||
// the domain names the payment code, and the signed statement proves the code
|
||||
// names the domain. Everything here is already public (the payment code is on
|
||||
// the card, the domain is the claim), so publishing it discloses nothing new.
|
||||
const claimByCode = new Map<string, { signed: string; verified_at: string | null }>();
|
||||
for (const c of await store.listDomains()) {
|
||||
if (c?.verified && c.domain) claimByCode.set(c.paymentCode, { signed: c.signed, verified_at: c.verified_at ?? null });
|
||||
}
|
||||
for (const n of nodes) {
|
||||
const codes = ownerCodesById.get(n.id) || [];
|
||||
const code = codes.find((c) => domainByCode.get(c)) || null;
|
||||
const domain = code ? domainByCode.get(code) || null : null;
|
||||
n.operator_domain = domain;
|
||||
const claim = code ? claimByCode.get(code) : null;
|
||||
n.operator_domain_proof = domain && claim ? {
|
||||
domain,
|
||||
paymentCode: code,
|
||||
txt_name: `_dojobay.${domain}`,
|
||||
txt_value: `dojobay-domain-v1 pm=${code}`,
|
||||
signed: claim.signed,
|
||||
verified_at: claim.verified_at,
|
||||
} : null;
|
||||
}
|
||||
|
||||
for (const n of nodes) {
|
||||
const p = priorById.get(n.id);
|
||||
const pr = (!p && approvedIds.has(n.id)) ? pending.nodes?.[n.id] : null;
|
||||
if (p) {
|
||||
n.status = p.status ?? n.status;
|
||||
n.checked_at = p.checked_at ?? n.checked_at;
|
||||
if (p.block_height != null) n.block_height = p.block_height;
|
||||
} else if (pr) {
|
||||
n.status = pr.status ?? n.status;
|
||||
n.checked_at = pr.checked_at ?? n.checked_at;
|
||||
if (pr.block_height != null) n.block_height = pr.block_height;
|
||||
}
|
||||
// Carry the live-detected version (prior snapshot, then a just-approved
|
||||
// node's pending probe) and fold it into the effective card version. The
|
||||
// updater writes detected_version each cycle; a rebuild must preserve it,
|
||||
// exactly as it preserves status and block height.
|
||||
const detected = (p && p.detected_version) || (pr && pr.detected_version) || null;
|
||||
n.detected_version = detected;
|
||||
n.version = effectiveVersion(detected, pairingById.get(n.id));
|
||||
// Same treatment for the Electrum endpoint: carry what the updater read and
|
||||
// publish it as indexer_url, which the card renders (N/A when null).
|
||||
const detectedIdx = (p && p.detected_indexer) || (pr && pr.detected_indexer) || null;
|
||||
n.detected_indexer = detectedIdx;
|
||||
n.indexer_url = effectiveIndexer(detectedIdx);
|
||||
}
|
||||
|
||||
await writeAtomic(OUT, {
|
||||
generated_at: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
|
||||
interval_minutes: 10,
|
||||
nodes,
|
||||
});
|
||||
|
||||
// Reliability history: ensure a bucket per node, seed a newly-approved node's
|
||||
// history from its pending history, and retire (grace period) unlisted ids.
|
||||
const hist = await readJSON(HIST, { interval_minutes: 10, window_checks: 144, nodes: {} });
|
||||
let touched = false;
|
||||
for (const n of nodes) {
|
||||
if (!hist.nodes[n.id]) {
|
||||
const seedChecks = (approvedIds.has(n.id) && pending.nodes?.[n.id]?.checks) || [];
|
||||
hist.nodes[n.id] = { checks: seedChecks.slice() };
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
const nowIso = new Date().toISOString();
|
||||
touched = retireUnlisted(hist.nodes, (id) => byId.has(id), nowIso) || touched;
|
||||
if (touched) { (hist as any).generated_at = (hist as any).generated_at || null; await writeAtomic(HIST, hist); }
|
||||
|
||||
// 90-day daily rollup membership.
|
||||
const dailyDoc = await readJSON(DAILY, { retention_days: 90, nodes: {} });
|
||||
let dailyTouched = false;
|
||||
for (const n of nodes) if (!dailyDoc.nodes[n.id]) {
|
||||
dailyDoc.nodes[n.id] = { days: (approvedIds.has(n.id) && pending.nodes?.[n.id]?.days) ? pending.nodes[n.id].days.slice() : [] };
|
||||
dailyTouched = true;
|
||||
}
|
||||
dailyTouched = retireUnlisted(dailyDoc.nodes, (id) => byId.has(id), nowIso) || dailyTouched;
|
||||
if (dailyTouched) await writeAtomic(DAILY, dailyDoc);
|
||||
|
||||
const msg = `public list rebuilt: ${nodes.length} nodes (${approved.length} approved submissions).`;
|
||||
return { nodes: nodes.length, approved: approved.length, msg };
|
||||
}
|
||||
|
||||
// Run when invoked directly.
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
||||
const r = await rebuild();
|
||||
console.log(r.msg);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — resource diagnostic.
|
||||
//
|
||||
// READ-ONLY. Measures what this instance actually uses, rather than guessing,
|
||||
// so an operator can size a VPS from evidence and this project can document a
|
||||
// requirement it has tested.
|
||||
//
|
||||
// What it looks at, and why each matters for THIS workload:
|
||||
//
|
||||
// memory the backend is a small long-running Node process; the updater is
|
||||
// a second one every ten minutes; tor and nginx sit alongside.
|
||||
// Peak matters more than current, because `npm ci` during a deploy
|
||||
// and the unzip during a self-update are the two spikes.
|
||||
// disk node_modules, the published data, and — the one that grows
|
||||
// without limit — data/backups, a full copy of the code kept by
|
||||
// every self-update.
|
||||
// cpu idle almost always, with a burst each probe cycle: one Tor
|
||||
// circuit per listed node, plus secp256k1 verification.
|
||||
// strain swap in use, OOM kills and load average are the evidence that a
|
||||
// box is actually too small, as opposed to merely modest.
|
||||
//
|
||||
// NO PATH FROM THE ENVIRONMENT REACHES A SUBPROCESS. WEB_ROOT and
|
||||
// PUBLIC_DATA_DIR are operator-set, and this file used to hand them to `df` and
|
||||
// `du`, which CodeQL flagged (js/shell-command-injection-from-environment) and
|
||||
// which is a real if narrow bug: a value beginning with a hyphen is read by
|
||||
// those tools as an option, not a path, so `WEB_ROOT=-x` silently measures
|
||||
// something other than what was asked for. Both are now answered by Node
|
||||
// itself, statfs() and a walk, which removes the class rather than escaping
|
||||
// around it. The two subprocesses that remain (systemctl, journalctl) exist
|
||||
// because nothing in Node can answer what they answer, and both take arguments
|
||||
// written here. Keep it that way: see sh() below.
|
||||
//
|
||||
// Usage, on the box:
|
||||
// cd /var/www/dojobay/server && node check-resources.ts
|
||||
// =============================================================================
|
||||
import { readFile, stat, readdir, lstat, statfs } from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const WEB_ROOT = process.env.WEB_ROOT || path.resolve(HERE, "..");
|
||||
const PUBLIC_DIR = process.env.PUBLIC_DATA_DIR || path.join(WEB_ROOT, "data");
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
const mb = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < MB) return (bytes / 1024).toFixed(0) + " KB";
|
||||
return (bytes / MB).toFixed(bytes < 10 * MB ? 1 : 0) + " MB";
|
||||
};
|
||||
const gb = (bytes: number) => (bytes / (1024 * MB)).toFixed(1) + " GB";
|
||||
const read = async (p: string) => { try { return await readFile(p, "utf8"); } catch { return null; } };
|
||||
|
||||
// Every call site passes a command and an argument list written in this file,
|
||||
// never a path, a name or anything else derived from the environment. The one
|
||||
// exception is UNITS below, which the suite checks directly. A future edit that
|
||||
// interpolates a variable in here fails the gate rather than shipping.
|
||||
const sh = async (cmd: string, args: string[]) => {
|
||||
try { return (await exec(cmd, args)).stdout.trim(); } catch { return null; }
|
||||
};
|
||||
|
||||
// The only values this file passes to a subprocess that are not written inline
|
||||
// at the call site. They are exported so the suite can assert on the array
|
||||
// itself rather than reading this source and guessing: an assertion about what
|
||||
// a program does is worth more than one about how it is spelled.
|
||||
export const UNITS = [
|
||||
"dojobay-server.service",
|
||||
"dojobay-update.service",
|
||||
"tor.service",
|
||||
"nginx.service",
|
||||
];
|
||||
|
||||
// Replaces `df`. statfs reports the filesystem holding the path, and the
|
||||
// arithmetic matches what df prints: used counts the blocks the filesystem
|
||||
// considers occupied, while available excludes the root reserve, so used plus
|
||||
// available is legitimately less than the total.
|
||||
export const diskUsage = async (p: string) => {
|
||||
try {
|
||||
const fs = await statfs(p);
|
||||
const block = Number(fs.bsize);
|
||||
return {
|
||||
size: Number(fs.blocks) * block,
|
||||
used: (Number(fs.blocks) - Number(fs.bfree)) * block,
|
||||
avail: Number(fs.bavail) * block,
|
||||
};
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
// Replaces `du -sb`: apparent size of a tree, symlinks counted but never
|
||||
// followed, unreadable entries skipped rather than fatal, and directory inodes
|
||||
// excluded, which is what `du -sb` does and is why this agrees with it to the
|
||||
// byte on a real node_modules. Counting the directories instead would add 4 KB
|
||||
// per directory of filesystem bookkeeping to a figure meant to describe
|
||||
// content. One difference remains: du counts a hard-linked file once, this
|
||||
// counts it once per link, which node_modules does not contain and which would
|
||||
// overstate rather than hide. It also walks in JavaScript, so a populated
|
||||
// node_modules takes a second or so rather than being instant, which is nothing
|
||||
// for a diagnostic run by hand a few times a year.
|
||||
export const dirSize = async (p: string): Promise<number | null> => {
|
||||
const root = await lstat(p).catch(() => null);
|
||||
if (!root) return null;
|
||||
if (!root.isDirectory()) return root.size;
|
||||
let total = 0;
|
||||
const walk = async (dir: string) => {
|
||||
const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
|
||||
if (!entries) return;
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) { await walk(full); continue; }
|
||||
const s = await lstat(full).catch(() => null);
|
||||
if (s) total += s.size;
|
||||
}
|
||||
};
|
||||
await walk(p);
|
||||
return total;
|
||||
};
|
||||
|
||||
const report = async () => {
|
||||
console.log("The Dojo Bay — what this instance actually uses\n");
|
||||
|
||||
// ---- the machine ----------------------------------------------------------
|
||||
const meminfo = (await read("/proc/meminfo")) || "";
|
||||
const kb = (key: string) => {
|
||||
const m = meminfo.match(new RegExp("^" + key + ":\\s+(\\d+) kB", "m"));
|
||||
return m ? Number(m[1]) * 1024 : null;
|
||||
};
|
||||
const memTotal = kb("MemTotal"), memAvail = kb("MemAvailable");
|
||||
const swapTotal = kb("SwapTotal"), swapFree = kb("SwapFree");
|
||||
const swapUsed = swapTotal != null && swapFree != null ? swapTotal - swapFree : null;
|
||||
const cpus = os.cpus();
|
||||
|
||||
console.log("MACHINE");
|
||||
console.log(` cpu ${cpus.length} × ${cpus[0]?.model?.trim() || "unknown"}`);
|
||||
console.log(` memory ${memTotal ? gb(memTotal) : "?"} total, ${memAvail ? gb(memAvail) : "?"} available`);
|
||||
console.log(` swap ${swapTotal ? gb(swapTotal) + " total, " + mb(swapUsed || 0) + " in use" : "none configured"}`);
|
||||
const la = os.loadavg();
|
||||
console.log(` load average ${la.map((n) => n.toFixed(2)).join(" ")} (1, 5, 15 min; ${cpus.length} core${cpus.length === 1 ? "" : "s"})`);
|
||||
|
||||
const disk = await diskUsage(WEB_ROOT);
|
||||
const diskFree = disk ? disk.avail : null;
|
||||
if (disk) console.log(` disk ${gb(disk.size)} total, ${gb(disk.used)} used, ${gb(disk.avail)} free`);
|
||||
|
||||
// ---- what our services use ------------------------------------------------
|
||||
console.log("\nSERVICES (current / peak since boot)");
|
||||
let ourPeak = 0;
|
||||
for (const unit of UNITS) {
|
||||
const base = `/sys/fs/cgroup/system.slice/${unit}`;
|
||||
const cur = Number((await read(`${base}/memory.current`)) || 0);
|
||||
const peak = Number((await read(`${base}/memory.peak`)) || 0);
|
||||
const active = await sh("systemctl", ["is-active", unit]);
|
||||
if (!cur && active !== "active") { console.log(` ${unit.padEnd(24)} not running`); continue; }
|
||||
if (unit.startsWith("dojobay")) ourPeak += peak || cur;
|
||||
console.log(` ${unit.padEnd(24)} ${cur ? mb(cur) : "—"}${peak ? " / " + mb(peak) : ""}`);
|
||||
}
|
||||
|
||||
// ---- disk, broken down ----------------------------------------------------
|
||||
console.log("\nDISK USED BY THIS INSTALLATION");
|
||||
const parts: [string, string][] = [
|
||||
["everything", WEB_ROOT],
|
||||
[" server/node_modules", path.join(WEB_ROOT, "server", "node_modules")],
|
||||
[" data (published)", PUBLIC_DIR],
|
||||
[" data/avatars", path.join(PUBLIC_DIR, "avatars")],
|
||||
[" data/backups", path.join(PUBLIC_DIR, "backups")],
|
||||
[" data/updates", path.join(PUBLIC_DIR, "updates")],
|
||||
];
|
||||
let backupsBytes = 0, backupCount = 0;
|
||||
for (const [label, p] of parts) {
|
||||
const bytes = await dirSize(p);
|
||||
if (bytes == null) { console.log(` ${label.padEnd(24)} —`); continue; }
|
||||
if (label.includes("backups")) {
|
||||
backupsBytes = bytes;
|
||||
try { backupCount = (await readdir(p)).length; } catch { /* none */ }
|
||||
}
|
||||
console.log(` ${label.padEnd(24)} ${mb(bytes)}${label.includes("backups") && backupCount ? ` (${backupCount} kept)` : ""}`);
|
||||
}
|
||||
|
||||
// ---- the workload ---------------------------------------------------------
|
||||
console.log("\nWORKLOAD");
|
||||
let nodeCount = 0, intervalMin = 10;
|
||||
try {
|
||||
const dojos = JSON.parse((await read(path.join(PUBLIC_DIR, "dojos.json"))) || "{}");
|
||||
nodeCount = (dojos.nodes || []).length;
|
||||
intervalMin = Number(dojos.interval_minutes) || 10;
|
||||
} catch { /* not built yet */ }
|
||||
const concurrency = Number(process.env.CONCURRENCY || 4);
|
||||
console.log(` listed nodes ${nodeCount}`);
|
||||
console.log(` probe cycle every ${intervalMin} min, up to ${concurrency} Tor circuits at once`);
|
||||
for (const f of ["dojos.json", "history.json", "history-daily.json"]) {
|
||||
const s = await stat(path.join(PUBLIC_DIR, f)).catch(() => null);
|
||||
if (s) console.log(` ${f.padEnd(22)} ${mb(s.size)}`);
|
||||
}
|
||||
|
||||
// ---- evidence of strain ---------------------------------------------------
|
||||
// journalctl does its own matching, so there is no pipeline and no shell: the
|
||||
// filter is an argument, the output is one line per matching entry, and a
|
||||
// journalctl that cannot answer leaves this null exactly as an absent one did.
|
||||
console.log("\nSIGNS OF STRAIN");
|
||||
const oom = await sh("journalctl", ["-k", "--no-pager", "--case-sensitive=false",
|
||||
"--grep=out of memory", "--output=cat"]);
|
||||
const oomCount = oom ? oom.split("\n").filter((l) => l.trim()).length : 0;
|
||||
const findings: string[] = [];
|
||||
if (oomCount > 0) findings.push(`${oomCount} out-of-memory event(s) in the kernel log — the box IS too small`);
|
||||
if (swapUsed && swapUsed > 64 * MB) findings.push(`${mb(swapUsed)} of swap in use — memory pressure, though not fatal`);
|
||||
if (memAvail && memTotal && memAvail < memTotal * 0.15) findings.push("under 15% of memory available right now");
|
||||
if (la[2] > cpus.length) findings.push(`15-minute load ${la[2].toFixed(2)} exceeds ${cpus.length} core(s)`);
|
||||
if (diskFree != null && diskFree < 2 * 1024 * MB) findings.push(`only ${gb(diskFree)} of disk free`);
|
||||
if (backupCount > 3) findings.push(`${backupCount} self-update backups kept (${mb(backupsBytes)}); nothing prunes these`);
|
||||
if (!findings.length) console.log(" none. Nothing here suggests this machine is short of anything.");
|
||||
else for (const f of findings) console.log(` · ${f}`);
|
||||
|
||||
// ---- what to tell other operators -----------------------------------------
|
||||
console.log("\nWHAT THIS SUGGESTS FOR A MINIMUM SPEC");
|
||||
const ourMb = ourPeak / MB;
|
||||
if (ourPeak > 0) {
|
||||
console.log(` This instance's own services peaked at about ${mb(ourPeak)}, carrying ${nodeCount} node(s).`);
|
||||
console.log(" Add tor, nginx and the operating system, and headroom for `npm ci`");
|
||||
console.log(" during a deploy, which is the largest transient by some way.");
|
||||
} else {
|
||||
console.log(" The services are not running here, so nothing was measured. Run this ON");
|
||||
console.log(" the instance, with the backend up, for numbers that mean anything.");
|
||||
}
|
||||
console.log("");
|
||||
console.log(` Suggested minimum: 1 vCPU, ${ourPeak > 0 && ourMb < 200 ? "1 GB" : "2 GB"} RAM, 20 GB disk, plus swap.`);
|
||||
console.log(" The work is almost entirely waiting on Tor, so cores buy little; memory");
|
||||
console.log(" and a little disk headroom are what matter. Run this again after a");
|
||||
console.log(" deploy and after a self-update to catch the peaks rather than the calm.");
|
||||
};
|
||||
|
||||
// Run when invoked, importable when tested. The suite exercises dirSize and
|
||||
// diskUsage directly; printing a report on import would make that impossible.
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) await report();
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — report the Dojo version of every listing.
|
||||
//
|
||||
// READ-ONLY. Nothing is written and no network is touched: it reads what the
|
||||
// updater has already recorded.
|
||||
//
|
||||
// Two versions per node, and the difference matters when choosing a minimum:
|
||||
//
|
||||
// detected from the node's own X-Dojo-Version header, read on every probe.
|
||||
// This is what it is actually running.
|
||||
// declared the version inside the pairing payload. Frozen when that payload
|
||||
// was generated and signed, so it can be years out of date while
|
||||
// the node itself is current. At least one listing here declares
|
||||
// 1.4.5 for exactly that reason.
|
||||
//
|
||||
// A minimum-version rule should therefore judge the DETECTED version. This
|
||||
// report shows both, so a threshold can be chosen against the real spread.
|
||||
//
|
||||
// Usage, on the box:
|
||||
// cd /var/www/dojobay/server
|
||||
// node check-versions.ts # against the configured minimum
|
||||
// node check-versions.ts 1.27.0 # against a threshold you are weighing
|
||||
// =============================================================================
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { store } from "./store.ts";
|
||||
import { MIN_DOJO_VERSION, judgeVersion, compareVersions } from "./dojo-version.ts";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PUBLIC_DIR = process.env.PUBLIC_DATA_DIR || path.join(HERE, "..", "data");
|
||||
const minimum = (process.argv.find((a) => /^\d/.test(a)) || MIN_DOJO_VERSION || "1.27.0").trim();
|
||||
|
||||
const dojos = await readFile(path.join(PUBLIC_DIR, "dojos.json"), "utf8")
|
||||
.then((t) => JSON.parse(t)).catch(() => ({ nodes: [] }));
|
||||
const published = new Map((dojos.nodes || []).map((n: any) => [n.id, n]));
|
||||
const records = (await store.listSubmissions())
|
||||
.filter((r) => r.status === "approved")
|
||||
.sort((a, b) => (a.network + a.name).localeCompare(b.network + b.name));
|
||||
|
||||
const rows = records.map((r) => {
|
||||
const pub: any = published.get(r.id) || {};
|
||||
const detected = pub.detected_version || null;
|
||||
const declared = r.payload?.pairing?.version || null;
|
||||
const verdict = judgeVersion(detected, declared, minimum);
|
||||
return { id: r.id, name: r.name || r.id, detected, declared, verdict, status: pub.status || "?" };
|
||||
});
|
||||
|
||||
const pad = (s: string, n: number) => (s || "").padEnd(n);
|
||||
console.log(`Minimum being applied: ${minimum}\n`);
|
||||
console.log(pad("RECORD", 30) + pad("DETECTED", 12) + pad("DECLARED", 12) + pad("NODE", 10) + "VERDICT");
|
||||
console.log("-".repeat(78));
|
||||
for (const r of rows) {
|
||||
const v = r.verdict.ok ? "ok" : (r.verdict.version ? "BELOW MINIMUM" : "no version reported");
|
||||
console.log(pad(r.id, 30) + pad(r.detected || "—", 12) + pad(r.declared || "—", 12) + pad(r.status, 10) + v);
|
||||
}
|
||||
|
||||
const below = rows.filter((r) => !r.verdict.ok && r.verdict.version);
|
||||
const unknown = rows.filter((r) => !r.verdict.ok && !r.verdict.version);
|
||||
const ok = rows.length - below.length - unknown.length;
|
||||
|
||||
console.log(`\n${ok} at or above ${minimum}, ${below.length} below, ${unknown.length} with no version reported.`);
|
||||
if (below.length) {
|
||||
console.log("\nBelow the minimum:");
|
||||
for (const r of below) console.log(` ${r.id}: ${r.verdict.version} (${r.verdict.source})`);
|
||||
}
|
||||
if (unknown.length) {
|
||||
console.log("\nNo version reported. A node that has never been probed successfully shows nothing here,");
|
||||
console.log("so check whether these are down rather than old before reading anything into it:");
|
||||
for (const r of unknown) console.log(` ${r.id} (node currently ${r.status})`);
|
||||
}
|
||||
|
||||
// The spread, which is what a threshold should actually be chosen against.
|
||||
const seen = rows.map((r) => r.detected).filter(Boolean) as string[];
|
||||
if (seen.length) {
|
||||
const uniq = [...new Set(seen)].sort(compareVersions);
|
||||
console.log(`\nDetected versions in use: ${uniq.join(", ")}`);
|
||||
console.log(`Oldest running: ${uniq[0]}. A minimum above that would refuse a node currently listed,`);
|
||||
console.log("though existing listings are never re-judged — the check applies to new submissions.");
|
||||
}
|
||||
process.exit(below.length || unknown.length ? 1 : 0);
|
||||
@@ -0,0 +1,429 @@
|
||||
// Auth47 login and BIP47 signed-payload verification for The Dojo Bay backend.
|
||||
// Thin wrappers over the audited Samourai libraries; the exact call shapes here
|
||||
// were verified against the libraries end to end (see selftest.mjs).
|
||||
import { Auth47Verifier } from "@dojo-tools/auth47";
|
||||
import { BIP47Factory } from "@dojo-tools/bip47";
|
||||
import { bitcoinMessageFactory } from "@dojo-tools/bitcoinjs-message";
|
||||
import * as bip47utils from "@dojo-tools/bip47/utils";
|
||||
import ecc from "@bitcoinerlab/secp256k1";
|
||||
|
||||
/** Outcome of a signature check: either accepted, or refused with a reason an
|
||||
* operator can act on. */
|
||||
export type VerifyResult =
|
||||
| { ok: true; error?: undefined; address?: string; paymentCode?: string | null }
|
||||
| { ok: false; error: string; address?: undefined; paymentCode?: undefined };
|
||||
|
||||
/** The parts of a wallet-exported signed block. */
|
||||
export interface ParsedBlock {
|
||||
/** Everything the signature covers, including the BIP47 tail. */
|
||||
message: string;
|
||||
/** The pairing JSON alone. */
|
||||
pairingText: string;
|
||||
/** The payment code inside the signed text, when present. */
|
||||
paymentCode: string | null;
|
||||
address: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
const bip47 = BIP47Factory(ecc);
|
||||
const message = bitcoinMessageFactory(ecc);
|
||||
|
||||
// ---- Auth47 ----------------------------------------------------------------
|
||||
// The verifier needs to know its own callback URL. We build it from the site's
|
||||
// base URL (the .onion origin) at construction time.
|
||||
export function makeAuth47(baseUrl) {
|
||||
const callback = new URL("/api/auth47/callback", baseUrl).toString();
|
||||
const verifier = new Auth47Verifier(ecc, callback);
|
||||
|
||||
// Full challenge URI shown to the wallet (includes the callback `c`).
|
||||
function challengeURI(nonce, expires, resource) {
|
||||
return verifier.generateURI({ nonce, expires, resource });
|
||||
}
|
||||
|
||||
// Per the spec, the wallet signs the challenge WITHOUT the callback param.
|
||||
// Given the full URI we generated, produce the value the proof must contain.
|
||||
function signedForm(fullUri) {
|
||||
const u = new URL(fullUri);
|
||||
u.searchParams.delete("c");
|
||||
return decodeURIComponent(u.toString());
|
||||
}
|
||||
|
||||
// Two URLs naming the same resource. Compared as parsed URLs rather than as
|
||||
// strings, so a trailing slash or a difference in host case is not treated as
|
||||
// a different site, while a different origin or path is. Anything that does
|
||||
// not parse is not equal to anything.
|
||||
function sameResource(a: string, b: string): boolean {
|
||||
try {
|
||||
const norm = (u: string) => {
|
||||
const x = new URL(u);
|
||||
return x.origin.toLowerCase() + x.pathname.replace(/\/+$/, "") + x.search;
|
||||
};
|
||||
return norm(a) === norm(b);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// Verify a posted proof. Returns { ok, paymentCode } or { ok:false, error }.
|
||||
//
|
||||
// expectedResource is REQUIRED, and the shape is the point. A signature is
|
||||
// only ever evidence of what it was made over, so a verifier that takes only
|
||||
// the thing being verified can answer "is this signed?" but never "is this
|
||||
// signed FOR ME?". The other three verifiers in this file all take an
|
||||
// expectation for that reason: verifySignedPayload takes expectedMessage and
|
||||
// expectedAddress, verifySignedUrlClaim takes expectedUrl, verifyOperatorDoc
|
||||
// takes expectedOnion. This one did not, and the missing binding was
|
||||
// invisible rather than a missing argument.
|
||||
//
|
||||
// What it prevents: the library checks that the challenge's r parameter is a
|
||||
// well-formed http(s) URL, but it cannot know which URL is ours. Without this
|
||||
// comparison an attacker could take a live nonce from this instance, show a
|
||||
// victim the same challenge with r rewritten to their own site, and relay the
|
||||
// resulting proof back here. The victim's wallet would display the attacker's
|
||||
// site, the signature would verify, and a session would be minted here in the
|
||||
// victim's name. The r parameter exists so a person can see what they are
|
||||
// signing into, and this check is what makes that display mean anything.
|
||||
function verify(proof: unknown, { expectedResource }: { expectedResource?: string } = {}): VerifyResult {
|
||||
// Fail closed rather than throwing: a caller who forgot this is a bug, but
|
||||
// a 500 from an auth endpoint is a worse way to find out than a refusal
|
||||
// that names the omission.
|
||||
if (!expectedResource) {
|
||||
return { ok: false, error: "internal: no expected resource supplied, refusing to verify an unbound proof" };
|
||||
}
|
||||
const res = verifier.verifyProof(proof);
|
||||
if (res.result !== "ok") return { ok: false, error: res.error };
|
||||
// Read the resource from the challenge the signature actually covers, not
|
||||
// from anything the caller passed alongside it.
|
||||
const challenge = (proof as { challenge?: unknown }).challenge;
|
||||
let resource: string | null = null;
|
||||
try { resource = new URL(String(challenge)).searchParams.get("r"); } catch { /* unparseable */ }
|
||||
if (!resource || !sameResource(resource, expectedResource)) {
|
||||
return { ok: false, error: `proof was signed for a different site (${resource || "no resource"}), not this one` };
|
||||
}
|
||||
// Auth47 defines two proof shapes: a nym proof carrying a payment code, and
|
||||
// an address proof carrying a plain address. Only the former identifies an
|
||||
// operator here, and reading .nym off the wrong one would bind a session to
|
||||
// undefined, so require it explicitly rather than assuming.
|
||||
const nym = (res.data as { nym?: string }).nym;
|
||||
if (typeof nym !== "string" || !nym) {
|
||||
return { ok: false, error: "proof does not carry a payment code (an address proof cannot identify an operator)" };
|
||||
}
|
||||
return { ok: true, paymentCode: nym };
|
||||
}
|
||||
|
||||
return { challengeURI, signedForm, verify, callback };
|
||||
}
|
||||
|
||||
// ---- payment code -> notification address ----------------------------------
|
||||
export function notificationAddress(paymentCode: string, network: string = "bitcoin"): string {
|
||||
const net = bip47utils.networks[network];
|
||||
return bip47.fromBase58(paymentCode, net).getNotificationAddress();
|
||||
}
|
||||
|
||||
// The exact text an operator signs to attest to a pairing payload, and the
|
||||
// exact text every gate checks a signature against.
|
||||
//
|
||||
// It lives here because it had grown two copies, in the submission gate and in
|
||||
// audit-signed.mjs, the second carrying a comment warning that it MUST mirror
|
||||
// the first. A canonical message that exists twice is a canonical message
|
||||
// waiting to disagree with itself, and the failure would be quiet in the worst
|
||||
// direction: signatures accepted at submission and reported as invalid by a
|
||||
// later audit, or the reverse. The installer needs it too, which would have
|
||||
// made three.
|
||||
export function canonicalPairing(payload: { pairing?: unknown; explorer?: unknown } | null | undefined): string {
|
||||
return JSON.stringify({ pairing: payload?.pairing, explorer: payload?.explorer });
|
||||
}
|
||||
|
||||
// Every address a given payment code could legitimately have signed from.
|
||||
// A PayNym is a MAINNET identity: an operator listing a testnet node still
|
||||
// signs with their mainnet notification address, because that is the only key
|
||||
// their wallet holds for that code. Deriving on testnet yields an "m…" address
|
||||
// that can never match, which silently made every testnet listing unverifiable.
|
||||
// Both derivations come from the same code, so accepting either is no weaker.
|
||||
export function notificationAddresses(paymentCode: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const net of ["bitcoin", "testnet"]) {
|
||||
try { const a = notificationAddress(paymentCode, net); if (!out.includes(a)) out.push(a); } catch { /* skip */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- lab-style signed pairing payload verification -------------------------
|
||||
// The submitted `signed` blob is a BIP-signed message. We require it to be
|
||||
// signed by the notification address of the operator's authenticated payment
|
||||
// code, over the exact pairing JSON they are submitting. This is the same
|
||||
// verify() the paymentcode.io lab uses.
|
||||
//
|
||||
// The signed message format Samourai/Ashigaru export wraps the payload between
|
||||
// BEGIN/END markers. CRITICAL, verified against a real wallet export: the text
|
||||
// the wallet signs is EVERYTHING between the markers, i.e. the pairing JSON
|
||||
// PLUS the trailing "BIP47:" line and payment code (no trailing newline). An
|
||||
// earlier revision excised the BIP47 tail before verifying, which made every
|
||||
// genuine wallet signature fail as "invalid signature"; the selftest did not
|
||||
// catch it because it constructed its own blocks under the same assumption.
|
||||
// Because the BIP47 line is inside the signed text, the payment code is
|
||||
// covered by the signature and can itself be verified against the signing
|
||||
// address (see verifySignedPayload).
|
||||
// Repair a signed block whose whitespace was mangled in transit.
|
||||
//
|
||||
// The signature covers the exact bytes between the markers, and the blank line
|
||||
// before the "BIP47:" line is part of them. Copying a block through a chat
|
||||
// window, a web form or a mail client routinely collapses that blank line, at
|
||||
// which point a perfectly good signature stops verifying and the operator is
|
||||
// told their signature is invalid, which is both wrong and unhelpful.
|
||||
//
|
||||
// This is safe rather than a fudge: a candidate is accepted ONLY if it verifies
|
||||
// cryptographically against an address the declared payment code derives, so
|
||||
// nothing is taken on trust. The repaired block is what gets stored, so later
|
||||
// audits verify too. Returns null when no candidate verifies.
|
||||
export function repairSignedBlock(text: unknown): { block: string; note: string | null } | null {
|
||||
const raw = String(text || "").replace(/\r\n/g, "\n");
|
||||
const addrM = raw.match(/Address:\s*(\S+)/);
|
||||
const sigM = raw.match(/\n([A-Za-z0-9+\/=]{80,})\n*-----END BITCOIN SIGNATURE/);
|
||||
const innerM = raw.match(/SIGNED MESSAGE-----[ \t]*\n([\s\S]*?)\n-----BEGIN BITCOIN SIGNATURE/);
|
||||
if (!addrM || !sigM || !innerM) return null;
|
||||
const address = addrM[1].trim(), signature = sigM[1].trim();
|
||||
const inner = innerM[1];
|
||||
const codeM = inner.match(/BIP47:\s*(PM8T[1-9A-HJ-NP-Za-km-z]+)/);
|
||||
const json = inner.replace(/\n*[ \t]*BIP47:[\s\S]*$/, "").replace(/\n+$/, "");
|
||||
const code = codeM ? codeM[1] : null;
|
||||
|
||||
const candidates: [string, string][] = [["", inner]];
|
||||
if (code) {
|
||||
candidates.push(
|
||||
["a blank line before the BIP47 line was restored", `${json}\n\nBIP47: ${code}`],
|
||||
["a blank line before the BIP47 line was restored", `${json}\n\nBIP47:\n${code}`],
|
||||
);
|
||||
}
|
||||
const accept = code ? notificationAddresses(code) : [];
|
||||
if (code && !accept.includes(address)) return null; // the code does not own this address
|
||||
const net = bip47utils.networks.bitcoin;
|
||||
for (const [note, candidate] of candidates) {
|
||||
let ok = false;
|
||||
try { ok = message.verify(candidate, address, signature, net.messagePrefix); } catch { ok = false; }
|
||||
if (!ok) continue;
|
||||
const block = `-----BEGIN BITCOIN SIGNED MESSAGE-----\n${candidate}\n` +
|
||||
`-----BEGIN BITCOIN SIGNATURE-----\nVersion: Bitcoin-qt (1.0)\nAddress: ${address}\n\n${signature}\n` +
|
||||
`-----END BITCOIN SIGNATURE-----`;
|
||||
return { block, note: note || null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseSignedBlock(text: unknown): ParsedBlock | null {
|
||||
if (!text || typeof text !== "string") return null;
|
||||
const t = text.replace(/\r\n/g, "\n");
|
||||
const msgM = t.match(/BEGIN BITCOIN SIGNED MESSAGE-----\n([\s\S]*?)\n-----BEGIN BITCOIN SIGNATURE/);
|
||||
const addrM = t.match(/Address:\s*(\S+)/);
|
||||
const sigM = t.match(/\n([A-Za-z0-9+/=]{80,})\n-----END BITCOIN SIGNATURE/);
|
||||
if (!msgM || !addrM || !sigM) return null;
|
||||
const message = msgM[1].trim(); // the full signed text
|
||||
const tail = message.match(/^([\s\S]*?)\n\s*BIP47:\s*\n?(\S+)$/);
|
||||
return {
|
||||
message, // what the signature covers
|
||||
pairingText: tail ? tail[1].trim() : message, // the pairing JSON alone
|
||||
paymentCode: tail ? tail[2] : null, // code inside the signed text
|
||||
address: addrM[1].trim(),
|
||||
signature: sigM[1].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Verify a signed pairing block. Checks, in order, with distinct errors:
|
||||
// 1. the block parses at all;
|
||||
// 2. the pairing JSON inside it matches the payload being submitted;
|
||||
// 3. the signature is cryptographically valid over the FULL signed text;
|
||||
// 4. (signature now known valid) the BIP47 payment code inside the signed
|
||||
// text is a valid code whose notification address IS the signing address;
|
||||
// 5. the signing address matches the authenticated payment code's
|
||||
// notification address (the session binding the API supplies).
|
||||
// Does the signed pairing text describe the same payload being submitted?
|
||||
//
|
||||
// Wallets and admin panels serialise this JSON differently: pretty-printed with
|
||||
// newlines and indentation, or with the object keys in another order. All of
|
||||
// those are the SAME payload, and a byte-exact comparison against our own
|
||||
// re-serialisation rejects them, which is what made genuine, correctly signed
|
||||
// listings fail the gate. So compare the parsed structures instead: identical
|
||||
// keys and identical values, order-insensitive, at every level. Anything that
|
||||
// is not valid JSON, or that differs in any value or key, still fails.
|
||||
export function sameSignedPayload(signedText: string, expected: string): boolean {
|
||||
const a = String(signedText).trim(), b = String(expected).trim();
|
||||
if (a === b) return true;
|
||||
let pa, pb;
|
||||
try { pa = JSON.parse(a); pb = JSON.parse(b); } catch { return false; }
|
||||
return stableStringify(pa) === stableStringify(pb);
|
||||
}
|
||||
|
||||
function stableStringify(v: unknown): string {
|
||||
if (Array.isArray(v)) return "[" + v.map(stableStringify).join(",") + "]";
|
||||
if (v && typeof v === "object") {
|
||||
const o = v as Record<string, unknown>;
|
||||
return "{" + Object.keys(o).sort().map((k) => JSON.stringify(k) + ":" + stableStringify(o[k])).join(",") + "}";
|
||||
}
|
||||
return JSON.stringify(v) ?? "null";
|
||||
}
|
||||
|
||||
export function verifySignedPayload({ signedText, expectedMessage, expectedAddress, network = "bitcoin" }: {
|
||||
signedText: string;
|
||||
expectedMessage?: string | null;
|
||||
expectedAddress?: string | string[] | null;
|
||||
network?: string;
|
||||
}): VerifyResult {
|
||||
const parsed = parseSignedBlock(signedText);
|
||||
if (!parsed) return { ok: false, error: "unrecognised signed message format" };
|
||||
if (expectedMessage != null && !sameSignedPayload(parsed.pairingText, expectedMessage)) {
|
||||
return { ok: false, error: "signed message does not match the submitted pairing code" };
|
||||
}
|
||||
const net = bip47utils.networks[network];
|
||||
let verified = false;
|
||||
try {
|
||||
verified = message.verify(parsed.message, parsed.address, parsed.signature, net.messagePrefix);
|
||||
} catch (e) {
|
||||
return { ok: false, error: "signature could not be verified (" + e.message + ")" };
|
||||
}
|
||||
if (!verified) return { ok: false, error: "invalid signature" };
|
||||
if (parsed.paymentCode) {
|
||||
const derived = notificationAddresses(parsed.paymentCode);
|
||||
if (!derived.length) {
|
||||
return { ok: false, error: "signature is valid, but the BIP47 line inside the signed message is not a valid payment code" };
|
||||
}
|
||||
if (!derived.includes(parsed.address)) {
|
||||
return { ok: false, error: "signature is valid, but the signing address is not the notification address of the payment code inside the message" };
|
||||
}
|
||||
}
|
||||
// expectedAddress may be a single address or every address the authenticated
|
||||
// code could have signed from (see notificationAddresses).
|
||||
const accept = expectedAddress == null ? null : (Array.isArray(expectedAddress) ? expectedAddress : [expectedAddress]);
|
||||
if (accept && !accept.includes(parsed.address)) {
|
||||
return { ok: false, error: "signed by a different address than the authenticated payment code" };
|
||||
}
|
||||
return { ok: true, address: parsed.address, paymentCode: parsed.paymentCode };
|
||||
}
|
||||
|
||||
// ---- operator binding (data/operator.json) ----------------------------------
|
||||
// A Dojo Bay instance MUST prove who runs it: operator.json binds the onion
|
||||
// address to the operator's payment code via a wallet signature over the text
|
||||
//
|
||||
// http://<onion>/
|
||||
//
|
||||
// BIP47: <payment code>
|
||||
//
|
||||
// (unlike pairing blocks, the BIP47 line here is INSIDE the signed message:
|
||||
// the operator pastes the whole text into the wallet's Sign tool). Verified at
|
||||
// install, at bootstrap import before trusting a remote instance's data, and
|
||||
// on every rebuild.
|
||||
// ---- signed URL claims -----------------------------------------------------
|
||||
// A verified operator domain is proven the same way the instance's own onion is:
|
||||
// the operator signs the URL, a blank line, then "BIP47: <their code>". Same
|
||||
// shape, same wallet procedure (PayNym → Sign message), so nothing new to learn
|
||||
// and no new crypto. This is deliberately a separate field from the pairing
|
||||
// payload: the pairing block attests to pairing data only, and operators
|
||||
// stuffing identity material into it is exactly what this feature replaces.
|
||||
export function claimText(url: string, paymentCode: string): string {
|
||||
return `${String(url).replace(/\/+$/, "")}/\n\nBIP47: ${paymentCode}`;
|
||||
}
|
||||
|
||||
// Verify a signed claim over `expectedUrl` by `paymentCode`. Returns
|
||||
// { ok } or { ok: false, error } with errors an operator can act on.
|
||||
export function verifySignedUrlClaim({ signed, expectedUrl, paymentCode }: {
|
||||
signed: string;
|
||||
expectedUrl: string;
|
||||
paymentCode: string;
|
||||
}): VerifyResult {
|
||||
if (!signed) return { ok: false, error: "no signed block supplied" };
|
||||
if (!paymentCode) return { ok: false, error: "no payment code supplied" };
|
||||
const t = String(signed).replace(/\r\n/g, "\n");
|
||||
const msgM = t.match(/BEGIN BITCOIN SIGNED MESSAGE-----[ \t]*\n?([\s\S]*?)\n-----BEGIN BITCOIN SIGNATURE/);
|
||||
const addrM = t.match(/Address:\s*(\S+)/);
|
||||
const sigM = t.match(/\n([A-Za-z0-9+\/=]{80,})\n*-----END BITCOIN SIGNATURE/);
|
||||
if (!msgM || !addrM || !sigM) {
|
||||
const missing = [
|
||||
!msgM && "the BEGIN BITCOIN SIGNED MESSAGE section",
|
||||
!addrM && "the Address: line",
|
||||
!sigM && "the signature line before END BITCOIN SIGNATURE",
|
||||
].filter(Boolean).join(", ");
|
||||
return { ok: false, error: `not a recognisable signed block (missing ${missing}) — the paste may have been truncated` };
|
||||
}
|
||||
const signedMessage = msgM[1].replace(/\n+$/, "");
|
||||
const norm = (u) => String(u || "").trim().replace(/\/+$/, "").toLowerCase();
|
||||
const firstLine = signedMessage.split("\n")[0].trim();
|
||||
if (norm(firstLine) !== norm(expectedUrl)) {
|
||||
return { ok: false, error: `the signed message starts with ${firstLine || "(nothing)"}, but this claim is for ${expectedUrl}` };
|
||||
}
|
||||
const bipM = signedMessage.match(/BIP47:\s*(PM8T[1-9A-HJ-NP-Za-km-z]+)/);
|
||||
if (!bipM) return { ok: false, error: "the signed message has no BIP47: line" };
|
||||
if (bipM[1] !== paymentCode) {
|
||||
return { ok: false, error: "the BIP47 line inside the signed message is a different payment code from the one you are signed in with" };
|
||||
}
|
||||
const accept = notificationAddresses(paymentCode);
|
||||
if (!accept.includes(addrM[1].trim())) {
|
||||
return { ok: false, error: `signed by ${addrM[1].trim()}, but your payment code's notification address is ${accept[0]} — sign under PayNym → Sign message, which uses your PayNym's notification address` };
|
||||
}
|
||||
const net = bip47utils.networks.bitcoin;
|
||||
try {
|
||||
if (!message.verify(signedMessage, addrM[1].trim(), sigM[1].trim(), net.messagePrefix)) {
|
||||
return { ok: false, error: "invalid signature" };
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, error: "signature could not be verified (" + e.message + ")" };
|
||||
}
|
||||
return { ok: true, address: addrM[1].trim() };
|
||||
}
|
||||
|
||||
export function verifyOperatorDoc(doc: any, { expectedOnion }: { expectedOnion?: string } = {}): VerifyResult {
|
||||
if (!doc || typeof doc !== "object") return { ok: false, error: "operator.json missing or unreadable" };
|
||||
if (!doc.paymentCode) return { ok: false, error: "operator.json has no paymentCode" };
|
||||
if (!doc.verifySigned) return { ok: false, error: "operator.json has no verifySigned block" };
|
||||
const t = String(doc.verifySigned).replace(/\r\n/g, "\n");
|
||||
// The newline after the BEGIN marker is optional: some terminals swallow it
|
||||
// when a block is pasted. It is not part of the signed text either way, so
|
||||
// tolerating it recovers the correct message rather than changing it.
|
||||
const msgM = t.match(/BEGIN BITCOIN SIGNED MESSAGE-----[ \t]*\n?([\s\S]*?)\n-----BEGIN BITCOIN SIGNATURE/);
|
||||
const addrM = t.match(/Address:\s*(\S+)/);
|
||||
const sigM = t.match(/\n([A-Za-z0-9+\/=]{80,})\n*-----END BITCOIN SIGNATURE/);
|
||||
if (!msgM || !addrM || !sigM) {
|
||||
// Name what is missing: a truncated or line-dropped paste is by far the
|
||||
// most common cause, and "not recognisable" alone sends people hunting
|
||||
// for a problem with their wallet instead of re-pasting.
|
||||
const missing = [
|
||||
!msgM && "the BEGIN BITCOIN SIGNED MESSAGE section",
|
||||
!addrM && "the Address: line",
|
||||
!sigM && "the signature line before END BITCOIN SIGNATURE",
|
||||
].filter(Boolean).join(", ");
|
||||
return { ok: false, error: `verifySigned is not a recognisable signed block (missing ${missing}) — the paste may have been truncated; paste the whole block again` };
|
||||
}
|
||||
const signedMessage = msgM[1].replace(/\n+$/, "");
|
||||
const norm = (u) => String(u || "").trim().replace(/\/+$/, "");
|
||||
const firstLine = signedMessage.split("\n")[0].trim();
|
||||
if (norm(firstLine) !== norm(doc.onion)) return { ok: false, error: "signed message does not match the declared onion" };
|
||||
if (expectedOnion && norm(doc.onion) !== norm(expectedOnion)) {
|
||||
return { ok: false, error: "declared onion does not match the address this document was fetched from" };
|
||||
}
|
||||
const bipM = signedMessage.match(/BIP47:\s*(PM8T[1-9A-HJ-NP-Za-km-z]+)/);
|
||||
if (!bipM || bipM[1] !== doc.paymentCode) {
|
||||
return { ok: false, error: "the BIP47 line inside the signed message does not match the declared payment code" };
|
||||
}
|
||||
// Accept either derivation of the notification address.
|
||||
//
|
||||
// A PayNym is a mainnet identity, but a wallet running on testnet derives the
|
||||
// notification address for THAT network, so the same payment code signs from
|
||||
// a different address depending on which mode the operator's wallet is in.
|
||||
// Insisting on the mainnet form refused perfectly good bindings from anyone
|
||||
// running a testnet wallet — the same defect fixed for listing signatures,
|
||||
// which this path missed.
|
||||
const accept = notificationAddresses(doc.paymentCode);
|
||||
const signer = addrM[1].trim();
|
||||
if (!accept.includes(signer)) {
|
||||
// Naming the addresses matters: the usual cause is signing from a different
|
||||
// account than the payment code entered, and the operator can only spot
|
||||
// that if they can see which address their wallet actually used.
|
||||
const expected = accept.length > 1
|
||||
? `${accept[0]} on mainnet, or ${accept[1]} from a testnet wallet`
|
||||
: accept[0] || "(the code could not be decoded)";
|
||||
return { ok: false, error: `signed by ${signer}, but the payment code's notification address is ${expected} — sign under PayNym → Sign message, which uses your PayNym's notification address` };
|
||||
}
|
||||
const net = bip47utils.networks.bitcoin; // the message prefix is the same on both
|
||||
try {
|
||||
if (!message.verify(signedMessage, signer, sigM[1].trim(), net.messagePrefix)) {
|
||||
return { ok: false, error: "invalid signature" };
|
||||
}
|
||||
} catch (e) { return { ok: false, error: "signature could not be verified (" + e.message + ")" }; }
|
||||
return { ok: true, address: signer };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — diagnose signed-block mismatches.
|
||||
//
|
||||
// READ-ONLY. For every record whose stored block does not pass the gate, this
|
||||
// answers the question the audit cannot: is the SIGNATURE bad, or is the stored
|
||||
// payload merely a different representation of the same signed text?
|
||||
//
|
||||
// For each record it reports, in order:
|
||||
// 1. INTERNAL VALIDITY - does the signature verify over the block's own text,
|
||||
// and does the BIP47 code inside that text derive the signing address?
|
||||
// If yes, the block is a genuine wallet export and nothing is forged.
|
||||
// 2. MESSAGE MATCH - does the pairing JSON inside the block equal
|
||||
// canonicalPairing(stored payload) byte for byte? If not, it shows the
|
||||
// first differing offset with a window either side, and whether the two
|
||||
// are the same DATA in a different serialisation (key order, spacing) or
|
||||
// genuinely different values.
|
||||
//
|
||||
// Run on the box: cd /var/www/dojobay/server && node diagnose-signed.mjs
|
||||
// Add --all to include records that already pass.
|
||||
// =============================================================================
|
||||
import { store } from "./store.ts";
|
||||
// canonicalPairing is imported, never reimplemented: a diagnostic that computes
|
||||
// the canonical message its own way can only ever report on itself.
|
||||
// server/selftest.mjs enforces the single definition.
|
||||
import { parseSignedBlock, verifySignedPayload, notificationAddresses, canonicalPairing } from "./crypto.ts";
|
||||
import { bitcoinMessageFactory } from "@dojo-tools/bitcoinjs-message";
|
||||
import * as bip47utils from "@dojo-tools/bip47/utils";
|
||||
import ecc from "@bitcoinerlab/secp256k1";
|
||||
|
||||
const message = bitcoinMessageFactory(ecc);
|
||||
const ALL = process.argv.includes("--all");
|
||||
const netOf = (rec) => (rec.network === "testnet" ? "testnet" : "bitcoin");
|
||||
|
||||
// Same data, different serialisation? Compare parsed structures, not strings.
|
||||
const deepEq = (a, b) => {
|
||||
try { return JSON.stringify(sortDeep(a)) === JSON.stringify(sortDeep(b)); } catch { return false; }
|
||||
};
|
||||
const sortDeep = (v) => {
|
||||
if (Array.isArray(v)) return v.map(sortDeep);
|
||||
if (v && typeof v === "object") {
|
||||
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortDeep(v[k])]));
|
||||
}
|
||||
return v;
|
||||
};
|
||||
|
||||
function firstDiff(a, b) {
|
||||
const n = Math.min(a.length, b.length);
|
||||
for (let i = 0; i < n; i++) if (a[i] !== b[i]) return i;
|
||||
return a.length === b.length ? -1 : n;
|
||||
}
|
||||
const window_ = (s, i) => JSON.stringify(s.slice(Math.max(0, i - 30), i + 30));
|
||||
|
||||
const recs = (await store.listSubmissions())
|
||||
.sort((a, b) => (a.network + a.name).localeCompare(b.network + b.name));
|
||||
|
||||
let genuine = 0, forged = 0, drifted = 0, reorder = 0;
|
||||
|
||||
for (const rec of recs) {
|
||||
if (!rec.signed) continue;
|
||||
const net = netOf(rec);
|
||||
const codes = Array.isArray(rec.paymentCodes) ? rec.paymentCodes : [];
|
||||
const canon = canonicalPairing(rec.payload);
|
||||
const passes = codes.some((c) => {
|
||||
try { return verifySignedPayload({ signedText: rec.signed, expectedMessage: canon, expectedAddress: notificationAddresses(c), network: net }).ok; }
|
||||
catch { return false; }
|
||||
});
|
||||
if (passes && !ALL) continue;
|
||||
|
||||
const label = rec.name && `${rec.network}-${rec.name}` !== rec.id ? `${rec.id} ("${rec.name}")` : rec.id;
|
||||
console.log(`\n=== ${label} (${rec.status})${passes ? " [currently passes]" : ""}`);
|
||||
const p = parseSignedBlock(rec.signed);
|
||||
if (!p) { console.log(" block does not parse at all"); continue; }
|
||||
|
||||
// 1. internal validity
|
||||
let sigOk = false;
|
||||
try { sigOk = message.verify(p.message, p.address, p.signature, bip47utils.networks[net].messagePrefix); } catch (e) { console.log(" verify threw:", e.message); }
|
||||
// A PayNym signs from its mainnet notification address whatever network the
|
||||
// node is on, so both derivations are legitimate.
|
||||
let derived = null;
|
||||
try { derived = p.paymentCode ? notificationAddresses(p.paymentCode) : null; } catch { derived = null; }
|
||||
const bound = Array.isArray(derived) && derived.includes(p.address);
|
||||
const derivedTxt = Array.isArray(derived) ? derived.join(" / ") : "(undecodable)";
|
||||
console.log(` signature over the block's own text : ${sigOk ? "VALID" : "INVALID"}`);
|
||||
console.log(` signing address : ${p.address}`);
|
||||
console.log(` BIP47 code inside the signed text : ${p.paymentCode ? p.paymentCode.slice(0, 12) + "…" : "(none)"} -> ${p.paymentCode ? derivedTxt : "n/a"} ${p.paymentCode ? (bound ? "(binds)" : "(DOES NOT BIND)") : ""}`);
|
||||
console.log(` record's payment code(s) : ${codes.map((c) => c.slice(0, 12) + "…").join(", ") || "(none)"}`);
|
||||
if (sigOk && bound) genuine++; else forged++;
|
||||
|
||||
// 2. message match
|
||||
if (p.pairingText === canon) { console.log(" pairing text matches the stored payload exactly"); continue; }
|
||||
const i = firstDiff(p.pairingText, canon);
|
||||
let signedObj, storedObj;
|
||||
try { signedObj = JSON.parse(p.pairingText); } catch {}
|
||||
try { storedObj = JSON.parse(canon); } catch {}
|
||||
const same = signedObj && storedObj && deepEq(signedObj, storedObj);
|
||||
if (same) reorder++; else drifted++;
|
||||
console.log(` pairing text DIFFERS from the stored payload`);
|
||||
console.log(` same data, different serialisation? ${same ? "YES - key order/spacing only" : "NO - the values themselves differ"}`);
|
||||
console.log(` lengths: signed ${p.pairingText.length}, stored ${canon.length}; first difference at offset ${i}`);
|
||||
console.log(` signed: ${window_(p.pairingText, i)}`);
|
||||
console.log(` stored: ${window_(canon, i)}`);
|
||||
if (!same && signedObj && storedObj) {
|
||||
const keys = new Set([...Object.keys(signedObj), ...Object.keys(storedObj)]);
|
||||
for (const k of keys) {
|
||||
if (JSON.stringify(sortDeep(signedObj[k])) !== JSON.stringify(sortDeep(storedObj[k]))) {
|
||||
console.log(` top-level key "${k}" differs:`);
|
||||
console.log(` signed: ${JSON.stringify(signedObj[k])}`);
|
||||
console.log(` stored: ${JSON.stringify(storedObj[k])}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nSummary of blocks examined: ${genuine} genuine (valid signature, code binds), ${forged} not genuine.`);
|
||||
console.log(`Mismatches: ${reorder} serialisation-only, ${drifted} with genuinely different values.`);
|
||||
console.log(genuine && !forged
|
||||
? "\nEvery block examined is a real wallet export; the failures are a stored-payload representation problem, not a trust problem."
|
||||
: "");
|
||||
@@ -0,0 +1,182 @@
|
||||
// =============================================================================
|
||||
// TXT record lookups over Tor, for verified operator domains.
|
||||
//
|
||||
// A Tor-only instance has no ordinary path to a TXT record: Tor's SOCKS
|
||||
// interface resolves names but cannot fetch arbitrary record types. So we ask
|
||||
// public DNS-over-HTTPS resolvers, tunnelling the HTTPS through the same SOCKS
|
||||
// proxy the probes use.
|
||||
//
|
||||
// Two deliberate choices, because a resolver's answer decides whether a
|
||||
// verified badge appears and a lying resolver could mint one:
|
||||
//
|
||||
// 1. Several independent resolvers are queried and a fixed number must agree
|
||||
// before a domain is treated as verified (DOH_AGREEMENT, default 2).
|
||||
// 2. "Could not reach enough resolvers" is reported as INCONCLUSIVE, never as
|
||||
// a failure, so a Tor hiccup cannot strip a badge from an honest operator.
|
||||
//
|
||||
// The HTTPS-over-Tor fetch here duplicates a little of updates.mjs on purpose:
|
||||
// that module is on the self-update path, which has never been exercised on real
|
||||
// hardware, and refactoring it to share code is not a risk worth taking for a
|
||||
// feature that only reads DNS.
|
||||
// =============================================================================
|
||||
import tls from "node:tls";
|
||||
import { socks5Connect } from "../scripts/update.mjs";
|
||||
import type { ProbeCfg } from "../types.js";
|
||||
|
||||
/** Transport settings a lookup needs; the caller may supply a subset. */
|
||||
type LookupCfg = Partial<ProbeCfg> & {
|
||||
/**
|
||||
* An extra certificate authority to trust for this lookup, and nothing else.
|
||||
*
|
||||
* Exists so the self-test can run the whole path — SOCKS, TLS, HTTP, DoH JSON
|
||||
* — against a mock resolver holding a self-signed certificate, WITHOUT
|
||||
* reaching for NODE_TLS_REJECT_UNAUTHORIZED, which switches validation off
|
||||
* for the entire process and every other connection made while it is set.
|
||||
* Certificate validation stays on here; the test simply supplies the anchor
|
||||
* that makes its own certificate valid.
|
||||
*/
|
||||
tlsCa?: string | Buffer | Array<string | Buffer>;
|
||||
};
|
||||
|
||||
interface ResolverAnswer { host: string; records: string[] }
|
||||
export interface TxtLookup {
|
||||
records: string[];
|
||||
answered: number;
|
||||
byResolver: ResolverAnswer[];
|
||||
errors: string[];
|
||||
}
|
||||
export interface TxtAgreement {
|
||||
ok: boolean;
|
||||
/** True when too few resolvers replied to draw any conclusion. */
|
||||
inconclusive: boolean;
|
||||
/** Absent when the lookup threw before any resolver could be counted. */
|
||||
answered?: number;
|
||||
agreed?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Resolvers use different JSON paths but the same response shape.
|
||||
const RESOLVERS: { host: string; path: string }[] = [
|
||||
{ host: "cloudflare-dns.com", path: "/dns-query" },
|
||||
{ host: "dns.quad9.net", path: "/dns-query" },
|
||||
{ host: "dns.google", path: "/resolve" },
|
||||
];
|
||||
|
||||
export const DOH_AGREEMENT = Math.max(1, +(process.env.DOH_AGREEMENT || 2));
|
||||
const MAX_BODY = 64 * 1024; // a TXT answer is tiny; cap the read
|
||||
|
||||
function resolverList(): { host: string; path: string }[] {
|
||||
const only = (process.env.DOH_RESOLVERS || "").trim();
|
||||
if (!only) return RESOLVERS;
|
||||
const wanted = only.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
return RESOLVERS.filter((r) => wanted.includes(r.host));
|
||||
}
|
||||
|
||||
// One HTTPS GET through the Tor SOCKS proxy, returning the response body as
|
||||
// text. Deliberately minimal: no redirects (a resolver that redirects is not
|
||||
// one we want), and a hard body cap.
|
||||
async function httpsGetOverTor(host: string, path: string,
|
||||
{ proxyHost, proxyPort, timeoutMs = 20000, tlsCa }: LookupCfg): Promise<string> {
|
||||
const raw = await socks5Connect(proxyHost, proxyPort, host, 443, timeoutMs);
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let done = false;
|
||||
const finish = (fn: (a?: any) => void, arg?: any) => { if (!done) { done = true; clearTimeout(timer); try { socket.destroy(); } catch {} fn(arg); } };
|
||||
const timer = setTimeout(() => finish(reject, new Error("timeout")), timeoutMs);
|
||||
const socket = tls.connect({ socket: raw, servername: host, ...(tlsCa ? { ca: tlsCa } : {}) }, () => {
|
||||
socket.write(
|
||||
`GET ${path} HTTP/1.1\r\nHost: ${host}\r\nUser-Agent: dojobay-domain-check\r\n` +
|
||||
`Accept: application/dns-json\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n`);
|
||||
});
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
socket.on("data", (d: Buffer) => {
|
||||
size += d.length;
|
||||
if (size > MAX_BODY) return finish(reject, new Error("response too large"));
|
||||
chunks.push(d);
|
||||
});
|
||||
socket.on("error", (e: Error) => finish(reject, e));
|
||||
socket.on("close", () => {
|
||||
if (done) return;
|
||||
try {
|
||||
const all = Buffer.concat(chunks);
|
||||
const headEnd = all.indexOf("\r\n\r\n");
|
||||
if (headEnd < 0) return finish(reject, new Error("malformed reply"));
|
||||
const headText = all.subarray(0, headEnd).toString("latin1");
|
||||
const m = headText.match(/^HTTP\/1\.[01] (\d{3})/);
|
||||
if (!m) return finish(reject, new Error("malformed reply"));
|
||||
if (+m[1] !== 200) return finish(reject, new Error("HTTP " + m[1]));
|
||||
let body = all.subarray(headEnd + 4);
|
||||
if (/transfer-encoding:\s*chunked/i.test(headText)) {
|
||||
const parts: Buffer[] = []; let p = 0;
|
||||
for (;;) {
|
||||
const nl = body.indexOf("\r\n", p);
|
||||
if (nl < 0) break;
|
||||
const n = parseInt(body.subarray(p, nl).toString("latin1"), 16);
|
||||
if (!n) break;
|
||||
parts.push(body.subarray(nl + 2, nl + 2 + n));
|
||||
p = nl + 2 + n + 2;
|
||||
}
|
||||
body = Buffer.concat(parts);
|
||||
}
|
||||
finish(resolve, body.toString("utf8"));
|
||||
} catch (e) { finish(reject, e); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// A DoH JSON answer gives TXT data as a quoted string, and a long record as
|
||||
// several quoted strings that must be concatenated. Normalise both to one line.
|
||||
export function parseTxtAnswer(json: string): string[] | null {
|
||||
let doc: any;
|
||||
try { doc = JSON.parse(json); } catch { return null; }
|
||||
if (typeof doc !== "object" || doc === null) return null;
|
||||
if (doc.Status === 3) return []; // NXDOMAIN: no records
|
||||
if (doc.Status !== 0) return null; // SERVFAIL etc: no answer
|
||||
const answers = Array.isArray(doc.Answer) ? doc.Answer : [];
|
||||
return answers
|
||||
.filter((a: any) => a && (a.type === 16 || a.type === undefined))
|
||||
.map((a: any) => String(a.data || ""))
|
||||
.map((d: string) => (d.includes('"') ? (d.match(/"([^"]*)"/g) || []).map((s) => s.slice(1, -1)).join("") : d))
|
||||
.map((d: string) => d.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// Look up TXT records for `name` across the resolvers. Returns
|
||||
// { records, answered, byResolver, errors } where `records` is the set of
|
||||
// records seen and `answered` counts resolvers that gave a usable answer.
|
||||
export async function lookupTxt(name: string, cfg: LookupCfg = {}): Promise<TxtLookup> {
|
||||
const resolvers = resolverList();
|
||||
const q = `?name=${encodeURIComponent(name)}&type=TXT`;
|
||||
const results = await Promise.allSettled(resolvers.map(async (r) => {
|
||||
const body = await httpsGetOverTor(r.host, r.path + q, cfg);
|
||||
const recs = parseTxtAnswer(body);
|
||||
if (recs === null) throw new Error("resolver returned no usable answer");
|
||||
return { host: r.host, records: recs };
|
||||
}));
|
||||
const byResolver: ResolverAnswer[] = [];
|
||||
const errors: string[] = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const r = results[i]; // a local, so the union narrows
|
||||
if (r.status === "fulfilled") byResolver.push(r.value);
|
||||
else errors.push(`${resolvers[i].host}: ${r.reason?.message || "failed"}`);
|
||||
}
|
||||
const records = [...new Set(byResolver.flatMap((r) => r.records))];
|
||||
return { records, answered: byResolver.length, byResolver, errors };
|
||||
}
|
||||
|
||||
// Do at least DOH_AGREEMENT resolvers see a record satisfying `predicate`?
|
||||
// Distinguishes "not there" from "we could not tell".
|
||||
export async function txtRecordAgreed(name: string, predicate: (r: string) => boolean,
|
||||
cfg: LookupCfg = {}): Promise<TxtAgreement> {
|
||||
const { answered, byResolver, errors, records } = await lookupTxt(name, cfg);
|
||||
if (answered < DOH_AGREEMENT) {
|
||||
return { ok: false, inconclusive: true, answered, agreed: 0,
|
||||
error: `only ${answered} of ${DOH_AGREEMENT} required resolvers answered (${errors.join("; ") || "no detail"})` };
|
||||
}
|
||||
const agreed = byResolver.filter((r) => r.records.some(predicate)).length;
|
||||
if (agreed >= DOH_AGREEMENT) return { ok: true, inconclusive: false, answered, agreed };
|
||||
return { ok: false, inconclusive: false, answered, agreed,
|
||||
error: records.length
|
||||
? `${agreed} of ${DOH_AGREEMENT} required resolvers saw a matching record; ${records.length} TXT record(s) present but not matching`
|
||||
: `no TXT record found at ${name}` };
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// =============================================================================
|
||||
// Dojo version comparison, and the minimum this directory will accept.
|
||||
//
|
||||
// A version reaches us two ways, and they are not equally trustworthy:
|
||||
//
|
||||
// detected read from the node's own X-Dojo-Version response header during a
|
||||
// probe. This is what the node is actually running.
|
||||
// declared the `version` inside the pairing payload. Informational, frozen
|
||||
// when the payload was generated, and often stale — one listing
|
||||
// here declares 1.4.5 while running something far newer, because
|
||||
// the payload was restored to match the signature that covers it.
|
||||
//
|
||||
// So anything deciding on a version prefers the detected one, and falls back to
|
||||
// the declared one only when the node did not report a header at all.
|
||||
// =============================================================================
|
||||
|
||||
// A country code inferred from whatever an operator wrote about where they are,
|
||||
// or nothing at all.
|
||||
//
|
||||
// The point is flags where we can manage them and no obligation anywhere else.
|
||||
// An operator is asked one free-text question and may answer "Finland", "FI",
|
||||
// "Central America", "Europe" or "Ancapistan"; the first two get a flag and the
|
||||
// rest do not, and none of them is an error. Nothing is enforced and nothing is
|
||||
// refused, because a directory of onion services has no business insisting that
|
||||
// somebody name a state.
|
||||
//
|
||||
// The names come from the runtime rather than a table in this repository.
|
||||
// Intl.DisplayNames knows 280 region codes and their English names, so the
|
||||
// lookup is current with the platform's ICU data instead of decaying in a file
|
||||
// nobody revisits. That also means an unassigned pair like XX yields nothing:
|
||||
// the runtime does not recognise it, so it cannot be a flag, and letterboxes on
|
||||
// a card read as a broken listing rather than a missing flag.
|
||||
const REGION_NAMES = new Intl.DisplayNames(["en"], { type: "region" });
|
||||
|
||||
/** lowercased name -> code, built once from whatever the runtime knows. */
|
||||
const NAME_TO_CODE: Map<string, string> = (() => {
|
||||
const m = new Map<string, string>();
|
||||
for (let a = 65; a < 91; a++) {
|
||||
for (let b = 65; b < 91; b++) {
|
||||
const cc = String.fromCharCode(a, b);
|
||||
let name: string | undefined;
|
||||
try { name = REGION_NAMES.of(cc); } catch { continue; }
|
||||
if (name && name !== cc) m.set(name.toLowerCase(), cc);
|
||||
}
|
||||
}
|
||||
// The handful the runtime will not answer to, because people do not write
|
||||
// country names the way the standard does. UK is the one that matters: it is
|
||||
// not a code, and typed as one it renders as two letterboxes.
|
||||
for (const [alias, cc] of [
|
||||
["uk", "GB"], ["united kingdom", "GB"], ["great britain", "GB"], ["britain", "GB"],
|
||||
["england", "GB"], ["scotland", "GB"], ["wales", "GB"], ["northern ireland", "GB"],
|
||||
["usa", "US"], ["u.s.a.", "US"], ["u.s.", "US"], ["america", "US"],
|
||||
["holland", "NL"], ["czech republic", "CZ"], ["south korea", "KR"], ["north korea", "KP"],
|
||||
["russia", "RU"], ["uae", "AE"], ["eu", "EU"], ["european union", "EU"],
|
||||
]) m.set(alias, cc);
|
||||
return m;
|
||||
})();
|
||||
|
||||
export function countryFor(text: unknown): string | null {
|
||||
const raw = String(text ?? "").trim();
|
||||
if (!raw) return null;
|
||||
// Segments, so "Helsinki, Finland" and "Europe (Finland)" both find something.
|
||||
// Longest first: "United States" should win over a stray "US" elsewhere in
|
||||
// the same answer.
|
||||
const parts = raw.split(/[,;/()\u2013\u2014|]+/).map((x) => x.trim()).filter(Boolean);
|
||||
for (const part of [raw, ...parts].sort((a, b) => b.length - a.length)) {
|
||||
const key = part.toLowerCase().replace(/\.$/, "");
|
||||
const named = NAME_TO_CODE.get(key);
|
||||
if (named) return named;
|
||||
if (/^[a-z]{2}$/i.test(part)) {
|
||||
const cc = part.toUpperCase();
|
||||
// Only if the runtime recognises it: an unassigned pair has no flag, and
|
||||
// two letterboxes look like a fault rather than an absence.
|
||||
try { if (REGION_NAMES.of(cc) !== cc) return cc; } catch { /* not a region */ }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Which network a pairing URL is for, read from the URL itself.
|
||||
//
|
||||
// A Dojo serves its testnet API under a `test` path segment and its mainnet API
|
||||
// without one: http://<onion>/test/v2 against http://<onion>/v2. That makes the
|
||||
// operator's declared network checkable against the endpoint they gave, and it
|
||||
// is worth checking, because a crossed pair is wrong in a way nothing
|
||||
// downstream catches. A testnet node listed as mainnet answers, reports a
|
||||
// height and probes green indefinitely; the only symptom is a block height a
|
||||
// few hundred thousand adrift, which reads as nothing at all, and anyone
|
||||
// pairing with it is sent to a chain they did not ask for.
|
||||
//
|
||||
// A whole path SEGMENT, never a substring: an onion address is base32 and can
|
||||
// carry those four letters in a row by chance, and /v2/testing is not a testnet
|
||||
// endpoint either.
|
||||
//
|
||||
// It lives here rather than in the installer because the same judgement belongs
|
||||
// at the submission gate, and this module is already where a node's declared
|
||||
// properties are judged against what it actually is.
|
||||
export function pairingNetwork(url: string): "mainnet" | "testnet" | null {
|
||||
try {
|
||||
return new URL(url).pathname.split("/").some((seg) => seg.toLowerCase() === "test")
|
||||
? "testnet" : "mainnet";
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** The lowest Dojo this directory will accept for a NEW listing. Set to "" or
|
||||
* "0" to disable the check entirely. Existing listings are never re-judged. */
|
||||
export const MIN_DOJO_VERSION = (process.env.MIN_DOJO_VERSION ?? "1.27.0").trim();
|
||||
|
||||
/** "v1.27.0-rc1" -> [1, 27, 0]. Null when there is no version in there at all. */
|
||||
export function parseVersion(v: unknown): number[] | null {
|
||||
if (typeof v !== "string") return null;
|
||||
const m = v.trim().replace(/^v/i, "").match(/^(\d+(?:\.\d+)*)/);
|
||||
if (!m) return null;
|
||||
const parts = m[1].split(".").map((n) => Number(n));
|
||||
return parts.every((n) => Number.isFinite(n)) ? parts : null;
|
||||
}
|
||||
|
||||
/** -1, 0 or 1. Missing components count as zero, so 1.27 equals 1.27.0. */
|
||||
export function compareVersions(a: unknown, b: unknown): number {
|
||||
const x = parseVersion(a) || [], y = parseVersion(b) || [];
|
||||
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
||||
const d = (x[i] || 0) - (y[i] || 0);
|
||||
if (d) return d > 0 ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function meetsMinimum(version: unknown, minimum: string = MIN_DOJO_VERSION): boolean {
|
||||
if (!minimum || compareVersions(minimum, "0") === 0) return true; // check disabled
|
||||
return compareVersions(version, minimum) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Judge a node's version for the submission gates.
|
||||
*
|
||||
* `unknown` is deliberately its own outcome rather than a silent pass or a
|
||||
* silent refusal: a node that reports no version at all is almost certainly too
|
||||
* old to carry the endpoints this directory reads, but saying so plainly is
|
||||
* more useful to an operator than either guessing.
|
||||
*/
|
||||
export function judgeVersion(
|
||||
detected: unknown, declared: unknown, minimum: string = MIN_DOJO_VERSION,
|
||||
): { ok: boolean; version: string | null; source: "detected" | "declared" | null; reason?: string } {
|
||||
if (!minimum || compareVersions(minimum, "0") === 0) {
|
||||
return { ok: true, version: (detected as string) || (declared as string) || null,
|
||||
source: detected ? "detected" : declared ? "declared" : null };
|
||||
}
|
||||
const version = (parseVersion(detected) ? detected : parseVersion(declared) ? declared : null) as string | null;
|
||||
const source = parseVersion(detected) ? "detected" as const : parseVersion(declared) ? "declared" as const : null;
|
||||
if (!version) {
|
||||
return { ok: false, version: null, source: null,
|
||||
reason: `this Dojo did not report a version, so it cannot be checked against the minimum of ${minimum}. `
|
||||
+ "Dojo has sent an X-Dojo-Version header on every response since well before that, so a node that "
|
||||
+ "sends none is almost certainly older. Upgrade, then submit again." };
|
||||
}
|
||||
if (!meetsMinimum(version, minimum)) {
|
||||
return { ok: false, version, source,
|
||||
reason: `this Dojo reports version ${version}, and this directory requires ${minimum} or newer. `
|
||||
+ "Earlier versions do not serve the endpoints listings are checked against. Upgrade, then submit again." };
|
||||
}
|
||||
return { ok: true, version, source };
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// =============================================================================
|
||||
// Verified operator domains.
|
||||
//
|
||||
// One domain per operator, bound to their BIP47 payment code and proven in both
|
||||
// directions, so neither half alone is enough:
|
||||
//
|
||||
// the domain asserts the code a TXT record at _dojobay.<domain> naming the
|
||||
// payment code; publishing it needs control of
|
||||
// the domain
|
||||
// the code asserts the domain a wallet-signed statement naming the domain;
|
||||
// producing it needs the PayNym's notification
|
||||
// key
|
||||
//
|
||||
// The signature is permanent and the TXT record is the revocable half. Remove
|
||||
// the record and the next sweep fails; after a grace period the badge drops,
|
||||
// while the claim is kept so restoring the record restores the badge without
|
||||
// re-signing. A domain that changes hands therefore stops being claimable by
|
||||
// its old owner without anyone having to notice.
|
||||
//
|
||||
// The signed statement deliberately omits this instance's onion, so a proof is
|
||||
// portable: a bootstrap import or peer sync carries it intact.
|
||||
//
|
||||
// A verified badge attests to CONTROL of a domain, not to trustworthiness: a
|
||||
// lookalike domain verifies exactly as easily as a real one. Hence admin
|
||||
// revocation, and punycode display for anything non-ASCII.
|
||||
// =============================================================================
|
||||
import { claimText, verifySignedUrlClaim } from "./crypto.ts";
|
||||
import { txtRecordAgreed } from "./dns.ts";
|
||||
import type { TxtAgreement } from "./dns.ts";
|
||||
import type { DomainClaim, ProbeCfg } from "../types.js";
|
||||
|
||||
type LookupCfg = Partial<ProbeCfg>;
|
||||
|
||||
/** A normalised domain, or the reason the input could not be one. */
|
||||
export type NormalisedDomain =
|
||||
| { ok: true; domain: string; punycode: boolean; error?: undefined }
|
||||
| { ok: false; error: string; domain?: undefined; punycode?: undefined };
|
||||
|
||||
export interface ClaimVerification {
|
||||
ok: boolean;
|
||||
stage?: "signature" | "dns";
|
||||
error?: string;
|
||||
hint?: string | null;
|
||||
inconclusive?: boolean;
|
||||
agreed?: number;
|
||||
answered?: number;
|
||||
}
|
||||
|
||||
export const TXT_PREFIX = "_dojobay";
|
||||
export const CLAIM_VERSION = "dojobay-domain-v1";
|
||||
export const RECHECK_MS = +(process.env.DOMAIN_RECHECK_HOURS || 24) * 3600 * 1000;
|
||||
/** A claim awaiting its first successful lookup is retried far more often: the
|
||||
* operator has just published a TXT record and is waiting for propagation. */
|
||||
export const PENDING_RECHECK_MS = +(process.env.DOMAIN_PENDING_RECHECK_MINUTES || 5) * 60 * 1000;
|
||||
export const GRACE_DAYS = +(process.env.DOMAIN_GRACE_DAYS || 7);
|
||||
|
||||
// Accept "example.com", "example.com/", "https://example.com" or a full URL and
|
||||
// reduce it to the bare ASCII host. Rejects anything that cannot be a public
|
||||
// domain an operator could publish a TXT record on.
|
||||
export function normaliseDomain(input: unknown): NormalisedDomain {
|
||||
let raw = String(input || "").trim().toLowerCase();
|
||||
if (!raw) return { ok: false, error: "enter a domain" };
|
||||
if (raw.includes(" ")) return { ok: false, error: "a domain cannot contain spaces" };
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\//.test(raw)) raw = "https://" + raw;
|
||||
let u: URL;
|
||||
try { u = new URL(raw); } catch { return { ok: false, error: "that is not a valid domain" }; }
|
||||
if (u.protocol !== "https:" && u.protocol !== "http:") return { ok: false, error: "use a plain domain, not a " + u.protocol.replace(":", "") + " URL" };
|
||||
if (u.username || u.password) return { ok: false, error: "a domain cannot contain a username or password" };
|
||||
if (u.port) return { ok: false, error: "leave the port off: verification uses DNS, not a web server" };
|
||||
const host = u.hostname; // WHATWG URL gives punycode for IDN
|
||||
if (host.endsWith(".onion")) return { ok: false, error: "an onion address cannot be verified by DNS; this field is for a clearnet domain you own" };
|
||||
if (host === "localhost" || /^\d+\.\d+\.\d+\.\d+$/.test(host) || host.startsWith("[")) {
|
||||
return { ok: false, error: "use a domain name, not an IP address" };
|
||||
}
|
||||
if (!/^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/.test(host)) {
|
||||
return { ok: false, error: "that is not a valid domain name" };
|
||||
}
|
||||
if (host.split(".").length < 2) return { ok: false, error: "include the full domain, for example example.com" };
|
||||
return { ok: true, domain: host, punycode: /[^\x00-\x7F]/.test(String(input)) || host.includes("xn--") };
|
||||
}
|
||||
|
||||
export const txtName = (domain: string): string => `${TXT_PREFIX}.${domain}`;
|
||||
/** The Host/Name field in a DNS panel is relative to the zone, so most control
|
||||
* panels want just this label. Handing over the fully-qualified name instead is
|
||||
* the classic way to end up with _dojobay.example.com.example.com. */
|
||||
export const txtHost = (): string => TXT_PREFIX;
|
||||
export const txtValue = (paymentCode: string): string => `${CLAIM_VERSION} pm=${paymentCode}`;
|
||||
export const signingText = (domain: string, paymentCode: string): string => claimText(`https://${domain}`, paymentCode);
|
||||
|
||||
// Does a TXT record claim this payment code? Tolerant of extra whitespace and
|
||||
// of the record being wrapped in quotes by a DNS UI, strict about the code.
|
||||
export function txtMatches(record: unknown, paymentCode: string): boolean {
|
||||
const r = String(record || "").trim().replace(/^"|"$/g, "").replace(/\s+/g, " ");
|
||||
if (!r.startsWith(CLAIM_VERSION)) return false;
|
||||
const m = r.match(/\bpm=(PM8T[1-9A-HJ-NP-Za-km-z]+)/);
|
||||
return !!m && m[1] === paymentCode;
|
||||
}
|
||||
|
||||
// Full verification: the signature first (cheap, local, and the operator's most
|
||||
// likely mistake), then DNS (slow, over Tor).
|
||||
export async function verifyClaim(
|
||||
{ domain, paymentCode, signed }: { domain: string; paymentCode: string; signed: string },
|
||||
cfg: LookupCfg = {},
|
||||
): Promise<ClaimVerification> {
|
||||
const sig = verifySignedUrlClaim({ signed, expectedUrl: `https://${domain}`, paymentCode });
|
||||
if (!sig.ok) return { ok: false, stage: "signature", error: sig.error };
|
||||
const dns = await txtRecordAgreed(txtName(domain), (r) => txtMatches(r, paymentCode), cfg);
|
||||
if (!dns.ok) {
|
||||
return { ok: false, stage: "dns", inconclusive: !!dns.inconclusive, error: dns.error,
|
||||
hint: dns.inconclusive ? null : `publish a TXT record at ${txtName(domain)} containing: ${txtValue(paymentCode)}` };
|
||||
}
|
||||
return { ok: true, agreed: dns.agreed, answered: dns.answered };
|
||||
}
|
||||
|
||||
// DNS-only re-check for the periodic sweep: the signature is immutable once
|
||||
// accepted, so there is nothing to re-verify locally.
|
||||
export async function recheckClaim(claim: DomainClaim, cfg: LookupCfg = {}): Promise<TxtAgreement> {
|
||||
return txtRecordAgreed(txtName(claim.domain), (r) => txtMatches(r, claim.paymentCode), cfg);
|
||||
}
|
||||
|
||||
// Fold a re-check result into a claim, applying the grace period. Pure, so the
|
||||
// policy is testable without any network.
|
||||
export function applyRecheck(claim: DomainClaim, result: TxtAgreement, now: number = Date.now()): DomainClaim {
|
||||
const next: DomainClaim = { ...claim, last_check: new Date(now).toISOString() };
|
||||
if (result.inconclusive) {
|
||||
// Could not tell. Change nothing except the timestamp, and say so.
|
||||
next.last_result = "inconclusive: " + (result.error || "no detail");
|
||||
return next;
|
||||
}
|
||||
if (result.ok) {
|
||||
next.verified = true;
|
||||
next.verified_at = next.verified_at || new Date(now).toISOString();
|
||||
next.fail_since = null;
|
||||
next.last_result = "ok";
|
||||
return next;
|
||||
}
|
||||
next.last_result = result.error || "no matching TXT record";
|
||||
next.fail_since = claim.fail_since || new Date(now).toISOString();
|
||||
const failingMs = now - Date.parse(next.fail_since);
|
||||
if (failingMs >= GRACE_DAYS * 86400 * 1000) next.verified = false;
|
||||
return next;
|
||||
}
|
||||
|
||||
export const isDue = (claim: DomainClaim, now: number = Date.now()): boolean => {
|
||||
if (!claim.last_check) return true;
|
||||
const since = now - Date.parse(claim.last_check);
|
||||
return since >= (claim.verified ? RECHECK_MS : PENDING_RECHECK_MS);
|
||||
};
|
||||
|
||||
// A URL is publishable as a card link only if it sits on the operator's verified
|
||||
// domain (the domain itself or a subdomain of it). This is what stops a card
|
||||
// carrying an unverifiable social profile while keeping "link to my own site".
|
||||
export function urlOnDomain(url: unknown, domain: string | null | undefined): boolean {
|
||||
if (!url || !domain) return false;
|
||||
let u: URL;
|
||||
try { u = new URL(String(url)); } catch { return false; }
|
||||
if (u.protocol !== "https:" && u.protocol !== "http:") return false;
|
||||
const h = u.hostname.toLowerCase();
|
||||
return h === domain || h.endsWith("." + domain);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — restore payload.pairing.version to the value that was signed.
|
||||
//
|
||||
// Some records have a stored pairing payload whose `version` was updated after
|
||||
// the operator signed it (a Dojo upgrade, typically), so the published payload
|
||||
// no longer matches the signature that attests to it. The version is purely
|
||||
// informational and the live value is read from the node's X-Dojo-Version
|
||||
// header on every probe, so the right correction is to put the payload back to
|
||||
// what was signed and let the next signed submission move both together.
|
||||
//
|
||||
// STRICTLY LIMITED: this only ever writes payload.pairing.version, and only on
|
||||
// records where the signed block and the stored payload are otherwise
|
||||
// identical (key order and whitespace ignored). Anything else is reported and
|
||||
// left alone.
|
||||
//
|
||||
// Usage, on the box:
|
||||
// cd /var/www/dojobay/server
|
||||
// node fix-payload-version.mjs # dry run, changes nothing
|
||||
// sudo systemctl stop dojobay-server.service
|
||||
// node fix-payload-version.mjs --apply # writes, after a backup
|
||||
// sudo systemctl start dojobay-server.service
|
||||
//
|
||||
// The stop/start matters: server/store.ts keeps the store in memory and is
|
||||
// designed as a single writer, so editing store.json underneath a running
|
||||
// server would be overwritten by its next session or nonce write. --apply
|
||||
// refuses to run while the service is active unless you pass --force.
|
||||
// =============================================================================
|
||||
import { readFile, writeFile, rename, copyFile } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseSignedBlock } from "./crypto.ts";
|
||||
|
||||
const APPLY = process.argv.includes("--apply");
|
||||
const FORCE = process.argv.includes("--force");
|
||||
const DIR = process.env.SERVER_DATA_DIR
|
||||
|| path.resolve(path.dirname(fileURLToPath(import.meta.url)), "data");
|
||||
const FILE = path.join(DIR, "store.json");
|
||||
|
||||
const stable = (v) => {
|
||||
if (Array.isArray(v)) return "[" + v.map(stable).join(",") + "]";
|
||||
if (v && typeof v === "object") {
|
||||
return "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + stable(v[k])).join(",") + "}";
|
||||
}
|
||||
return JSON.stringify(v) ?? "null";
|
||||
};
|
||||
// Everything except pairing.version, so we can prove that is the only difference.
|
||||
const withoutVersion = (payload) => {
|
||||
const p = structuredClone(payload || {});
|
||||
if (p.pairing && typeof p.pairing === "object") delete p.pairing.version;
|
||||
return { pairing: p.pairing, explorer: p.explorer };
|
||||
};
|
||||
|
||||
if (APPLY && !FORCE) {
|
||||
let active = "";
|
||||
try { active = execFileSync("systemctl", ["is-active", "dojobay-server.service"], { encoding: "utf8" }).trim(); } catch (e) { active = (e.stdout || "").trim(); }
|
||||
if (active === "active") {
|
||||
console.error("REFUSING: dojobay-server.service is running.\n" +
|
||||
"The store is held in memory by the server and would overwrite this edit.\n" +
|
||||
" sudo systemctl stop dojobay-server.service\n" +
|
||||
" node fix-payload-version.mjs --apply\n" +
|
||||
" sudo systemctl start dojobay-server.service\n" +
|
||||
"(--force overrides this check, but do not use it on a live instance.)");
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const raw = await readFile(FILE, "utf8");
|
||||
const doc = JSON.parse(raw);
|
||||
const recs = Object.values(doc.submissions || {}).sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
const planned = [];
|
||||
const skipped = [];
|
||||
for (const rec of recs) {
|
||||
if (!rec.signed) continue;
|
||||
const p = parseSignedBlock(rec.signed);
|
||||
if (!p) { skipped.push([rec.id, "signed block does not parse"]); continue; }
|
||||
let signedObj;
|
||||
try { signedObj = JSON.parse(p.pairingText); } catch { skipped.push([rec.id, "signed text is not a bare pairing JSON (extra content around it)"]); continue; }
|
||||
const sv = signedObj?.pairing?.version ?? null;
|
||||
const cv = rec.payload?.pairing?.version ?? null;
|
||||
if (sv === cv) continue; // nothing to do
|
||||
if (stable(withoutVersion(signedObj)) !== stable(withoutVersion(rec.payload))) {
|
||||
skipped.push([rec.id, `differs beyond the version (signed ${JSON.stringify(sv)} vs stored ${JSON.stringify(cv)}), left alone`]);
|
||||
continue;
|
||||
}
|
||||
planned.push({ rec, from: cv, to: sv });
|
||||
}
|
||||
|
||||
console.log(`Store: ${FILE}`);
|
||||
console.log(`Records with a signed block: ${recs.filter((r) => r.signed).length}\n`);
|
||||
|
||||
if (planned.length) {
|
||||
console.log(`Version-only differences (${planned.length}) — payload.pairing.version will be set back to the signed value:`);
|
||||
for (const { rec, from, to } of planned) console.log(` ${rec.id}: ${JSON.stringify(from)} -> ${JSON.stringify(to)}`);
|
||||
console.log("");
|
||||
}
|
||||
if (skipped.length) {
|
||||
console.log(`Not touched (${skipped.length}):`);
|
||||
for (const [id, why] of skipped) console.log(` ${id}: ${why}`);
|
||||
console.log("");
|
||||
}
|
||||
if (!planned.length) { console.log("Nothing to change."); process.exit(0); }
|
||||
|
||||
if (!APPLY) {
|
||||
console.log("DRY RUN — nothing written. Re-run with --apply (with the service stopped) to make these changes.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backup = `${FILE}.bak-${stamp}`;
|
||||
await copyFile(FILE, backup);
|
||||
for (const { rec, to } of planned) doc.submissions[rec.id].payload.pairing.version = to;
|
||||
// A temporary name no other writer can take; see build-public.ts. One write per
|
||||
// run, so the pid alone distinguishes it.
|
||||
const tmp = `${FILE}.${process.pid}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(doc, null, 2) + "\n");
|
||||
await rename(tmp, FILE);
|
||||
|
||||
console.log(`Backup written: ${backup}`);
|
||||
console.log(`Applied ${planned.length} change(s).`);
|
||||
console.log("Start the service again, then re-run audit-signed.mjs. The published\n" +
|
||||
"dojos.json picks the corrected payload up on the next updater cycle.");
|
||||
@@ -0,0 +1,25 @@
|
||||
// Launcher for the backend, which lives in index.ts.
|
||||
//
|
||||
// This file stays plain JavaScript on purpose, for three reasons:
|
||||
//
|
||||
// 1. It can be parsed by ANY Node version, so an operator on an older runtime
|
||||
// gets the message below instead of a syntax error from a .ts file they
|
||||
// cannot execute. The check must run before the import, hence the dynamic
|
||||
// import rather than a static one.
|
||||
// 2. systemd, `npm start` and the README all name index.mjs, so nothing about
|
||||
// deployment changes.
|
||||
// 3. self-update.mjs sanity-checks that an update archive contains
|
||||
// server/index.mjs before accepting it. Renaming this file outright would
|
||||
// make every legitimate update look malformed.
|
||||
const major = Number(process.versions.node.split(".")[0]);
|
||||
if (Number.isNaN(major) || major < 24) {
|
||||
console.error(
|
||||
`The Dojo Bay backend needs Node 24 or newer (found ${process.versions.node}).\n` +
|
||||
"It runs TypeScript directly, which relies on type stripping added in Node 24,\n" +
|
||||
"and its BIP47 libraries require it too. Upgrade Node, then restart the service.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mod = await import("./index.ts");
|
||||
export const server = mod.server;
|
||||
export const routes = mod.routes;
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+260
@@ -0,0 +1,260 @@
|
||||
{
|
||||
"name": "dojobay-server",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dojobay-server",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@bitcoinerlab/secp256k1": "1.2.0",
|
||||
"@dojo-tools/auth47": "2.0.0",
|
||||
"@dojo-tools/bip47": "2.0.0",
|
||||
"@dojo-tools/bitcoinjs-message": "4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bip39": "3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@bitcoinerlab/secp256k1": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.2.0.tgz",
|
||||
"integrity": "sha512-jeujZSzb3JOZfmJYI0ph1PVpCRV5oaexCgy+RvCXV8XlY+XFB/2n3WOcvBsKLsOw78KYgnQrQWb2HrKE4be88Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "^1.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dojo-tools/auth47": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dojo-tools/auth47/-/auth47-2.0.0.tgz",
|
||||
"integrity": "sha512-uoSG3MGy0TLanBftq49v9MdcFgy4xzCVU2iUUWEpQreFvfeVuxQbaTMGi5g7R0UafLiVVMIFf1rcF6JvvkxHbQ==",
|
||||
"license": "LGPL-3.0",
|
||||
"dependencies": {
|
||||
"@dojo-tools/bip47": "2.0.0",
|
||||
"@dojo-tools/bitcoinjs-message": "4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@dojo-tools/bip47": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dojo-tools/bip47/-/bip47-2.0.0.tgz",
|
||||
"integrity": "sha512-0B5nlP/71ArOY8yPOjuJacPPiHPeyZOlI8618VJcfiibZQGugEFV3HIG5Doxo6sSSwU5NrX/96OF6KG4pmzsOA==",
|
||||
"license": "LGPL-3.0",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.2.0",
|
||||
"@scure/base": "^2.0.0",
|
||||
"bip32": "5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@dojo-tools/bitcoinjs-message": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dojo-tools/bitcoinjs-message/-/bitcoinjs-message-4.0.0.tgz",
|
||||
"integrity": "sha512-bVK5tsrORzZ3aMgI5kn9YcfAqJzdiObFL/3Pxg8+ouTsXdyDOrBoITpCniemLxMUCanXUWou9LWg7Th5PzFLOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.2.0",
|
||||
"@scure/base": "^2.0.0",
|
||||
"varuint-bitcoin": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "1.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
|
||||
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves/node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/base-x": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
|
||||
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bip32": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/bip32/-/bip32-5.0.1.tgz",
|
||||
"integrity": "sha512-PWlHIAgYCfVhwqNpZyeakHXuLAGyN6rEQZnhxHxKI3BoFJRVWLl26455fhRlHsmbYcV986HqtPnt33Edu5sTCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.2.0",
|
||||
"@scure/base": "^1.1.1",
|
||||
"uint8array-tools": "^0.0.8",
|
||||
"valibot": "^1.2.0",
|
||||
"wif": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bip32/node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/bip32/node_modules/@scure/base": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
|
||||
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/bip39": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bip39/-/bip39-3.1.0.tgz",
|
||||
"integrity": "sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bip39/node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/bs58": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
|
||||
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base-x": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bs58check": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bs58check/-/bs58check-4.0.0.tgz",
|
||||
"integrity": "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.2.0",
|
||||
"bs58": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bs58check/node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/uint8array-tools": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz",
|
||||
"integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/valibot": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz",
|
||||
"integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/varuint-bitcoin": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-2.0.0.tgz",
|
||||
"integrity": "sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uint8array-tools": "^0.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/wif": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wif/-/wif-5.0.0.tgz",
|
||||
"integrity": "sha512-iFzrC/9ne740qFbNjTZ2FciSRJlHIXoxqk/Y5EnE08QOXu1WjJyCCswwDTYbohAOEnlCtLaAAQBhyaLRFh2hMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bs58check": "^4.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "dojobay-server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Auth47-gated self-service submission API for The Dojo Bay.",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node index.mjs",
|
||||
"build-public": "node build-public.mjs",
|
||||
"test": "node selftest.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dojo-tools/auth47": "2.0.0",
|
||||
"@dojo-tools/bip47": "2.0.0",
|
||||
"@dojo-tools/bitcoinjs-message": "4.0.0",
|
||||
"@bitcoinerlab/secp256k1": "1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bip39": "3.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// PayNym.rs lookup. paynym.rs runs the same API the historical Samourai server
|
||||
// exposed, and offers both a clearnet host and a Tor onion. We prefer the onion
|
||||
// (the box already has a SOCKS proxy for the connection gate, and it keeps the
|
||||
// lookup inside Tor), falling back to clearnet.
|
||||
//
|
||||
// The call is POST {base}/api/v1/nym body {"nym": "<payment code>"} and the
|
||||
// response carries the registered nym label. This resolution is ALWAYS
|
||||
// best-effort: any failure returns null and callers must carry on, because a
|
||||
// paynym.rs outage must never block a submission or an approval.
|
||||
import { socks5Connect, PROBE_CFG } from "./probe.mjs";
|
||||
|
||||
// Override via env if the onion address changes.
|
||||
const PAYNYM_ONION = process.env.PAYNYM_ONION
|
||||
|| "http://paynym25chftmsywv4v2r67agbrr62lcxagsf4tymbzpeeucucy2ivad.onion";
|
||||
const PAYNYM_CLEARNET = process.env.PAYNYM_CLEARNET || "https://paynym.rs";
|
||||
|
||||
// Pull the human label out of whatever shape the API returns. The legacy API
|
||||
// nests it under codes[].claimed / nymName; we probe a few known keys so a
|
||||
// minor schema change degrades to "not found" rather than a wrong value.
|
||||
function extractNym(obj) {
|
||||
if (!obj || typeof obj !== "object") return null;
|
||||
const direct = obj.nymName || obj.nym_name || obj.nym;
|
||||
if (typeof direct === "string" && direct.length) return direct;
|
||||
if (Array.isArray(obj.codes) && obj.codes[0] && typeof obj.codes[0].claimed === "string") return obj.codes[0].claimed;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Minimal HTTP POST over a SOCKS5 stream (onion), reading the JSON body.
|
||||
function postOverTor(onionUrl, path, jsonBody, timeoutMs) {
|
||||
return new Promise(async (resolve) => {
|
||||
let socket;
|
||||
try {
|
||||
const u = new URL(onionUrl);
|
||||
socket = await socks5Connect(PROBE_CFG.proxyHost, PROBE_CFG.proxyPort, u.hostname, +(u.port || 80), timeoutMs);
|
||||
} catch { return resolve(null); }
|
||||
const body = Buffer.from(JSON.stringify(jsonBody), "utf8");
|
||||
const host = new URL(onionUrl).hostname;
|
||||
const req =
|
||||
`POST ${path} HTTP/1.0\r\nHost: ${host}\r\nContent-Type: application/json\r\n` +
|
||||
`Content-Length: ${body.length}\r\nConnection: close\r\n\r\n`;
|
||||
let buf = "";
|
||||
const done = (v) => { try { socket.destroy(); } catch {} resolve(v); };
|
||||
const timer = setTimeout(() => done(null), timeoutMs);
|
||||
socket.on("data", (d) => { buf += d.toString("utf8"); });
|
||||
socket.on("close", () => {
|
||||
clearTimeout(timer);
|
||||
const i = buf.indexOf("\r\n\r\n");
|
||||
if (i < 0) return resolve(null);
|
||||
try { resolve(JSON.parse(buf.slice(i + 4))); } catch { resolve(null); }
|
||||
});
|
||||
socket.on("error", () => done(null));
|
||||
socket.write(req + body.toString("utf8"));
|
||||
});
|
||||
}
|
||||
|
||||
async function postClearnet(base, paymentCode, timeoutMs) {
|
||||
if (typeof fetch !== "function") return null;
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
const r = await fetch(base + "/api/v1/nym", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ nym: paymentCode }),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
clearTimeout(t);
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Fetch the raw nym document (codes[], nymName, ...) for a handle or payment
|
||||
// code, Tor first. Returns the parsed object or null. Never throws.
|
||||
export async function fetchNymInfo(nymOrCode, { timeoutMs = 20000, preferTor = true } = {}) {
|
||||
if (!nymOrCode) return null;
|
||||
let obj = null;
|
||||
if (preferTor) obj = await postOverTor(PAYNYM_ONION, "/api/v1/nym", { nym: nymOrCode }, timeoutMs);
|
||||
if (!obj) obj = await postClearnet(PAYNYM_CLEARNET, nymOrCode, timeoutMs);
|
||||
return obj && typeof obj === "object" ? obj : null;
|
||||
}
|
||||
|
||||
// Every BIP47 code variant registered for a PayNym (segwit + legacy), because
|
||||
// the wallet may sign Auth47 with either. [] when unresolvable.
|
||||
export async function fetchNymCodes(nymOrCode, opts) {
|
||||
const info = await fetchNymInfo(nymOrCode, opts);
|
||||
return Array.isArray(info?.codes) ? info.codes.filter((c) => c && typeof c.code === "string") : [];
|
||||
}
|
||||
|
||||
// Resolve a payment code to its registered PayNym label, or null. Never throws.
|
||||
export async function resolvePayNym(paymentCode, opts) {
|
||||
const name = extractNym(await fetchNymInfo(paymentCode, opts));
|
||||
if (!name) return null;
|
||||
return name.startsWith("+") ? name : "+" + name;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// On-demand Tor reachability check, reusing the exact probe from the updater so
|
||||
// the self-service "connection gate" and the cron checker agree.
|
||||
export { probe, socks5Connect } from "../scripts/update.mjs";
|
||||
|
||||
export const PROBE_CFG = {
|
||||
proxyHost: process.env.TOR_SOCKS_HOST || "127.0.0.1",
|
||||
proxyPort: +(process.env.TOR_SOCKS_PORT || 9050),
|
||||
timeoutMs: +(process.env.TIMEOUT_MS || 30000),
|
||||
connectOnly: process.env.CONNECT_ONLY === "1",
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — remove a listing and its reliability history.
|
||||
//
|
||||
// Deleting a record through the API leaves its history behind: unlisting stamps
|
||||
// it `retired` and keeps it for HISTORY_GRACE_DAYS so that a node relisted
|
||||
// within the window resurrects its uptime intact. That is right for a node
|
||||
// coming back, and wrong for one being removed deliberately. This removes both,
|
||||
// now.
|
||||
//
|
||||
// Usage, on the box:
|
||||
// cd /var/www/dojobay/server
|
||||
// node remove-listing.ts <record-id> # dry run
|
||||
// sudo systemctl stop dojobay-server.service
|
||||
// node remove-listing.ts --apply <record-id>
|
||||
// sudo systemctl start dojobay-server.service
|
||||
// node build-public.mjs
|
||||
//
|
||||
// As with the other write tools, --apply refuses to run while the service is
|
||||
// up, because store.ts holds the store in memory as a single writer and would
|
||||
// overwrite the edit. Both files are backed up first.
|
||||
// =============================================================================
|
||||
import { readFile, writeFile, rename, copyFile } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { StoreRecord } from "../types.js";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const APPLY = argv.includes("--apply");
|
||||
const FORCE = argv.includes("--force");
|
||||
const IDS = argv.filter((a) => !a.startsWith("--"));
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const STORE_DIR = process.env.SERVER_DATA_DIR || path.join(HERE, "data");
|
||||
const PUBLIC_DIR = process.env.PUBLIC_DATA_DIR || path.join(HERE, "..", "data");
|
||||
const STORE = path.join(STORE_DIR, "store.json");
|
||||
const HISTORY = ["history.json", "history-daily.json"].map((f) => path.join(PUBLIC_DIR, f));
|
||||
|
||||
if (!IDS.length) {
|
||||
console.error("Usage: node remove-listing.ts [--apply] <record-id>…\n" +
|
||||
"Record ids are shown by audit-signed.mjs, for example mainnet-kilombino.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (APPLY && !FORCE) {
|
||||
let active = "";
|
||||
try { active = execFileSync("systemctl", ["is-active", "dojobay-server.service"], { encoding: "utf8" }).trim(); }
|
||||
catch (e: any) { active = (e.stdout || "").trim(); }
|
||||
if (active === "active") {
|
||||
console.error("REFUSING: dojobay-server.service is running.\n" +
|
||||
"The store is held in memory by the server and would overwrite this edit.\n" +
|
||||
" sudo systemctl stop dojobay-server.service\n" +
|
||||
" node remove-listing.ts --apply <record-id>\n" +
|
||||
" sudo systemctl start dojobay-server.service");
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const readJSON = async (p: string, fallback: any) => {
|
||||
try { return JSON.parse(await readFile(p, "utf8")); } catch { return fallback; }
|
||||
};
|
||||
// A temporary name no other writer can take; see build-public.ts. The counter
|
||||
// matters as well as the pid, because one run rewrites both the store and the
|
||||
// seed in quick succession.
|
||||
let tmpSeq = 0;
|
||||
const writeAtomic = async (p: string, doc: any) => {
|
||||
const tmp = `${p}.${process.pid}.${(tmpSeq = (tmpSeq + 1) % 1e6)}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(doc, null, 2) + "\n");
|
||||
await rename(tmp, p);
|
||||
};
|
||||
|
||||
const store = await readJSON(STORE, { submissions: {} });
|
||||
const found: StoreRecord[] = [];
|
||||
const missing: string[] = [];
|
||||
for (const id of IDS) {
|
||||
const rec = store.submissions?.[id];
|
||||
if (rec) found.push(rec); else missing.push(id);
|
||||
}
|
||||
|
||||
console.log(`Store: ${STORE}`);
|
||||
console.log(`History: ${PUBLIC_DIR}\n`);
|
||||
|
||||
for (const rec of found) {
|
||||
const codes = (rec.paymentCodes || []).length;
|
||||
console.log(` ${rec.id} (${rec.status})`);
|
||||
console.log(` name ${rec.name || "(none)"}`);
|
||||
console.log(` onion ${rec.payload?.pairing?.url || "(none)"}`);
|
||||
console.log(` codes ${codes || "NONE — this listing has no owner"}`);
|
||||
}
|
||||
for (const id of missing) console.log(` ${id}: not in the store`);
|
||||
console.log("");
|
||||
|
||||
let histCounts: Record<string, number> = {};
|
||||
for (const f of HISTORY) {
|
||||
const doc = await readJSON(f, { nodes: {} });
|
||||
histCounts[path.basename(f)] = IDS.filter((id) => doc.nodes && doc.nodes[id]).length;
|
||||
}
|
||||
console.log("History entries to remove: " +
|
||||
Object.entries(histCounts).map(([f, n]) => `${f}: ${n}`).join(", ") + "\n");
|
||||
|
||||
if (!found.length && !Object.values(histCounts).some(Boolean)) {
|
||||
console.log("Nothing to remove."); process.exit(missing.length ? 1 : 0);
|
||||
}
|
||||
|
||||
if (!APPLY) {
|
||||
console.log("DRY RUN — nothing written. Re-run with --apply (service stopped) to remove.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
await copyFile(STORE, `${STORE}.bak-${stamp}`);
|
||||
for (const rec of found) delete store.submissions[rec.id];
|
||||
await writeAtomic(STORE, store);
|
||||
console.log(`Backup written: ${STORE}.bak-${stamp}`);
|
||||
|
||||
for (const f of HISTORY) {
|
||||
const doc = await readJSON(f, null);
|
||||
if (!doc || !doc.nodes) continue;
|
||||
let touched = false;
|
||||
for (const id of IDS) if (doc.nodes[id]) { delete doc.nodes[id]; touched = true; }
|
||||
if (!touched) continue;
|
||||
await copyFile(f, `${f}.bak-${stamp}`);
|
||||
await writeAtomic(f, doc);
|
||||
console.log(`Purged history from ${path.basename(f)} (backup alongside).`);
|
||||
}
|
||||
|
||||
console.log(`\nRemoved ${found.length} listing(s). Start the service, then run\n` +
|
||||
"build-public.mjs to republish, and audit-signed.mjs to confirm the result.");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,224 @@
|
||||
// Tiny JSON-file store for the backend. Single-writer (one server process),
|
||||
// atomic writes, no external database. Holds submissions, live sessions and
|
||||
// outstanding Auth47 nonces.
|
||||
import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { StoreRecord, DomainClaim } from "../types.js";
|
||||
|
||||
/** A short-lived, single-use Auth47 nonce. */
|
||||
export interface Nonce { expires: number; [k: string]: unknown }
|
||||
/** A signed-in operator's session, keyed by a random cookie id. */
|
||||
export interface Session { paymentCode: string; expires: number; [k: string]: unknown }
|
||||
|
||||
interface StoreShape {
|
||||
submissions: Record<string, StoreRecord>;
|
||||
sessions: Record<string, Session>;
|
||||
nonces: Record<string, Nonce>;
|
||||
domains: Record<string, DomainClaim>;
|
||||
}
|
||||
|
||||
const DIR = process.env.SERVER_DATA_DIR
|
||||
|| path.resolve(path.dirname(fileURLToPath(import.meta.url)), "data");
|
||||
const FILE = path.join(DIR, "store.json");
|
||||
|
||||
const EMPTY: StoreShape = { submissions: {}, sessions: {}, nonces: {}, domains: {} };
|
||||
let cache: StoreShape | null = null;
|
||||
|
||||
// Whether a record carries a signed pairing block at all. A shape check, not a
|
||||
// verification: the submit gate decided whether the block verifies against the
|
||||
// operator's own payment code, and re-deriving that at every read would mean
|
||||
// the store and the rebuild silently dropping listings over a cryptographic
|
||||
// judgement made elsewhere. This asks only what a caller is entitled to ask
|
||||
// here, which is whether there is anything for a visitor to check.
|
||||
// server/audit-signed.mjs is the tool that re-runs the real verification over
|
||||
// the whole store. It lives in this file rather than beside the verifier so
|
||||
// that store.ts stays on node builtins alone: remove-listing.ts and the
|
||||
// migration scripts import the store, and should not have to pull in secp256k1
|
||||
// to ask a question about a string.
|
||||
export function hasSignedBlock(rec: { signed?: string | null } | null | undefined): boolean {
|
||||
const signed = typeof rec?.signed === "string" ? rec.signed.trim() : "";
|
||||
return signed.includes("BEGIN BITCOIN SIGNED MESSAGE") && signed.includes("BEGIN BITCOIN SIGNATURE");
|
||||
}
|
||||
|
||||
// A submission's ownership is a paymentCodes ARRAY, because one PayNym often
|
||||
// carries two BIP47 codes (segwit and legacy variants) and the wallet may sign
|
||||
// Auth47 with either. Records written before this schema carried a scalar
|
||||
// paymentCode; normalise those on read so old store files keep working.
|
||||
function normaliseSubmission<T>(rec: T): T {
|
||||
if (!rec || typeof rec !== "object") return rec;
|
||||
const r = rec as { paymentCodes?: unknown; paymentCode?: string };
|
||||
if (!Array.isArray(r.paymentCodes)) {
|
||||
r.paymentCodes = r.paymentCode ? [r.paymentCode] : [];
|
||||
}
|
||||
r.paymentCodes = [...new Set((r.paymentCodes as unknown[]).filter((c): c is string => typeof c === "string" && !!c))];
|
||||
delete r.paymentCode;
|
||||
return rec;
|
||||
}
|
||||
|
||||
async function load(): Promise<StoreShape> {
|
||||
if (cache) return cache;
|
||||
await mkdir(DIR, { recursive: true });
|
||||
try {
|
||||
cache = { ...EMPTY, ...JSON.parse(await readFile(FILE, "utf8")) };
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
|
||||
cache = structuredClone(EMPTY);
|
||||
}
|
||||
for (const rec of Object.values(cache.submissions)) normaliseSubmission(rec);
|
||||
return cache;
|
||||
}
|
||||
|
||||
// A temporary name no other writer can take; see build-public.ts. The store has
|
||||
// a single writer by design, but the backend and a maintenance script can both
|
||||
// be pointed at it, and that is precisely when a shared temporary name bites.
|
||||
let tmpSeq = 0;
|
||||
async function persist() {
|
||||
const tmp = `${FILE}.${process.pid}.${(tmpSeq = (tmpSeq + 1) % 1e6)}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(cache, null, 2) + "\n");
|
||||
await rename(tmp, FILE);
|
||||
}
|
||||
|
||||
export const store = {
|
||||
async get() { return load(); },
|
||||
async save() { await persist(); },
|
||||
|
||||
// --- nonces (single-use, short lived) ---
|
||||
async putNonce(nonce: string, data: Nonce) { (await load()).nonces[nonce] = data; await persist(); },
|
||||
async takeNonce(nonce: string): Promise<Nonce | null> {
|
||||
const s = await load();
|
||||
const n = s.nonces[nonce];
|
||||
if (n) { delete s.nonces[nonce]; await persist(); }
|
||||
return n || null;
|
||||
},
|
||||
async gcNonces(now: number = Date.now()) {
|
||||
const s = await load();
|
||||
let changed = false;
|
||||
for (const [k, v] of Object.entries(s.nonces)) {
|
||||
if (!v || v.expires < now) { delete s.nonces[k]; changed = true; }
|
||||
}
|
||||
if (changed) await persist();
|
||||
},
|
||||
|
||||
// --- sessions ---
|
||||
async putSession(data: Session): Promise<string> {
|
||||
const s = await load();
|
||||
const id = randomBytes(32).toString("hex");
|
||||
s.sessions[id] = data;
|
||||
await persist();
|
||||
return id;
|
||||
},
|
||||
async getSession(id: string | null | undefined): Promise<Session | null> {
|
||||
if (!id) return null;
|
||||
const s = await load();
|
||||
const sess = s.sessions[id];
|
||||
if (!sess) return null;
|
||||
if (sess.expires < Date.now()) { delete s.sessions[id]; await persist(); return null; }
|
||||
return sess;
|
||||
},
|
||||
async dropSession(id: string) {
|
||||
const s = await load();
|
||||
if (s.sessions[id]) { delete s.sessions[id]; await persist(); }
|
||||
},
|
||||
|
||||
// --- submissions (keyed by network + name slug; owned by paymentCodes[]) ---
|
||||
async listSubmissions(): Promise<StoreRecord[]> { return Object.values((await load()).submissions); },
|
||||
async submissionsFor(paymentCode: string): Promise<StoreRecord[]> {
|
||||
return Object.values((await load()).submissions)
|
||||
.filter((r) => Array.isArray(r.paymentCodes) && r.paymentCodes.includes(paymentCode));
|
||||
},
|
||||
// Every record must carry at least one BIP47 payment code and a signed
|
||||
// pairing block. This is the single chokepoint through which every write to
|
||||
// the store passes, so enforcing both here is what makes an unowned or
|
||||
// unattested listing structurally impossible rather than merely discouraged:
|
||||
// the payment code is the identity the directory rests on, and the signature
|
||||
// is what lets a visitor check the pairing details against that identity
|
||||
// without trusting this site at all. A listing without one cannot be owned,
|
||||
// edited, verified or recognised by a visitor; a listing without the other
|
||||
// asks the visitor to take our word for an onion address and an API key,
|
||||
// which is the one thing this directory exists not to require. Historically a
|
||||
// few pre-Auth47 records existed without a code, and rather more predate the
|
||||
// signature gate; both doors are now closed.
|
||||
async putSubmission(rec: StoreRecord): Promise<StoreRecord> {
|
||||
const normalised = normaliseSubmission(rec);
|
||||
const codes = (normalised as StoreRecord).paymentCodes;
|
||||
// An emptiness guard, deliberately, not a validator: whether a code is a
|
||||
// real BIP47 payment code is settled at the gates that admit it — an Auth47
|
||||
// session proves possession, and the signature checks derive its
|
||||
// notification address. What must be impossible HERE is a listing with no
|
||||
// owner at all.
|
||||
if (!Array.isArray(codes) || !codes.some((c) => typeof c === "string" && /^PM\w{6,}/.test(c.trim()))) {
|
||||
throw new Error(`refusing to store ${rec?.id}: a listing must carry a BIP47 payment code`);
|
||||
}
|
||||
// The same kind of guard for the signature: a shape check, not a
|
||||
// verification. Whether the block verifies against the record's own code is
|
||||
// settled at the submit and pairing-edit gates, which have the session and
|
||||
// the canonical message to hand and can say precisely what is wrong. What
|
||||
// must be impossible HERE is a record whose pairing details nobody has
|
||||
// attested to, however it was assembled — by an admin action, an import, a
|
||||
// migration or a future endpoint that has not been written yet.
|
||||
if (!hasSignedBlock(normalised)) {
|
||||
throw new Error(`refusing to store ${rec?.id}: a listing must carry a signed pairing block. ` +
|
||||
`Ask the operator to sign their pairing payload, or remove the listing with server/remove-listing.ts.`);
|
||||
}
|
||||
const s = await load();
|
||||
s.submissions[rec.id] = normalised;
|
||||
await persist();
|
||||
return rec;
|
||||
},
|
||||
async getSubmission(id: string): Promise<StoreRecord | null> {
|
||||
const rec = (await load()).submissions[id] || null;
|
||||
return rec ? normaliseSubmission(rec) : null;
|
||||
},
|
||||
// Retention: a rejected submission is kept briefly so a maintainer can reverse
|
||||
// a mistake, then deleted. Nothing else ever removed one, so the store
|
||||
// accumulated the payment code, pairing payload and signature of every
|
||||
// operator ever turned down — including the apikey, which is a live
|
||||
// credential to their Dojo, not merely metadata. Returns the ids removed.
|
||||
async pruneRejected(days: number, now: number = Date.now()): Promise<string[]> {
|
||||
const s = await load();
|
||||
const cutoff = now - days * 86400 * 1000;
|
||||
const gone: string[] = [];
|
||||
for (const [id, rec] of Object.entries(s.submissions)) {
|
||||
if (rec?.status !== "rejected") continue;
|
||||
const stamp = Date.parse(rec.updated_at || rec.created_at || "");
|
||||
// A record with no usable timestamp is pruned rather than kept forever.
|
||||
if (Number.isFinite(stamp) && stamp > cutoff) continue;
|
||||
delete s.submissions[id];
|
||||
gone.push(id);
|
||||
}
|
||||
if (gone.length) await persist();
|
||||
return gone;
|
||||
},
|
||||
|
||||
async deleteSubmission(id: string) {
|
||||
const s = await load();
|
||||
if (s.submissions[id]) { delete s.submissions[id]; await persist(); }
|
||||
},
|
||||
|
||||
// --- verified operator domains (keyed by payment code) ---------------------
|
||||
// One claim per code. A record is kept even after it stops verifying, so
|
||||
// restoring the TXT record restores the badge without a fresh signature.
|
||||
async listDomains(): Promise<DomainClaim[]> { return Object.values((await load()).domains || {}); },
|
||||
async getDomain(paymentCode: string): Promise<DomainClaim | null> { return ((await load()).domains || {})[paymentCode] || null; },
|
||||
async putDomain(claim: DomainClaim): Promise<DomainClaim> {
|
||||
const s = await load();
|
||||
s.domains = s.domains || {};
|
||||
s.domains[claim.paymentCode] = claim;
|
||||
await persist();
|
||||
return claim;
|
||||
},
|
||||
async deleteDomain(paymentCode: string) {
|
||||
const s = await load();
|
||||
if (s.domains && s.domains[paymentCode]) { delete s.domains[paymentCode]; await persist(); }
|
||||
},
|
||||
// Every verified domain, as a payment code -> domain map, for the rebuild.
|
||||
async verifiedDomainMap(): Promise<Map<string, string>> {
|
||||
const out = new Map<string, string>();
|
||||
for (const c of Object.values((await load()).domains || {})) {
|
||||
if (c && c.verified && c.domain) out.set(c.paymentCode, c.domain);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
// How far behind is this instance? Compares the local data/version.json
|
||||
// commit against the GitHub repository, over Tor (TLS through the same SOCKS
|
||||
// tunnel the probes use), and reports commits behind plus releases published
|
||||
// since this instance was built. Consumed by GET /api/admin/updates for the
|
||||
// admin console's update line. Everything degrades gracefully: if GitHub is
|
||||
// unreachable over Tor, the endpoint says so rather than failing the panel.
|
||||
//
|
||||
// "Releases behind" resolves release tags to commits and compares identity, so
|
||||
// an instance running the exact commit of the newest release reports zero. The
|
||||
// earlier version counted releases published after the local build timestamp,
|
||||
// which always reported one behind: a tag is created after the commit it points
|
||||
// at has been built and deployed. When the running commit is not itself a
|
||||
// released tag, it falls back to that timestamp guess and flags it as such.
|
||||
import tls from "node:tls";
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { socks5Connect } from "../scripts/update.mjs";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const GITHUB_REPO = process.env.GITHUB_REPO || "Dojobay/dojobay";
|
||||
const API_HOST = "api.github.com";
|
||||
|
||||
// The request line and headers, separated out so the Accept value is testable
|
||||
// without a network or a TLS mock. It is not a detail: a download used to ask
|
||||
// for `application/octet-stream`, and GitHub's archive route answers 415
|
||||
// Unsupported Media Type to that, so self-update never got past its first
|
||||
// request and no operator ever saw it work. Verified against the live endpoint:
|
||||
// octet-stream returns 415, while both `application/vnd.github+json` and `*/*`
|
||||
// return the 302 to codeload that this transport already follows.
|
||||
//
|
||||
// `*/*` rather than the JSON type, because a download genuinely will take
|
||||
// whatever the route serves and saying so is true; asking for JSON to obtain a
|
||||
// zip works only by convention and would be the next thing to break quietly.
|
||||
// The metadata calls keep the JSON type, which is what those routes serve and
|
||||
// what pins the API version.
|
||||
export function githubRequestHead(apiPath, host, { binary = false } = {}) {
|
||||
return `GET ${apiPath} HTTP/1.1\r\nHost: ${host}\r\nUser-Agent: dojobay-update-check\r\n` +
|
||||
`Accept: ${binary ? "*/*" : "application/vnd.github+json"}\r\n` +
|
||||
`Accept-Encoding: identity\r\nConnection: close\r\n\r\n`;
|
||||
}
|
||||
|
||||
// One HTTPS GET over the Tor SOCKS proxy. Handles chunked replies, returns
|
||||
// both a text `body` and a raw `bodyBuf`, and follows GitHub's redirect from
|
||||
// api.github.com to codeload for zipball downloads (binary: true) up to a few
|
||||
// hops. Host is derived per hop so codeload.github.com is reached correctly.
|
||||
/**
|
||||
* @param {string} apiPath
|
||||
* @param {{ proxyHost?: string, proxyPort?: number, timeoutMs?: number,
|
||||
* binary?: boolean, _host?: string, _hops?: number }} [opts]
|
||||
*/
|
||||
export async function githubGet(apiPath, { proxyHost, proxyPort, timeoutMs = 30000, binary = false, _host = API_HOST, _hops = 0 } = {}) {
|
||||
const raw = await socks5Connect(proxyHost, proxyPort, _host, 443, timeoutMs);
|
||||
const res = await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { socket.destroy(); reject(new Error("timeout")); }, timeoutMs);
|
||||
const socket = tls.connect({ socket: raw, servername: _host }, () => {
|
||||
socket.write(githubRequestHead(apiPath, _host, { binary }));
|
||||
});
|
||||
const chunks = [];
|
||||
socket.on("data", (d) => chunks.push(d));
|
||||
socket.on("error", (e) => { clearTimeout(timer); reject(e); });
|
||||
socket.on("close", () => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
const all = Buffer.concat(chunks);
|
||||
const headEnd = all.indexOf("\r\n\r\n");
|
||||
if (headEnd < 0) return reject(new Error("malformed reply"));
|
||||
const headText = all.subarray(0, headEnd).toString("latin1");
|
||||
const m = headText.match(/^HTTP\/1\.[01] (\d{3})/);
|
||||
if (!m) return reject(new Error("malformed reply"));
|
||||
const status = +m[1];
|
||||
const locM = headText.match(/\r\nlocation:\s*([^\r\n]+)/i);
|
||||
let bodyBuf = all.subarray(headEnd + 4);
|
||||
if (/transfer-encoding:\s*chunked/i.test(headText)) {
|
||||
const parts = []; let p = 0;
|
||||
for (;;) {
|
||||
const nl = bodyBuf.indexOf("\r\n", p);
|
||||
if (nl < 0) break;
|
||||
const size = parseInt(bodyBuf.subarray(p, nl).toString("latin1"), 16);
|
||||
if (!size) break;
|
||||
parts.push(bodyBuf.subarray(nl + 2, nl + 2 + size));
|
||||
p = nl + 2 + size + 2;
|
||||
}
|
||||
bodyBuf = Buffer.concat(parts);
|
||||
}
|
||||
resolve({ status, location: locM ? locM[1].trim() : null, bodyBuf });
|
||||
} catch (e) { reject(e); }
|
||||
});
|
||||
});
|
||||
if ([301, 302, 307, 308].includes(res.status) && res.location && _hops < 4) {
|
||||
const u = new URL(res.location);
|
||||
return githubGet(u.pathname + u.search, { proxyHost, proxyPort, timeoutMs, binary, _host: u.hostname, _hops: _hops + 1 });
|
||||
}
|
||||
return { status: res.status, body: res.bodyBuf.toString("utf8"), bodyBuf: res.bodyBuf };
|
||||
}
|
||||
|
||||
/** What to tell an operator when GitHub refuses a request.
|
||||
*
|
||||
* A bare "HTTP 403" reads as something broken here, and it is not: GitHub
|
||||
* allows sixty unauthenticated requests an hour PER IP ADDRESS, and a Tor exit
|
||||
* is one address shared with everyone else using it, so an instance can arrive
|
||||
* at an exit whose hour was already spent by strangers. Observed on a live
|
||||
* instance: limit 60, remaining 0, used 60, for an exit nobody here had made a
|
||||
* single request through.
|
||||
*
|
||||
* It clears by itself when the window rolls over, and often sooner on a new
|
||||
* circuit, since the limit follows the exit rather than the client. Saying so
|
||||
* is the difference between an operator waiting and an operator going looking
|
||||
* for a fault that does not exist. 429 is included because GitHub uses it for
|
||||
* secondary limits and means the same thing to a reader.
|
||||
*/
|
||||
export function githubRefusal(what, status) {
|
||||
if (status === 403 || status === 429) {
|
||||
// No call-site prefix. Which of the three requests hit the limit is of no
|
||||
// use to an operator, and "compare: GitHub is rate-limiting..." reads as
|
||||
// though "compare" were a thing that had gone wrong.
|
||||
return `GitHub is rate-limiting this Tor exit (HTTP ${status}). `
|
||||
+ "The limit is per exit address and shared with every other user of it, so it clears on its "
|
||||
+ "own within the hour, usually sooner on a new circuit. Updating from a peer .onion does not "
|
||||
+ "touch GitHub and works meanwhile.";
|
||||
}
|
||||
return `${what}: HTTP ${status}`;
|
||||
}
|
||||
|
||||
export async function checkUpdates({ repo = GITHUB_REPO, transport = githubGet, cfg = {} } = {}) {
|
||||
const verPath = path.join(process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"), "version.json");
|
||||
const version = JSON.parse(await readFile(verPath, "utf8"));
|
||||
if (!version.commit || version.commit === "dev") throw new Error("local version.json has no deployed commit");
|
||||
|
||||
const cmp = await transport(`/repos/${repo}/compare/${encodeURIComponent(version.commit)}...main`, cfg);
|
||||
if (cmp.status !== 200) throw new Error(githubRefusal("compare", cmp.status));
|
||||
const compare = JSON.parse(cmp.body);
|
||||
|
||||
const rel = await transport(`/repos/${repo}/releases?per_page=30`, cfg);
|
||||
if (rel.status !== 200) throw new Error(githubRefusal("releases", rel.status));
|
||||
const releases = JSON.parse(rel.body);
|
||||
|
||||
// Which release are we actually running?
|
||||
//
|
||||
// This used to count releases published after the local build timestamp,
|
||||
// which is wrong in the ordinary case: a tag is created AFTER the commit it
|
||||
// points at has been built and deployed, so an instance running the exact
|
||||
// commit of the newest release always reported itself one release behind.
|
||||
//
|
||||
// Resolve each release's tag to a commit instead and compare identity. If our
|
||||
// commit IS a released tag, the number of releases published after it is the
|
||||
// honest answer (zero, when we are on the latest). Only when no tag matches do
|
||||
// we fall back to the timestamp approximation, and say so.
|
||||
let tagSha = new Map();
|
||||
let tagsError = null;
|
||||
try {
|
||||
const tg = await transport(`/repos/${repo}/tags?per_page=100`, cfg);
|
||||
if (tg.status === 200) {
|
||||
for (const t of JSON.parse(tg.body)) {
|
||||
if (t?.name && t?.commit?.sha) tagSha.set(t.name, String(t.commit.sha));
|
||||
}
|
||||
} else {
|
||||
tagsError = githubRefusal("tag lookup", tg.status);
|
||||
}
|
||||
} catch (e) {
|
||||
tagsError = "tag lookup: " + (e?.message || "failed");
|
||||
}
|
||||
|
||||
// version.json carries a short commit, the API a full sha; match either way.
|
||||
const sameCommit = (a, b) => {
|
||||
if (!a || !b) return false;
|
||||
const x = String(a).toLowerCase(), y = String(b).toLowerCase();
|
||||
return x.startsWith(y) || y.startsWith(x);
|
||||
};
|
||||
|
||||
const runningIndex = releases.findIndex((r) => sameCommit(tagSha.get(r.tag_name), version.commit));
|
||||
const approximate = runningIndex < 0;
|
||||
const builtAt = Date.parse(version.built || 0) || 0;
|
||||
|
||||
// Three states, and the third used to be reported as the second.
|
||||
//
|
||||
// matched our commit IS a released tag: the count is exact.
|
||||
// no match we are on an untagged commit mid-cycle: the timestamp count
|
||||
// is a fair approximation, because we are genuinely not on a
|
||||
// release.
|
||||
// no tag data we could not look tags up at all, usually because a shared
|
||||
// Tor exit hit GitHub's rate limit. The timestamp count is
|
||||
// then WORSE than saying nothing: a tag is always created
|
||||
// after its commit was built, so an instance running the very
|
||||
// newest release scores one behind. Report null instead.
|
||||
const releasesBehind = !approximate ? runningIndex
|
||||
: tagsError ? null
|
||||
: releases.filter((r) => Date.parse(r.published_at || 0) > builtAt).length;
|
||||
|
||||
return {
|
||||
commit: version.commit,
|
||||
built: version.built || null,
|
||||
commits_behind: compare.ahead_by ?? 0, // main is ahead of us by this many
|
||||
status: compare.status || "unknown", // identical | behind | ahead | diverged
|
||||
latest_release: releases[0] ? releases[0].tag_name : null,
|
||||
/** The release we are running, when our commit is exactly a released tag. */
|
||||
current_release: approximate ? null : releases[runningIndex].tag_name,
|
||||
releases_behind: releasesBehind,
|
||||
/** True when releases_behind is the timestamp guess rather than an identity
|
||||
* match, which happens when the running commit is not itself a released
|
||||
* tag (mid-cycle, or a local build). */
|
||||
releases_behind_approx: approximate,
|
||||
/** Why the release could not be identified, when it could not. */
|
||||
releases_note: tagsError,
|
||||
repo,
|
||||
checked_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether an update check may be answered from the cache, and what to tell the
|
||||
* operator if a forced check was refused.
|
||||
*
|
||||
* Three rules, and the third is the only interesting one. An ordinary request
|
||||
* takes the cache while it is fresh, because six hours is right for an
|
||||
* unattended check over Tor where GitHub rate limits shared exit nodes. A
|
||||
* forced request goes out. A forced request inside the floor is answered from
|
||||
* the cache with the wait attached rather than refused, because the operator
|
||||
* asked what the state is and the honest answer is the last one known plus how
|
||||
* stale it is.
|
||||
*
|
||||
* Pure, and separate from the route, so the floor can be tested without a
|
||||
* reachable GitHub: the route only fills its cache on success, so an
|
||||
* unreachable GitHub means the cached path is never taken and the floor never
|
||||
* runs. A rule that cannot be exercised is a rule nobody has checked.
|
||||
*/
|
||||
export function updateCacheDecision({ cachedAt = null, now = Date.now(), forced = false,
|
||||
forcedAt = 0, ttlMs = 6 * 3600 * 1000, floorMs = 60 * 1000 } = {}) {
|
||||
const fresh = cachedAt !== null && now - cachedAt < ttlMs;
|
||||
if (!fresh) return { serveCached: false, waitS: 0 };
|
||||
if (!forced) return { serveCached: true, waitS: 0 };
|
||||
const since = now - forcedAt;
|
||||
if (forcedAt && since < floorMs) {
|
||||
return { serveCached: true, waitS: Math.ceil((floorMs - since) / 1000) };
|
||||
}
|
||||
return { serveCached: false, waitS: 0 };
|
||||
}
|
||||
Reference in New Issue
Block a user