Files
archy/docker/dojobay/scripts/bootstrap-import.mjs
T

318 lines
16 KiB
JavaScript

#!/usr/bin/env node
// Bootstrap a new Dojo Bay from a TRUSTED existing instance, so a fresh
// directory is mature the moment it starts: its nodes become approved store
// records here and their reliability histories carry over.
//
// node scripts/bootstrap-import.mjs --onion <56-char>.onion \
// --code PM8T... [--dry-run]
//
// Trust is verified before anything is imported: the remote instance's
// data/operator.json must bind that onion to exactly the payment code YOU
// typed in, under a valid wallet signature (server/crypto.ts). If the
// signature does not verify, or binds a different onion or code, nothing is
// fetched further. After that: dojos.json supplies the nodes, both history
// files supply the record, and each PayNym is resolved against paynym.rs
// (over Tor) for its full BIP47 code-variant set so imported operators can
// sign in here with either variant. Existing ids are never touched; history
// is only written for ids that have none.
import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { httpOverTor } from "./update.mjs";
import { store, hasSignedBlock } from "../server/store.ts";
import { verifySignedPayload, canonicalPairing } from "../server/crypto.ts";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const DATA_DIR = process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data");
const defaultCfg = () => ({
proxyHost: process.env.TOR_SOCKS_HOST || "127.0.0.1",
proxyPort: +(process.env.TOR_SOCKS_PORT || 9050),
});
// GET a JSON document from the remote instance over Tor.
async function torFetchJSON(onionHost, urlPath, cfg, timeoutMs = 30000) {
const req = `GET ${urlPath} HTTP/1.0\r\nHost: ${onionHost}\r\nUser-Agent: dojobay-bootstrap\r\nConnection: close\r\n\r\n`;
const res = await httpOverTor(cfg, onionHost, 80, req, timeoutMs);
if (res.status !== 200) throw new Error(`${urlPath}: HTTP ${res.status || "no response"}`);
return JSON.parse(res.body);
}
// A temporary name no other writer can take; see server/build-public.ts. The
// counter matters as well as the pid: one import writes the seed, both history
// files and the avatars in quick succession.
let tmpSeq = 0;
async function writeJSONAtomic(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);
}
// fetchers are injectable for the self-test: fetchDoc(urlPath) -> object,
// fetchCodes(paynymOrCode) -> [{code, segwit}, ...]
/**
* @param {{ onionHost?: string, trustedCode?: string, dryRun?: boolean, dataDir?: string,
* log?: (...a: any[]) => void, fetchDoc?: any, fetchCodes?: any,
* status?: "approved" | "pending" }} [opts]
*/
export async function bootstrapImport({
onionHost, trustedCode, dryRun = false, dataDir = DATA_DIR, log = console.error,
fetchDoc, fetchCodes, status = "approved",
} = {}) {
const cfg = defaultCfg();
fetchDoc = fetchDoc || ((p) => torFetchJSON(onionHost, p, cfg));
if (!fetchCodes) {
const { fetchNymCodes } = await import("../server/paynym.mjs");
fetchCodes = (nym) => fetchNymCodes(nym);
}
// 1) trust gate: the remote operator binding must verify for THIS onion and
// exactly the payment code the operator typed in.
const { verifyOperatorDoc } = await import("../server/crypto.ts");
const opDoc = await fetchDoc("/data/operator.json");
const v = verifyOperatorDoc(opDoc, { expectedOnion: `http://${onionHost}` });
if (!v.ok) throw new Error(`refusing to import: remote operator binding does not verify (${v.error})`);
if (opDoc.paymentCode !== trustedCode) {
throw new Error("refusing to import: the remote instance is operated by a DIFFERENT payment code than the one you trusted");
}
log(`trusted: ${onionHost} is signed by ${trustedCode.slice(0, 12)}… ✓`);
// 2) data
const dojos = await fetchDoc("/data/dojos.json");
const hist = await fetchDoc("/data/history.json").catch(() => ({ nodes: {} }));
const daily = await fetchDoc("/data/history-daily.json").catch(() => ({ nodes: {} }));
const nodes = (dojos.nodes || []).filter((n) => n.payload?.pairing?.url);
// The pairing URL identifies a physical Dojo; an id does not.
//
// An operator installing a new instance names their own node in the anchor,
// then bootstraps from a directory that already lists it. The two ids differ,
// because each instance derives one from the name it was given, so the same
// machine arrived twice: once as the anchor and once as an import, with its
// reliability history split between them. What is actually the same thing is
// the onion address in the signed pairing payload, which is why matching on
// it is not a heuristic. Two listings cannot share one, and an operator
// cannot claim somebody else's without the signature failing.
//
// Compared as a whole URL rather than by host alone, because one machine may
// legitimately serve mainnet at /v2 and testnet at /test/v2, and those are
// two listings. Lower-cased and stripped of a trailing slash, since neither
// changes which endpoint is meant.
const pairingKey = (n) => {
const u = n?.payload?.pairing?.url;
if (typeof u !== "string" || !u) return null;
return u.trim().toLowerCase().replace(/\/+$/, "");
};
// Everything this instance already lists, from the store AND from the seed
// anchor. The anchor is not a store record, which is exactly why it was
// invisible to this check and why the operator's own node was the one node
// guaranteed to duplicate.
const localByUrl = new Map();
for (const r of await store.listSubmissions()) {
const k = pairingKey(r);
if (k) localByUrl.set(k, r.id);
}
try {
const seed = JSON.parse(await readFile(path.join(dataDir, "seed.json"), "utf8"));
for (const n of seed.nodes || []) {
const k = pairingKey(n);
if (k && !localByUrl.has(k)) localByUrl.set(k, n.id);
}
} catch { /* no anchor yet, which is normal on a bare install */ }
// 3) plan records: skip existing ids; resolve full code sets per PayNym
const existingIds = new Set((await store.listSubmissions()).map((r) => r.id));
const plan = [];
const codeCache = new Map();
for (const n of nodes) {
if (existingIds.has(n.id)) { plan.push({ action: "skip", n }); continue; }
// Same machine under a different id. The record is not created, because a
// second listing for one Dojo is worse than a missing one, but the history
// is worth having: it is the same node's record of itself, and dropping it
// would restart an operator's reliability figures from nothing on a machine
// that has been up for months. Carried onto the id this instance uses.
const dupOf = localByUrl.get(pairingKey(n));
if (dupOf) { plan.push({ action: "merge", n, dupOf }); continue; }
// A published node from another instance carries its signed block in
// dojos.json, so an unsigned one either predates the rule there or was
// published by an instance that does not enforce it. Either way it cannot
// enter this store, and saying so in the plan is better than a throw from
// putSubmission half way through the import.
if (!hasSignedBlock(n)) { plan.push({ action: "refuse", n, why: "no signed pairing block" }); continue; }
// And the block must actually verify, here, against the payload it claims
// to cover.
//
// hasSignedBlock only looks for the two header lines, and putSubmission
// enforces nothing more, so until this check an imported listing's
// signature was taken on the source instance's word: a directory that was
// careless or compromised could publish a well-formed block that verifies
// against nothing, and every instance bootstrapping from it would list the
// node. This is the same standard the domain badges above are already held
// to, and for the same reason: one compromised directory must not be able
// to place listings across a federation.
//
// Offline and self-contained. canonicalPairing derives the message from the
// payload being imported, so a payload altered in transit no longer matches
// what was signed, and the addresses come from the payment code named
// inside the block itself rather than from anything the source asserts.
const sig = verifySignedPayload({
signedText: n.signed,
expectedMessage: canonicalPairing(n.payload),
network: n.network === "testnet" ? "testnet" : "bitcoin",
});
if (!sig.ok) { plan.push({ action: "refuse", n, why: `signature does not verify (${sig.error})` }); continue; }
let codes = n.paymentCode ? [n.paymentCode] : [];
if (n.paynym) {
if (!codeCache.has(n.paynym)) codeCache.set(n.paynym, await fetchCodes(n.paynym).catch(() => []));
const all = codeCache.get(n.paynym).map((c) => c.code);
if (all.length) codes = [...new Set([...all, ...codes])];
}
if (!codes.length) { plan.push({ action: "refuse", n, why: "no BIP47 payment code" }); continue; }
plan.push({ action: "import", n, codes });
}
const now = new Date().toISOString();
for (const { action, n, codes, why } of plan) {
log(` ${action.padEnd(6)} ${n.id.padEnd(28)} ${n.paynym || "(no PayNym)"} (${(codes || []).length} codes)${why ? " — " + why : ""}`);
}
const imports = plan.filter((p) => p.action === "import");
const merges = plan.filter((p) => p.action === "merge");
const refused = plan.filter((p) => p.action === "refuse");
for (const m of merges) {
log(` merge ${m.n.id.padEnd(28)} same Dojo as ${m.dupOf}: history only, no second listing`);
}
if (refused.length) log(`refused ${refused.length} node(s) that cannot be listed here: ${refused.map((p) => p.n.id).join(", ")}`);
// The plan as data, not as log lines. The command line reads the log; the
// admin console has to render this and let an operator decide, and parsing
// the log back out would be inventing a format nobody agreed on.
const rows = plan.map(({ action, n, codes, dupOf, why }) => ({
action, id: n.id, name: n.name || n.id, network: n.network || null,
paynym: n.paynym || null, url: n?.payload?.pairing?.url || null,
codes: (codes || []).length, dupOf: dupOf || null, why: why || null,
}));
if (dryRun) {
log(`dry run: ${imports.length} node(s) would be imported`
+ (merges.length ? `, ${merges.length} recognised as already listed here` : "")
+ ", nothing written.");
return { imported: 0, planned: imports.length, merged: merges.length,
refused: refused.length, plan: rows, status };
}
for (const { n, codes } of imports) {
await store.putSubmission({
id: n.id, network: n.network, name: n.name || n.id,
paymentCodes: codes, paynym: n.paynym || null,
jurisdiction: n.jurisdiction || null, country: n.country || null,
hardware: n.hardware || null, payload: n.payload,
signed: n.signed || null,
// approved at install, because choosing to bootstrap from a directory IS
// the decision to trust its list. An import into a running instance
// arrives pending instead, so it lands in the moderation queue the
// operator already uses and nothing is published until they say so.
status, source: `bootstrap-import:${onionHost}`,
created_at: now, updated_at: now,
});
}
// 3b) verified operator domains.
//
// dojos.json publishes each badge's proof, and the signed statement is
// deliberately portable: it names the domain and the payment code, never the
// instance that verified it. So a claim travels intact — but it is NOT taken
// on the source's word. We re-verify the signature here, locally and offline,
// and store the claim UNVERIFIED so this instance's own sweep must see the TXT
// record with its own eyes before any badge appears. Importing a badge because
// another instance said so would make one compromised directory able to mint
// verified domains across a federation.
const claims = new Map();
for (const n of dojos.nodes || []) {
const pf = n.operator_domain_proof;
if (!pf || !pf.domain || !pf.paymentCode || !pf.signed) continue;
if (claims.has(pf.paymentCode)) continue;
claims.set(pf.paymentCode, pf);
}
let domainsImported = 0, domainsRefused = 0;
if (claims.size) {
const { verifySignedUrlClaim } = await import("../server/crypto.ts");
for (const [code, pf] of claims) {
if (await store.getDomain(code)) continue; // never overwrite a local claim
const v = verifySignedUrlClaim({ signed: pf.signed, expectedUrl: `https://${pf.domain}`, paymentCode: code });
if (!v.ok) {
log(` domain ${pf.domain}: refused (${v.error})`);
domainsRefused++;
continue;
}
await store.putDomain({
paymentCode: code, domain: pf.domain, signed: pf.signed,
verified: false, // this instance has not seen the DNS yet
verified_at: null,
last_check: null, // so the sweep picks it up immediately
last_result: `imported from ${onionHost}; awaiting our own DNS check`,
fail_since: null, created_at: now,
});
log(` domain ${pf.domain}: signature verified, awaiting our own TXT lookup`);
domainsImported++;
}
}
// 4) histories: only for ids we have no history for
for (const [file, remote] of [["history.json", hist], ["history-daily.json", daily]]) {
const p = path.join(dataDir, file);
let local; try { local = JSON.parse(await readFile(p, "utf8")); } catch { local = { nodes: {} } }
local.nodes = local.nodes || {};
let added = 0;
for (const [id, entry] of Object.entries(remote.nodes || {})) {
if (!local.nodes[id] && imports.some((x) => x.n.id === id)) { local.nodes[id] = entry; added++; continue; }
// A duplicate contributes its history under the id this instance uses.
//
// The two series are combined rather than one replacing the other. An
// anchor installed an hour ago has a handful of checks of its own and the
// remote has months: overwriting throws away the local ones, skipping
// throws away the months, and neither is what an operator means by
// importing history. Combined, de-duplicated on the timestamp, sorted,
// and trimmed to the same window the updater keeps.
const merged = merges.find((x) => x.n.id === id);
if (!merged) continue;
const key = entry.checks ? "checks" : "days";
const stamp = key === "checks" ? "t" : "d";
const mine = (local.nodes[merged.dupOf] || {})[key] || [];
const theirs = entry[key] || [];
if (!theirs.length) continue;
const byStamp = new Map();
// Local last, so a period this instance measured itself wins over the
// remote's account of the same period.
for (const row of [...theirs, ...mine]) if (row && row[stamp]) byStamp.set(row[stamp], row);
const all = [...byStamp.values()].sort((x, y) => String(x[stamp]).localeCompare(String(y[stamp])));
const cap = key === "checks" ? (remote.window_checks || local.window_checks || 144) : 90;
local.nodes[merged.dupOf] = { [key]: all.slice(-cap) };
added++;
}
if (added) {
if (remote.interval_minutes && !local.interval_minutes) local.interval_minutes = remote.interval_minutes;
if (remote.window_checks && !local.window_checks) local.window_checks = remote.window_checks;
await writeJSONAtomic(p, local);
log(` history: ${added} node(s) carried into ${file}`);
}
}
log(`imported ${imports.length} node(s) from ${onionHost}`
+ (merges.length ? `, and recognised ${merges.length} as node(s) this instance already lists` : "")
+ ". Now run: node server/build-public.mjs");
return { imported: imports.length, planned: imports.length, merged: merges.length,
refused: refused.length, plan: rows, status,
domains_imported: domainsImported, domains_refused: domainsRefused };
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
const arg = (k) => { const i = process.argv.indexOf(k); return i > 0 ? process.argv[i + 1] : null; };
const onionHost = String(arg("--onion") || "").replace(/^https?:\/\//, "").replace(/\/.*$/, "");
const trustedCode = arg("--code");
if (!/^[a-z2-7]{56}\.onion$/.test(onionHost) || !trustedCode) {
console.error("usage: node scripts/bootstrap-import.mjs --onion <56-char>.onion --code PM8T... [--dry-run]");
process.exit(1);
}
bootstrapImport({ onionHost, trustedCode, dryRun: process.argv.includes("--dry-run") })
.catch((e) => { console.error("fatal:", e.message); process.exit(1); });
}