diff --git a/package.json b/package.json index 4404c70..b9bca6d 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,9 @@ "page-sort": { "description": "Manage page sort packages" }, + "reachability": { + "description": "Make this node dialable from browsers and keep it that way" + }, "update": { "description": "Check for and install bitsocial CLI updates" } diff --git a/src/cli/commands/reachability/status.ts b/src/cli/commands/reachability/status.ts new file mode 100644 index 0000000..70ed946 --- /dev/null +++ b/src/cli/commands/reachability/status.ts @@ -0,0 +1,46 @@ +import { Command, Flags } from "@oclif/core"; +import defaults from "../../../common-utils/defaults.js"; +import { readReachabilityStatus, type ReachabilityStatus } from "../../../reachability/state.js"; + +export default class Status extends Command { + static override description = "Show whether browsers can reach this node: mode, announced addresses, certificate expiry and last checks"; + + static override enableJsonFlag = true; + + static override flags = { + "pkcOptions.dataPath": Flags.directory({ + description: "Data path of the node", + required: false + }) + }; + + static override examples = ["bitsocial reachability status", "bitsocial reachability status --json"]; + + async run(): Promise { + const { flags } = await this.parse(Status); + const status = await readReachabilityStatus(flags["pkcOptions.dataPath"] || defaults.PKC_DATA_PATH); + + if (!status.configured) { + this.log("Reachability is not configured. Browsers cannot reach this node. Run: bitsocial reachability setup"); + return status; + } + this.log(`Enabled: ${status.enabled ? "yes" : "no"}`); + this.log(`Mode: ${status.mode}${status.staging ? " (Let's Encrypt staging)" : ""}`); + if (status.domain) this.log(`Domain: ${status.domain}`); + this.log(`Announced addresses: ${status.addresses.length > 0 ? "" : "none"}`); + for (const address of status.addresses) this.log(` ${address}`); + this.log(`Certificates: ${status.certificates.length > 0 ? "" : "none"}`); + for (const certificate of status.certificates) + this.log(` ${certificate.names.join(", ")}: expires ${certificate.notAfter} (${certificate.hoursLeft} h left)`); + if (status.lastChecks?.lastIpCheck) { + const { at, publicIps } = status.lastChecks.lastIpCheck; + this.log(`Last IP check: ${at} (${publicIps.join(", ") || "no public IP found"})`); + } + for (const [domain, check] of Object.entries(status.lastChecks?.domains ?? {})) { + const state = check.ok ? "ok" : `failing since ${check.failingSince ?? check.lastCheckAt}`; + this.log(`Domain check ${domain}: ${state}${check.droppedFromAnnounce ? ", no longer announced" : ""} (checked ${check.lastCheckAt})`); + } + if (status.lastChecks?.caddy) this.log(`Caddy: ${status.lastChecks.caddy.running ? "running" : "not running"}`); + return status; + } +} diff --git a/src/reachability/kubo-config.ts b/src/reachability/kubo-config.ts new file mode 100644 index 0000000..537d249 --- /dev/null +++ b/src/reachability/kubo-config.ts @@ -0,0 +1,152 @@ +// The only code that decides what `bitsocial reachability` changes in the kubo config. Everything +// here is pure so the disable/enable byte-for-byte round trip can be tested as a property. + +// The `server` kubo profile adds this filter, and its connection gater then rejects every connection +// whose remote address is loopback, which is exactly what a local TLS terminator produces. +export const LOOPBACK_ADDR_FILTER = "/ip4/127.0.0.0/ipcidr/8"; + +export const WEBTRANSPORT_SWARM_ADDRESSES = [ + "/ip4/0.0.0.0/udp/443/quic-v1", + "/ip4/0.0.0.0/udp/443/quic-v1/webtransport", + "/ip6/::/udp/443/quic-v1", + "/ip6/::/udp/443/quic-v1/webtransport" +] as const; + +export const localWsSwarmAddress = (wsPort: number) => `/ip4/127.0.0.1/tcp/${wsPort}/ws`; + +// A field's shape before we touched it. kubo writes `null` and `[]` for empty lists depending on +// version and profile, and restoring the wrong one would break the byte-exact round trip. +type OriginalShape = "absent" | "null" | "present"; + +// What the last apply changed. Stored in reachability/config.json; only this tells our entries apart +// from identical ones the operator wrote, and names the old addresses to remove after an IP change. +export interface KuboEditRecord { + addressesShape: OriginalShape; + swarmListShape: OriginalShape; + appendAnnounceListShape: OriginalShape; + addedSwarm: string[]; + addedAppendAnnounce: string[]; + removedAddrFilters: { entry: string; index: number }[]; + autoTls: { sectionShape: OriginalShape; enabledShape: OriginalShape; enabled?: unknown }; +} + +export interface ReachabilityKuboInput { + wsPort: number; + announce: string[]; + kuboEdits?: KuboEditRecord; +} + +const shapeOf = (value: unknown): OriginalShape => (value === undefined ? "absent" : value === null ? "null" : "present"); + +const withoutEntries = (list: unknown, entries: string[]) => (Array.isArray(list) ? list.filter((entry) => !entries.includes(entry)) : list); + +// Puts an emptied list or section back the way we found it. A list the operator has since added to stays. +const restoreShape = (parent: any, key: string, shape: OriginalShape) => { + const value = parent[key]; + const isEmpty = + value === null || + value === undefined || + (Array.isArray(value) && value.length === 0) || + (typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0); + if (!isEmpty || shape === "present") return; + if (shape === "absent") delete parent[key]; + else parent[key] = null; +}; + +function undoListEdits(config: any, edits: KuboEditRecord) { + if (config.Addresses) { + config.Addresses.Swarm = withoutEntries(config.Addresses.Swarm, edits.addedSwarm); + config.Addresses.AppendAnnounce = withoutEntries(config.Addresses.AppendAnnounce, edits.addedAppendAnnounce); + restoreShape(config.Addresses, "Swarm", edits.swarmListShape); + restoreShape(config.Addresses, "AppendAnnounce", edits.appendAnnounceListShape); + restoreShape(config, "Addresses", edits.addressesShape); + } + if (edits.removedAddrFilters.length > 0 && config.Swarm) { + const restored = edits.removedAddrFilters.map(({ entry }) => entry); + const filters: unknown[] = Array.isArray(config.Swarm.AddrFilters) ? withoutEntries(config.Swarm.AddrFilters, restored) as unknown[] : []; + for (const { entry, index } of edits.removedAddrFilters) filters.splice(Math.min(index, filters.length), 0, entry); + config.Swarm.AddrFilters = filters; + } +} + +// Runs only on disable. If AutoTLS is no longer false the operator flipped it by hand, and we leave +// their choice alone. +function restoreAutoTls(config: any, autoTls: KuboEditRecord["autoTls"]) { + if (config.AutoTLS?.Enabled !== false) return; + if (autoTls.enabledShape === "absent") delete config.AutoTLS.Enabled; + else config.AutoTLS.Enabled = autoTls.enabled; + restoreShape(config, "AutoTLS", autoTls.sectionShape); +} + +const appendMissing = (existing: string[], entries: string[]) => { + const added: string[] = []; + for (const entry of entries) { + if (existing.includes(entry) || added.includes(entry)) continue; + added.push(entry); + } + existing.push(...added); + return added; +}; + +// Returns the new kubo config and the edit record the caller must persist with it. Applying is always +// undo-then-redo against the operator's baseline, so repeated applies and IP changes cannot pile up. +export function planKuboConfig( + currentKuboConfig: any, + reachabilityConfig: ReachabilityKuboInput, + enabled: boolean +): { kuboConfig: any; kuboEdits: KuboEditRecord | undefined } { + const config = structuredClone(currentKuboConfig ?? {}); + const previousEdits = reachabilityConfig.kuboEdits; + if (previousEdits) undoListEdits(config, previousEdits); + + if (!enabled) { + if (previousEdits) restoreAutoTls(config, previousEdits.autoTls); + return { kuboConfig: config, kuboEdits: undefined }; + } + + const addressesShape = previousEdits?.addressesShape ?? shapeOf(config.Addresses); + config.Addresses ??= {}; + const swarmListShape = previousEdits?.swarmListShape ?? shapeOf(config.Addresses.Swarm); + const appendAnnounceListShape = previousEdits?.appendAnnounceListShape ?? shapeOf(config.Addresses.AppendAnnounce); + config.Addresses.Swarm ??= []; + config.Addresses.AppendAnnounce ??= []; + + const addedSwarm = appendMissing(config.Addresses.Swarm, [localWsSwarmAddress(reachabilityConfig.wsPort), ...WEBTRANSPORT_SWARM_ADDRESSES]); + const addedAppendAnnounce = appendMissing(config.Addresses.AppendAnnounce, reachabilityConfig.announce); + + const removedAddrFilters: KuboEditRecord["removedAddrFilters"] = []; + if (Array.isArray(config.Swarm?.AddrFilters)) { + config.Swarm.AddrFilters.forEach((entry: unknown, index: number) => { + if (entry === LOOPBACK_ADDR_FILTER) removedAddrFilters.push({ entry, index }); + }); + config.Swarm.AddrFilters = withoutEntries(config.Swarm.AddrFilters, [LOOPBACK_ADDR_FILTER]); + } + + // Set once per enable. A later repair or IP change must not undo an operator flipping it back on. + let autoTls = previousEdits?.autoTls; + if (!autoTls) { + autoTls = { + sectionShape: shapeOf(config.AutoTLS), + enabledShape: shapeOf(config.AutoTLS?.Enabled), + ...(config.AutoTLS?.Enabled !== undefined ? { enabled: config.AutoTLS.Enabled } : {}) + }; + config.AutoTLS = { ...(config.AutoTLS ?? {}), Enabled: false }; + } + + return { + kuboConfig: config, + kuboEdits: { + addressesShape, + swarmListShape, + appendAnnounceListShape, + addedSwarm, + addedAppendAnnounce, + removedAddrFilters, + autoTls + } + }; +} + +export function computeKuboConfig(currentKuboConfig: any, reachabilityConfig: ReachabilityKuboInput, enabled: boolean): any { + return planKuboConfig(currentKuboConfig, reachabilityConfig, enabled).kuboConfig; +} diff --git a/src/reachability/state.ts b/src/reachability/state.ts new file mode 100644 index 0000000..6df51a3 --- /dev/null +++ b/src/reachability/state.ts @@ -0,0 +1,223 @@ +import { X509Certificate } from "crypto"; +import fs from "fs/promises"; +import net from "net"; +import path from "path"; +import type { KuboEditRecord } from "./kubo-config.js"; + +export const REACHABILITY_CONFIG_VERSION = 1; + +export type ReachabilityMode = "webtransport-only" | "ip" | "domain" | "ip+domain" | "reverse-proxy" | "ip+reverse-proxy"; + +const REACHABILITY_MODES: readonly ReachabilityMode[] = ["webtransport-only", "ip", "domain", "ip+domain", "reverse-proxy", "ip+reverse-proxy"]; + +// Written by setup, enable and disable, and by the daemon when an IP change alters the addresses. +export interface ReachabilityConfig { + version: typeof REACHABILITY_CONFIG_VERSION; + enabled: boolean; + mode: ReachabilityMode; + wsPort: number; + announce: string[]; + publicIps: string[]; + domain?: string; + staging: boolean; + acmeDirectory: string; + kuboEdits?: KuboEditRecord; +} + +// Written only by the daemon, in its own file so a check landing mid-setup cannot overwrite the +// operator's config with a stale copy. +export interface ReachabilityRuntimeStatus { + updatedAt: string; + lastIpCheck?: { at: string; publicIps: string[] }; + domains?: Record; + caddy?: { running: boolean; lastExitAt?: string }; +} + +export interface CertificateInfo { + issuer: string; + names: string[]; + notAfter: string; + hoursLeft: number; +} + +export interface ReachabilityStatus { + configured: boolean; + enabled: boolean; + mode: ReachabilityMode | null; + addresses: string[]; + wsPort: number | null; + domain: string | null; + staging: boolean; + certificates: CertificateInfo[]; + lastChecks: ReachabilityRuntimeStatus | null; +} + +export const reachabilityDir = (dataPath: string) => path.join(dataPath, "reachability"); +export const reachabilityConfigPath = (dataPath: string) => path.join(reachabilityDir(dataPath), "config.json"); +export const reachabilityRuntimeStatusPath = (dataPath: string) => path.join(reachabilityDir(dataPath), "runtime-status.json"); +export const caddyDataDir = (dataPath: string) => path.join(reachabilityDir(dataPath), "caddy-data"); + +const isStringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every((entry) => typeof entry === "string"); + +function assertValidConfig(parsed: any, configPath: string): asserts parsed is ReachabilityConfig { + const fail = (reason: string): never => { + throw new Error(`Invalid reachability config at ${configPath}: ${reason}`); + }; + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) fail("expected a JSON object"); + if (typeof parsed.version !== "number") fail("missing version"); + // Rewriting a newer file with this version's schema would silently lose fields, so refuse. + if (parsed.version > REACHABILITY_CONFIG_VERSION) + fail(`version ${parsed.version} was written by a newer bitsocial-cli; this one understands up to ${REACHABILITY_CONFIG_VERSION}. Update bitsocial-cli`); + if (parsed.version < 1) fail(`unknown version ${parsed.version}`); + if (typeof parsed.enabled !== "boolean") fail("enabled must be a boolean"); + if (!REACHABILITY_MODES.includes(parsed.mode)) fail(`unknown mode ${JSON.stringify(parsed.mode)}`); + if (!Number.isInteger(parsed.wsPort) || parsed.wsPort < 1 || parsed.wsPort > 65535) fail("wsPort must be a port number"); + if (!isStringArray(parsed.announce)) fail("announce must be a list of multiaddrs"); + if (!isStringArray(parsed.publicIps)) fail("publicIps must be a list of IP addresses"); + if (parsed.domain !== undefined && typeof parsed.domain !== "string") fail("domain must be a string"); + if (typeof parsed.staging !== "boolean") fail("staging must be a boolean"); + if (typeof parsed.acmeDirectory !== "string") fail("acmeDirectory must be a URL"); +} + +async function readJsonIfExists(filePath: string): Promise { + let raw: string; + try { + raw = await fs.readFile(filePath, "utf-8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + try { + return JSON.parse(raw); + } catch (error) { + throw new Error(`Invalid JSON in ${filePath}: ${(error as Error).message}`); + } +} + +// Write then rename, so a crash or a concurrent reader never sees a half-written file. The temp name +// is unique per process because the daemon and a CLI command can both be writing. +async function writeJsonAtomic(filePath: string, value: unknown): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + const temporaryPath = `${filePath}.${process.pid}.tmp`; + await fs.writeFile(temporaryPath, JSON.stringify(value, null, 4) + "\n"); + await fs.rename(temporaryPath, filePath); +} + +export async function readReachabilityConfig(dataPath: string): Promise { + const configPath = reachabilityConfigPath(dataPath); + const parsed = await readJsonIfExists(configPath); + if (parsed === undefined) return undefined; + assertValidConfig(parsed, configPath); + return parsed; +} + +export const writeReachabilityConfig = (dataPath: string, config: ReachabilityConfig) => + writeJsonAtomic(reachabilityConfigPath(dataPath), config); + +export const writeReachabilityRuntimeStatus = (dataPath: string, status: ReachabilityRuntimeStatus) => + writeJsonAtomic(reachabilityRuntimeStatusPath(dataPath), status); + +const isPlainObject = (value: unknown): value is Record => value !== null && typeof value === "object" && !Array.isArray(value); + +function isRuntimeStatus(value: unknown): value is ReachabilityRuntimeStatus { + if (!isPlainObject(value) || typeof value["updatedAt"] !== "string") return false; + const { lastIpCheck, domains, caddy } = value; + if (lastIpCheck !== undefined && !(isPlainObject(lastIpCheck) && typeof lastIpCheck["at"] === "string" && isStringArray(lastIpCheck["publicIps"]))) + return false; + if (domains !== undefined) { + if (!isPlainObject(domains)) return false; + for (const domain of Object.values(domains)) + if (!isPlainObject(domain) || typeof domain["lastCheckAt"] !== "string" || typeof domain["ok"] !== "boolean") return false; + } + return caddy === undefined || (isPlainObject(caddy) && typeof caddy["running"] === "boolean"); +} + +// Status must keep working when this file is damaged: it only holds the last check results. +async function readRuntimeStatus(dataPath: string): Promise { + try { + const parsed = await readJsonIfExists(reachabilityRuntimeStatusPath(dataPath)); + return isRuntimeStatus(parsed) ? parsed : null; + } catch { + return null; + } +} + +// The directory name Caddy stores a CA's certificates under. +export function acmeIssuerKey(acmeDirectory: string): string { + const url = new URL(acmeDirectory); + return `${url.host}${url.pathname}`.replace(/[:/]/g, "-"); +} + +// Certificate SANs and operator input spell the same IPv6 address differently, so compare as addresses. +export function isSameCertificateName(a: string, b: string): boolean { + if (net.isIPv6(a) && net.isIPv6(b)) { + const blockList = new net.BlockList(); + blockList.addAddress(a, "ipv6"); + return blockList.check(b, "ipv6"); + } + return a.toLowerCase() === b.toLowerCase(); +} + +// What the node serves now. Caddy keeps certificates for old addresses and other CAs until they expire, +// and treating those as current would misreport expiry, or let a staging certificate pass for a real one. +export function currentCertificates(certificates: CertificateInfo[], acmeDirectory: string, names: string[]): CertificateInfo[] { + const issuer = acmeIssuerKey(acmeDirectory); + return certificates.filter( + (certificate) => certificate.issuer === issuer && certificate.names.some((held) => names.some((name) => isSameCertificateName(held, name))) + ); +} + +function certificateNames(certificate: X509Certificate): string[] { + return (certificate.subjectAltName ?? "") + .split(", ") + .filter(Boolean) + .map((entry) => entry.replace(/^(DNS|IP Address):/, "")); +} + +// Caddy stores certificates at /certificates///.crt. +export async function readIssuedCertificates(dataPath: string, now: Date = new Date()): Promise { + const certificatesRoot = path.join(caddyDataDir(dataPath), "certificates"); + let entries: { name: string; parentPath: string; isFile(): boolean }[]; + try { + entries = await fs.readdir(certificatesRoot, { recursive: true, withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + const certificates: CertificateInfo[] = []; + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".crt")) continue; + const filePath = path.join(entry.parentPath, entry.name); + try { + const certificate = new X509Certificate(await fs.readFile(filePath)); + const notAfter = new Date(certificate.validTo); + certificates.push({ + issuer: path.relative(certificatesRoot, filePath).split(path.sep)[0]!, + names: certificateNames(certificate), + notAfter: notAfter.toISOString(), + hoursLeft: Math.floor((notAfter.getTime() - now.getTime()) / 3_600_000) + }); + } catch { + continue; // Caddy may be mid-write; the next status call will see it + } + } + return certificates.sort((a, b) => a.notAfter.localeCompare(b.notAfter)); +} + +export async function readReachabilityStatus(dataPath: string, now: Date = new Date()): Promise { + const config = await readReachabilityConfig(dataPath); + const [allCertificates, lastChecks] = await Promise.all([readIssuedCertificates(dataPath, now), readRuntimeStatus(dataPath)]); + const names = config ? [...config.publicIps, ...(config.domain ? [config.domain] : [])] : []; + const certificates = config ? currentCertificates(allCertificates, config.acmeDirectory, names) : []; + return { + configured: config !== undefined, + enabled: config?.enabled ?? false, + mode: config?.mode ?? null, + addresses: config?.enabled ? config.announce : [], + wsPort: config?.wsPort ?? null, + domain: config?.domain ?? null, + staging: config?.staging ?? false, + certificates, + lastChecks + }; +} diff --git a/test/cli/reachability-status.test.ts b/test/cli/reachability-status.test.ts new file mode 100644 index 0000000..1092fac --- /dev/null +++ b/test/cli/reachability-status.test.ts @@ -0,0 +1,58 @@ +import { spawn } from "child_process"; +import { describe, expect, it } from "vitest"; +import { directory as randomDirectory } from "tempy"; +import { writeReachabilityConfig, writeReachabilityRuntimeStatus } from "../../src/reachability/state.js"; + +const runStatus = (args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number | null }> => + new Promise((resolve, reject) => { + const proc = spawn("node", ["./bin/run", "reachability", "status", ...args], { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (data: Buffer) => (stdout += data.toString())); + proc.stderr.on("data", (data: Buffer) => (stderr += data.toString())); + proc.on("error", reject); + proc.on("close", (exitCode) => resolve({ stdout, stderr, exitCode })); + }); + +describe("bitsocial reachability status", () => { + it("--json prints only parseable JSON when not configured", async () => { + const { stdout, exitCode } = await runStatus(["--json", "--pkcOptions.dataPath", randomDirectory()]); + expect(exitCode).toBe(0); + expect(JSON.parse(stdout)).toMatchObject({ configured: false, enabled: false, addresses: [] }); + }); + + it("--json reflects the stored config", async () => { + const dataPath = randomDirectory(); + const announce = ["/ip4/203.0.113.7/tcp/443/tls/ws"]; + await writeReachabilityConfig(dataPath, { version: 1, enabled: true, mode: "ip", wsPort: 4002, announce, publicIps: ["203.0.113.7"], staging: true, acmeDirectory: "https://acme-staging-v02.api.letsencrypt.org/directory" }); + const { stdout, exitCode } = await runStatus(["--json", "--pkcOptions.dataPath", dataPath]); + expect(exitCode).toBe(0); + expect(JSON.parse(stdout)).toMatchObject({ configured: true, mode: "ip", addresses: announce, wsPort: 4002, staging: true }); + }); + + it("prints domain and Caddy checks for a person", async () => { + const dataPath = randomDirectory(); + await writeReachabilityConfig(dataPath, { version: 1, enabled: true, mode: "domain", wsPort: 4002, announce: [], publicIps: [], domain: "node.example", staging: false, acmeDirectory: "https://acme-v02.api.letsencrypt.org/directory" }); + await writeReachabilityRuntimeStatus(dataPath, { + updatedAt: "2026-11-02T00:00:00.000Z", + domains: { "node.example": { lastCheckAt: "2026-11-02T00:00:00.000Z", ok: false, failingSince: "2026-11-01T00:00:00.000Z" } }, + caddy: { running: false, lastExitAt: "2026-11-02T00:00:00.000Z" } + }); + const { stdout } = await runStatus(["--pkcOptions.dataPath", dataPath]); + expect(stdout).toContain("Domain check node.example: failing since 2026-11-01T00:00:00.000Z"); + expect(stdout).toContain("Caddy: not running"); + }); + + it("names the setup command for a person when not configured", async () => { + const { stdout } = await runStatus(["--pkcOptions.dataPath", randomDirectory()]); + expect(stdout).toContain("bitsocial reachability setup"); + }); + + it("exits non-zero on a config from a newer version", async () => { + const dataPath = randomDirectory(); + await writeReachabilityConfig(dataPath, { version: 2 } as any); + const { exitCode, stderr } = await runStatus(["--pkcOptions.dataPath", dataPath]); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("newer bitsocial-cli"); + }); +}); diff --git a/test/reachability/kubo-config.test.ts b/test/reachability/kubo-config.test.ts new file mode 100644 index 0000000..cf7403b --- /dev/null +++ b/test/reachability/kubo-config.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; +import { + computeKuboConfig, + LOOPBACK_ADDR_FILTER, + localWsSwarmAddress, + planKuboConfig, + WEBTRANSPORT_SWARM_ADDRESSES, + type ReachabilityKuboInput +} from "../../src/reachability/kubo-config.js"; + +const DEFAULT_SWARM = [ + "/ip4/0.0.0.0/tcp/4001", + "/ip6/::/tcp/4001", + "/ip4/0.0.0.0/udp/4001/webrtc-direct", + "/ip4/0.0.0.0/udp/4001/quic-v1", + "/ip4/0.0.0.0/udp/4001/quic-v1/webtransport", + "/ip6/::/udp/4001/webrtc-direct", + "/ip6/::/udp/4001/quic-v1", + "/ip6/::/udp/4001/quic-v1/webtransport" +]; +const SERVER_ADDR_FILTERS = ["/ip4/10.0.0.0/ipcidr/8", "/ip4/100.64.0.0/ipcidr/10", LOOPBACK_ADDR_FILTER, "/ip4/169.254.0.0/ipcidr/16"]; +const ANNOUNCE_V4 = "/ip4/203.0.113.7/tcp/443/tls/ws"; +const ANNOUNCE_V6 = "/ip6/2001:db8::7/tcp/443/tls/ws"; +const reachability = (overrides: Partial = {}): ReachabilityKuboInput => ({ + wsPort: 4002, + announce: [ANNOUNCE_V4, ANNOUNCE_V6], + ...overrides +}); +const serverProfileConfig = () => ({ + Identity: { PeerID: "peer" }, + Addresses: { API: "/ip4/127.0.0.1/tcp/50019", Swarm: [...DEFAULT_SWARM], Announce: [], AppendAnnounce: [], NoAnnounce: [] }, + Swarm: { AddrFilters: [...SERVER_ADDR_FILTERS], DisableNatPortMap: true }, + AutoTLS: { Enabled: true } +}); + +// One enable, then one disable, as the commands would run them: each persists the returned record. +const enable = (kubo: any, input: ReachabilityKuboInput) => { + const { kuboConfig, kuboEdits } = planKuboConfig(kubo, input, true); + return { kubo: kuboConfig, input: { ...input, kuboEdits } }; +}; +const disable = (kubo: any, input: ReachabilityKuboInput) => { + const { kuboConfig, kuboEdits } = planKuboConfig(kubo, input, false); + return { kubo: kuboConfig, input: { ...input, kuboEdits } }; +}; +const bytes = (value: unknown) => JSON.stringify(value, null, 4); + +describe("computeKuboConfig when enabled", () => { + it("adds the local ws and WebTransport listeners next to the existing ones", () => { + const result = computeKuboConfig(serverProfileConfig(), reachability(), true); + expect(result.Addresses.Swarm).toEqual([...DEFAULT_SWARM, localWsSwarmAddress(4002), ...WEBTRANSPORT_SWARM_ADDRESSES]); + }); + + it("announces every address that succeeded", () => { + expect(computeKuboConfig(serverProfileConfig(), reachability(), true).Addresses.AppendAnnounce).toEqual([ANNOUNCE_V4, ANNOUNCE_V6]); + }); + + it("removes only the loopback AddrFilter", () => { + const result = computeKuboConfig(serverProfileConfig(), reachability(), true); + expect(result.Swarm.AddrFilters).toEqual(SERVER_ADDR_FILTERS.filter((entry) => entry !== LOOPBACK_ADDR_FILTER)); + }); + + it("turns AutoTLS off and leaves every other key alone", () => { + const result = computeKuboConfig(serverProfileConfig(), reachability(), true); + expect(result.AutoTLS.Enabled).toBe(false); + expect(result.Identity).toEqual({ PeerID: "peer" }); + expect(result.Swarm.DisableNatPortMap).toBe(true); + expect(result.Addresses.API).toBe("/ip4/127.0.0.1/tcp/50019"); + }); + + it("does not mutate its input", () => { + const input = serverProfileConfig(); + const before = bytes(input); + computeKuboConfig(input, reachability(), true); + expect(bytes(input)).toBe(before); + }); + + it("is idempotent", () => { + const first = enable(serverProfileConfig(), reachability()); + const second = enable(first.kubo, first.input); + expect(bytes(second.kubo)).toBe(bytes(first.kubo)); + expect(second.input.kuboEdits).toEqual(first.input.kuboEdits); + }); + + it("replaces the old address after an IP change", () => { + const first = enable(serverProfileConfig(), reachability()); + const moved = "/ip4/203.0.113.99/tcp/443/tls/ws"; + const second = enable(first.kubo, { ...first.input, announce: [moved] }); + expect(second.kubo.Addresses.AppendAnnounce).toEqual([moved]); + }); + + it("replaces the old listener when the ws port moves", () => { + const first = enable(serverProfileConfig(), reachability()); + const second = enable(first.kubo, { ...first.input, wsPort: 4003 }); + expect(second.kubo.Addresses.Swarm).toContain(localWsSwarmAddress(4003)); + expect(second.kubo.Addresses.Swarm).not.toContain(localWsSwarmAddress(4002)); + }); + + it("removes a loopback filter the operator put back, since it would block the terminator", () => { + const first = enable(serverProfileConfig(), reachability()); + first.kubo.Swarm.AddrFilters.push(LOOPBACK_ADDR_FILTER); + expect(enable(first.kubo, first.input).kubo.Swarm.AddrFilters).not.toContain(LOOPBACK_ADDR_FILTER); + }); + + it("keeps AutoTLS on after a repair if the operator flipped it back", () => { + const first = enable(serverProfileConfig(), reachability()); + first.kubo.AutoTLS.Enabled = true; + expect(enable(first.kubo, first.input).kubo.AutoTLS.Enabled).toBe(true); + }); +}); + +describe("computeKuboConfig when disabled", () => { + it("changes nothing if reachability was never applied", () => { + const input = serverProfileConfig(); + expect(bytes(computeKuboConfig(input, reachability(), false))).toBe(bytes(input)); + }); + + it("keeps an identical entry the operator had before we enabled", () => { + const original = serverProfileConfig(); + original.Addresses.Swarm.push(WEBTRANSPORT_SWARM_ADDRESSES[0]); + (original.Addresses.AppendAnnounce as string[]).push(ANNOUNCE_V4); + const enabled = enable(original, reachability()); + expect(bytes(disable(enabled.kubo, enabled.input).kubo)).toBe(bytes(original)); + }); + + it("keeps what the operator added while enabled", () => { + const enabled = enable(serverProfileConfig(), reachability()); + enabled.kubo.Addresses.Swarm.push("/ip4/0.0.0.0/tcp/9999"); + enabled.kubo.Addresses.AppendAnnounce.push("/dns4/operator.example/tcp/443/tls/ws"); + enabled.kubo.Swarm.AddrFilters.push("/ip4/192.0.2.0/ipcidr/24"); + const { kubo } = disable(enabled.kubo, enabled.input); + expect(kubo.Addresses.Swarm).toEqual([...DEFAULT_SWARM, "/ip4/0.0.0.0/tcp/9999"]); + expect(kubo.Addresses.AppendAnnounce).toEqual(["/dns4/operator.example/tcp/443/tls/ws"]); + expect(kubo.Swarm.AddrFilters).toEqual([...SERVER_ADDR_FILTERS, "/ip4/192.0.2.0/ipcidr/24"]); + }); + + it("does not override an operator's AutoTLS flip", () => { + const original = serverProfileConfig(); + original.AutoTLS.Enabled = false; + const enabled = enable(original, reachability()); + enabled.kubo.AutoTLS.Enabled = true; + expect(disable(enabled.kubo, enabled.input).kubo.AutoTLS.Enabled).toBe(true); + }); + + it("clears the edit record", () => { + const enabled = enable(serverProfileConfig(), reachability()); + expect(disable(enabled.kubo, enabled.input).input.kuboEdits).toBeUndefined(); + }); +}); + +describe("disable/enable byte-for-byte round trip", () => { + const fixtures: [string, () => any][] = [ + ["server profile", serverProfileConfig], + ["empty config", () => ({})], + ["no AutoTLS section", () => ({ Addresses: { Swarm: [...DEFAULT_SWARM] }, Swarm: { AddrFilters: null } })], + ["AutoTLS section without Enabled", () => ({ AutoTLS: {}, Addresses: { Swarm: [] } })], + ["AutoTLS explicitly off", () => ({ AutoTLS: { Enabled: false, ShortAddrs: true } })], + ["null AutoTLS section", () => ({ AutoTLS: null, Addresses: { Swarm: [] } })], + ["null Addresses section", () => ({ Addresses: null, AutoTLS: { Enabled: true } })], + ["null AutoTLS.Enabled", () => ({ AutoTLS: { Enabled: null } })], + ["null lists", () => ({ Addresses: { Swarm: null, AppendAnnounce: null }, Swarm: { AddrFilters: null } })], + ["loopback filter first", () => ({ Swarm: { AddrFilters: [LOOPBACK_ADDR_FILTER, "/ip4/10.0.0.0/ipcidr/8"] } })], + ["loopback filter last", () => ({ Swarm: { AddrFilters: ["/ip4/10.0.0.0/ipcidr/8", LOOPBACK_ADDR_FILTER] } })], + ["loopback filter twice", () => ({ Swarm: { AddrFilters: [LOOPBACK_ADDR_FILTER, "/ip4/10.0.0.0/ipcidr/8", LOOPBACK_ADDR_FILTER] } })], + ["only the loopback filter", () => ({ Swarm: { AddrFilters: [LOOPBACK_ADDR_FILTER] } })], + ["no loopback filter", () => ({ Swarm: { AddrFilters: ["/ip4/10.0.0.0/ipcidr/8"] } })], + ["operator already listens on udp/443", () => ({ Addresses: { Swarm: [...WEBTRANSPORT_SWARM_ADDRESSES, "/ip4/0.0.0.0/tcp/4001"] } })], + ["operator already announces our address", () => ({ Addresses: { AppendAnnounce: [ANNOUNCE_V6, "/ip4/192.0.2.1/tcp/1"] } })], + ["unusual key order", () => ({ AutoTLS: { Enabled: true }, Swarm: {}, Identity: {}, Addresses: { AppendAnnounce: [], API: "x", Swarm: [] } })] + ]; + + it.each(fixtures)("%s: disable(enable(x)) is x", (_name, fixture) => { + const original = fixture(); + const enabled = enable(original, reachability()); + expect(bytes(disable(enabled.kubo, enabled.input).kubo)).toBe(bytes(original)); + }); + + it.each(fixtures)("%s: enable(disable(enable(x))) is enable(x)", (_name, fixture) => { + const enabled = enable(fixture(), reachability()); + const disabled = disable(enabled.kubo, enabled.input); + const reenabled = enable(disabled.kubo, disabled.input); + expect(bytes(reenabled.kubo)).toBe(bytes(enabled.kubo)); + expect(reenabled.input.kuboEdits).toEqual(enabled.input.kuboEdits); + }); + + it.each(fixtures)("%s: survives an IP change and a port move before disable", (_name, fixture) => { + const original = fixture(); + const first = enable(original, reachability()); + const moved = enable(first.kubo, { ...first.input, announce: ["/ip4/203.0.113.99/tcp/443/tls/ws"], wsPort: 4003 }); + expect(bytes(disable(moved.kubo, moved.input).kubo)).toBe(bytes(original)); + }); + + it.each(fixtures)("%s: stays exact when the record goes through config.json", (_name, fixture) => { + const original = fixture(); + const enabled = enable(original, reachability()); + const reloaded = JSON.parse(JSON.stringify(enabled.input)); + expect(bytes(disable(JSON.parse(bytes(enabled.kubo)), reloaded).kubo)).toBe(bytes(original)); + }); +}); diff --git a/test/reachability/state.test.ts b/test/reachability/state.test.ts new file mode 100644 index 0000000..875d65a --- /dev/null +++ b/test/reachability/state.test.ts @@ -0,0 +1,215 @@ +import fs from "fs/promises"; +import path from "path"; +import { describe, expect, it } from "vitest"; +import { directory as randomDirectory } from "tempy"; +import { + acmeIssuerKey, + caddyDataDir, + readIssuedCertificates, + readReachabilityConfig, + readReachabilityStatus, + reachabilityConfigPath, + reachabilityRuntimeStatusPath, + writeReachabilityConfig, + writeReachabilityRuntimeStatus, + type ReachabilityConfig +} from "../../src/reachability/state.js"; + +// Self-signed, SANs IP:203.0.113.7, IP:2001:db8::7, DNS:node.example, expires 2126-10-09. +const CERTIFICATE_PEM = `-----BEGIN CERTIFICATE----- +MIIBwTCCAWigAwIBAgIULCKX6LXG4To/wQObUkjGcK75nXswCgYIKoZIzj0EAwIw +HDEaMBgGA1UEAwwRcmVhY2hhYmlsaXR5LXRlc3QwIBcNMjYxMTAyMTI1NzQxWhgP +MjEyNjEwMDkxMjU3NDFaMBwxGjAYBgNVBAMMEXJlYWNoYWJpbGl0eS10ZXN0MFkw +EwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAELndqlPuOzVu5TN+hSkn0IrB98qbFHS7K +iCj2zWR7niAi/octeM1WJe7hMYPSOHLKVeb6l9musqzB+oKJgf6BlqOBhTCBgjAd +BgNVHQ4EFgQU59HJ9KLzGmnyywqsFgO0q+SIQ44wHwYDVR0jBBgwFoAU59HJ9KLz +GmnyywqsFgO0q+SIQ44wDwYDVR0TAQH/BAUwAwEB/zAvBgNVHREEKDAmhwTLAHEH +hxAgAQ24AAAAAAAAAAAAAAAHggxub2RlLmV4YW1wbGUwCgYIKoZIzj0EAwIDRwAw +RAIgY0U1lu/j1wXrOBhZ09CaVWyCrwxwSJ8hIMqO7XAq2SACIFPBLDHJ67dGGXq+ +Kd6p8xQWxlusPWNMReJkdwGvoLKZ +-----END CERTIFICATE----- +`; + +const validConfig = (): ReachabilityConfig => ({ + version: 1, + enabled: true, + mode: "ip", + wsPort: 4002, + announce: ["/ip4/203.0.113.7/tcp/443/tls/ws"], + publicIps: ["203.0.113.7"], + staging: false, + acmeDirectory: "https://acme-v02.api.letsencrypt.org/directory" +}); + +const writeRawConfig = async (dataPath: string, content: string) => { + await fs.mkdir(path.dirname(reachabilityConfigPath(dataPath)), { recursive: true }); + await fs.writeFile(reachabilityConfigPath(dataPath), content); +}; + +const installCertificate = async (dataPath: string, name = "203.0.113.7", issuer = "acme-v02.api.letsencrypt.org-directory") => { + const directory = path.join(caddyDataDir(dataPath), "certificates", issuer, name); + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile(path.join(directory, `${name}.crt`), CERTIFICATE_PEM); + await fs.writeFile(path.join(directory, `${name}.key`), "not a certificate"); +}; + +describe("reachability config file", () => { + it("returns undefined when nothing is configured", async () => { + expect(await readReachabilityConfig(randomDirectory())).toBeUndefined(); + }); + + it("round-trips and creates the directory", async () => { + const dataPath = randomDirectory(); + await writeReachabilityConfig(dataPath, validConfig()); + expect(await readReachabilityConfig(dataPath)).toEqual(validConfig()); + }); + + it("leaves no temp file behind", async () => { + const dataPath = randomDirectory(); + await writeReachabilityConfig(dataPath, validConfig()); + expect(await fs.readdir(path.dirname(reachabilityConfigPath(dataPath)))).toEqual(["config.json"]); + }); + + it("refuses a config from a newer version instead of dropping its fields", async () => { + const dataPath = randomDirectory(); + await writeRawConfig(dataPath, JSON.stringify({ ...validConfig(), version: 2 })); + await expect(readReachabilityConfig(dataPath)).rejects.toThrow(/newer bitsocial-cli/); + }); + + it("names the file when the JSON is corrupt", async () => { + const dataPath = randomDirectory(); + await writeRawConfig(dataPath, "{ not json"); + await expect(readReachabilityConfig(dataPath)).rejects.toThrow(reachabilityConfigPath(dataPath)); + }); + + it.each([ + ["no version", { version: undefined }], + ["version 0", { version: 0 }], + ["enabled not boolean", { enabled: "yes" }], + ["unknown mode", { mode: "relay" }], + ["port out of range", { wsPort: 70000 }], + ["announce not a list", { announce: "/ip4/1.1.1.1" }], + ["publicIps holding a number", { publicIps: [1] }], + ["domain not a string", { domain: 5 }], + ["staging missing", { staging: undefined }], + ["acmeDirectory missing", { acmeDirectory: undefined }] + ])("rejects %s", async (_label, overrides) => { + const dataPath = randomDirectory(); + await writeRawConfig(dataPath, JSON.stringify({ ...validConfig(), ...overrides })); + await expect(readReachabilityConfig(dataPath)).rejects.toThrow(/Invalid reachability config/); + }); +}); + +describe("acmeIssuerKey", () => { + it.each([ + ["https://acme-v02.api.letsencrypt.org/directory", "acme-v02.api.letsencrypt.org-directory"], + ["https://acme-staging-v02.api.letsencrypt.org/directory", "acme-staging-v02.api.letsencrypt.org-directory"], + ["https://127.0.0.1:38443/dir", "127.0.0.1-38443-dir"] + ])("%s is stored by Caddy under %s", (directory, key) => { + expect(acmeIssuerKey(directory)).toBe(key); + }); +}); + +describe("issued certificates", () => { + it("returns nothing before Caddy has a data directory", async () => { + expect(await readIssuedCertificates(randomDirectory())).toEqual([]); + }); + + it("reads names, expiry and the CA from Caddy's layout", async () => { + const dataPath = randomDirectory(); + await installCertificate(dataPath); + const [certificate] = await readIssuedCertificates(dataPath, new Date("2126-10-09T00:00:00Z")); + expect(certificate).toEqual({ + issuer: "acme-v02.api.letsencrypt.org-directory", + names: ["203.0.113.7", "2001:DB8:0:0:0:0:0:7", "node.example"], + notAfter: "2126-10-09T12:57:41.000Z", + hoursLeft: 12 + }); + }); + + it("skips an unreadable certificate instead of failing", async () => { + const dataPath = randomDirectory(); + await installCertificate(dataPath); + const brokenDirectory = path.join(caddyDataDir(dataPath), "certificates", "ca", "broken"); + await fs.mkdir(brokenDirectory, { recursive: true }); + await fs.writeFile(path.join(brokenDirectory, "broken.crt"), "half written"); + expect(await readIssuedCertificates(dataPath)).toHaveLength(1); + }); +}); + +describe("reachability status", () => { + it("reports not configured", async () => { + expect(await readReachabilityStatus(randomDirectory())).toEqual({ + configured: false, + enabled: false, + mode: null, + addresses: [], + wsPort: null, + domain: null, + staging: false, + certificates: [], + lastChecks: null + }); + }); + + it("announces nothing while disabled, but keeps the stored mode", async () => { + const dataPath = randomDirectory(); + await writeReachabilityConfig(dataPath, { ...validConfig(), enabled: false }); + const status = await readReachabilityStatus(dataPath); + expect(status).toMatchObject({ configured: true, enabled: false, mode: "ip", addresses: [] }); + }); + + it("lists only certificates for the current addresses from the configured CA", async () => { + const dataPath = randomDirectory(); + await writeReachabilityConfig(dataPath, validConfig()); + await installCertificate(dataPath, "203.0.113.7", "acme-staging-v02.api.letsencrypt.org-directory"); + expect((await readReachabilityStatus(dataPath)).certificates).toEqual([]); + await installCertificate(dataPath, "203.0.113.7"); + const { certificates } = await readReachabilityStatus(dataPath); + expect(certificates.map((certificate) => certificate.issuer)).toEqual(["acme-v02.api.letsencrypt.org-directory"]); + }); + + it("drops a certificate for an address we no longer hold", async () => { + const dataPath = randomDirectory(); + await writeReachabilityConfig(dataPath, { ...validConfig(), publicIps: ["198.51.100.9"], announce: [] }); + await installCertificate(dataPath); + expect((await readReachabilityStatus(dataPath)).certificates).toEqual([]); + }); + + it("matches an IPv6 address however it is spelled", async () => { + const dataPath = randomDirectory(); + await writeReachabilityConfig(dataPath, { ...validConfig(), publicIps: ["2001:0db8:0:0::7"] }); + await installCertificate(dataPath); + expect((await readReachabilityStatus(dataPath)).certificates).toHaveLength(1); + }); + + it("matches the configured domain case-insensitively", async () => { + const dataPath = randomDirectory(); + await writeReachabilityConfig(dataPath, { ...validConfig(), publicIps: [], domain: "NODE.example" }); + await installCertificate(dataPath); + expect((await readReachabilityStatus(dataPath)).certificates).toHaveLength(1); + }); + + it("includes the daemon's last checks", async () => { + const dataPath = randomDirectory(); + await writeReachabilityConfig(dataPath, validConfig()); + const lastChecks = { updatedAt: "2026-11-02T00:00:00.000Z", lastIpCheck: { at: "2026-11-02T00:00:00.000Z", publicIps: ["203.0.113.7"] } }; + await writeReachabilityRuntimeStatus(dataPath, lastChecks); + expect((await readReachabilityStatus(dataPath)).lastChecks).toEqual(lastChecks); + }); + + it.each([ + ["broken JSON", "{ broken"], + ["a list", "[]"], + ["no updatedAt", JSON.stringify({ lastIpCheck: { at: "x", publicIps: [] } })], + ["publicIps not a list", JSON.stringify({ updatedAt: "x", lastIpCheck: { at: "x", publicIps: "1.1.1.1" } })], + ["domains not an object", JSON.stringify({ updatedAt: "x", domains: [] })], + ["a malformed domain entry", JSON.stringify({ updatedAt: "x", domains: { a: { ok: "yes" } } })], + ["caddy.running not a boolean", JSON.stringify({ updatedAt: "x", caddy: { running: 1 } })] + ])("treats a runtime status file with %s as no checks yet", async (_label, content) => { + const dataPath = randomDirectory(); + await writeReachabilityConfig(dataPath, validConfig()); + await fs.writeFile(reachabilityRuntimeStatusPath(dataPath), content); + expect((await readReachabilityStatus(dataPath)).lastChecks).toBeNull(); + }); +});