From 1555fefe2111611efd8ea2ce997badee1aa54296 Mon Sep 17 00:00:00 2001 From: Rinse Date: Mon, 2 Nov 2026 12:56:59 +0000 Subject: [PATCH 1/4] feat(reachability): add the pure kubo config transform computeKuboConfig(kuboConfig, reachabilityConfig, enabled) is the one place that decides the kubo edits. planKuboConfig also returns a record of what changed, which tells our entries from identical operator ones and lets disable restore the touched fields byte for byte. --- src/reachability/kubo-config.ts | 148 +++++++++++++++++++ test/reachability/kubo-config.test.ts | 195 ++++++++++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 src/reachability/kubo-config.ts create mode 100644 test/reachability/kubo-config.test.ts diff --git a/src/reachability/kubo-config.ts b/src/reachability/kubo-config.ts new file mode 100644 index 0000000..eb91e60 --- /dev/null +++ b/src/reachability/kubo-config.ts @@ -0,0 +1,148 @@ +// 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 container 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); + 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); + if (edits.addressesShape === "absent" && Object.keys(config.Addresses).length === 0) delete config.Addresses; + } + 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; + if (autoTls.sectionShape === "absent" && Object.keys(config.AutoTLS).length === 0) delete config.AutoTLS; +} + +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/test/reachability/kubo-config.test.ts b/test/reachability/kubo-config.test.ts new file mode 100644 index 0000000..4933f67 --- /dev/null +++ b/test/reachability/kubo-config.test.ts @@ -0,0 +1,195 @@ +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 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)); + }); +}); From 6ed4c24482fa16e60945994f458f79d66910a217 Mon Sep 17 00:00:00 2001 From: Rinse Date: Mon, 2 Nov 2026 13:12:02 +0000 Subject: [PATCH 2/4] fix(reachability): restore null kubo sections on disable --- src/reachability/kubo-config.ts | 12 ++++++++---- test/reachability/kubo-config.test.ts | 3 +++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/reachability/kubo-config.ts b/src/reachability/kubo-config.ts index eb91e60..537d249 100644 --- a/src/reachability/kubo-config.ts +++ b/src/reachability/kubo-config.ts @@ -40,10 +40,14 @@ const shapeOf = (value: unknown): OriginalShape => (value === undefined ? "absen const withoutEntries = (list: unknown, entries: string[]) => (Array.isArray(list) ? list.filter((entry) => !entries.includes(entry)) : list); -// Puts an emptied container back the way we found it. A list the operator has since added to stays. +// 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); + 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; @@ -55,7 +59,7 @@ function undoListEdits(config: any, edits: KuboEditRecord) { config.Addresses.AppendAnnounce = withoutEntries(config.Addresses.AppendAnnounce, edits.addedAppendAnnounce); restoreShape(config.Addresses, "Swarm", edits.swarmListShape); restoreShape(config.Addresses, "AppendAnnounce", edits.appendAnnounceListShape); - if (edits.addressesShape === "absent" && Object.keys(config.Addresses).length === 0) delete config.Addresses; + restoreShape(config, "Addresses", edits.addressesShape); } if (edits.removedAddrFilters.length > 0 && config.Swarm) { const restored = edits.removedAddrFilters.map(({ entry }) => entry); @@ -71,7 +75,7 @@ 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; - if (autoTls.sectionShape === "absent" && Object.keys(config.AutoTLS).length === 0) delete config.AutoTLS; + restoreShape(config, "AutoTLS", autoTls.sectionShape); } const appendMissing = (existing: string[], entries: string[]) => { diff --git a/test/reachability/kubo-config.test.ts b/test/reachability/kubo-config.test.ts index 4933f67..cf7403b 100644 --- a/test/reachability/kubo-config.test.ts +++ b/test/reachability/kubo-config.test.ts @@ -154,6 +154,9 @@ describe("disable/enable byte-for-byte round trip", () => { ["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] } })], From 537eff14019f4d4ff209c0bc78dde276e0eeb93b Mon Sep 17 00:00:00 2001 From: Rinse Date: Mon, 2 Nov 2026 13:00:39 +0000 Subject: [PATCH 3/4] feat(reachability): add versioned state and the status command reachability/config.json holds the operator's choices and refuses to load a newer version rather than drop its fields. The daemon's check results go in a separate file so they cannot overwrite a setup in progress. --- package.json | 3 + src/cli/commands/reachability/status.ts | 41 ++++++ src/reachability/state.ts | 177 ++++++++++++++++++++++++ test/cli/reachability-status.test.ts | 45 ++++++ test/reachability/state.test.ts | 163 ++++++++++++++++++++++ 5 files changed, 429 insertions(+) create mode 100644 src/cli/commands/reachability/status.ts create mode 100644 src/reachability/state.ts create mode 100644 test/cli/reachability-status.test.ts create mode 100644 test/reachability/state.test.ts 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..d8a4c12 --- /dev/null +++ b/src/cli/commands/reachability/status.ts @@ -0,0 +1,41 @@ +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"})`); + } + return status; + } +} diff --git a/src/reachability/state.ts b/src/reachability/state.ts new file mode 100644 index 0000000..5aa23a4 --- /dev/null +++ b/src/reachability/state.ts @@ -0,0 +1,177 @@ +import { X509Certificate } from "crypto"; +import fs from "fs/promises"; +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; + 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"); +} + +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); + +// Status must keep working when this file is damaged: it only holds the last check results. +async function readRuntimeStatus(dataPath: string): Promise { + try { + return ((await readJsonIfExists(reachabilityRuntimeStatusPath(dataPath))) as ReachabilityRuntimeStatus | undefined) ?? null; + } catch { + return null; + } +} + +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 [certificates, lastChecks] = await Promise.all([readIssuedCertificates(dataPath, now), readRuntimeStatus(dataPath)]); + 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..0a7d3b4 --- /dev/null +++ b/test/cli/reachability-status.test.ts @@ -0,0 +1,45 @@ +import { spawn } from "child_process"; +import { describe, expect, it } from "vitest"; +import { directory as randomDirectory } from "tempy"; +import { writeReachabilityConfig } 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 }); + 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("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/state.test.ts b/test/reachability/state.test.ts new file mode 100644 index 0000000..1e13262 --- /dev/null +++ b/test/reachability/state.test.ts @@ -0,0 +1,163 @@ +import fs from "fs/promises"; +import path from "path"; +import { describe, expect, it } from "vitest"; +import { directory as randomDirectory } from "tempy"; +import { + 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 +}); + +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 }] + ])("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("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("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("survives a damaged runtime status file", async () => { + const dataPath = randomDirectory(); + await writeReachabilityConfig(dataPath, validConfig()); + await fs.writeFile(reachabilityRuntimeStatusPath(dataPath), "{ broken"); + expect((await readReachabilityStatus(dataPath)).lastChecks).toBeNull(); + }); +}); From 812bb36d744f2b28f5f094fd9157d07097a7fb55 Mon Sep 17 00:00:00 2001 From: Rinse Date: Mon, 2 Nov 2026 13:12:41 +0000 Subject: [PATCH 4/4] fix(reachability): show only current certificates and validate runtime status Caddy keeps certificates for old addresses and other CAs until they expire, so status now filters to the configured CA and current names. A damaged runtime status file reads as no checks yet, and the text output prints domain and Caddy checks. --- src/cli/commands/reachability/status.ts | 5 +++ src/reachability/state.ts | 50 ++++++++++++++++++++- test/cli/reachability-status.test.ts | 17 ++++++- test/reachability/state.test.ts | 60 +++++++++++++++++++++++-- 4 files changed, 124 insertions(+), 8 deletions(-) diff --git a/src/cli/commands/reachability/status.ts b/src/cli/commands/reachability/status.ts index d8a4c12..70ed946 100644 --- a/src/cli/commands/reachability/status.ts +++ b/src/cli/commands/reachability/status.ts @@ -36,6 +36,11 @@ export default class Status extends Command { 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/state.ts b/src/reachability/state.ts index 5aa23a4..6df51a3 100644 --- a/src/reachability/state.ts +++ b/src/reachability/state.ts @@ -1,5 +1,6 @@ import { X509Certificate } from "crypto"; import fs from "fs/promises"; +import net from "net"; import path from "path"; import type { KuboEditRecord } from "./kubo-config.js"; @@ -19,6 +20,7 @@ export interface ReachabilityConfig { publicIps: string[]; domain?: string; staging: boolean; + acmeDirectory: string; kuboEdits?: KuboEditRecord; } @@ -74,6 +76,7 @@ function assertValidConfig(parsed: any, configPath: string): asserts parsed is R 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 { @@ -114,15 +117,56 @@ export const writeReachabilityConfig = (dataPath: string, config: ReachabilityCo 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 { - return ((await readJsonIfExists(reachabilityRuntimeStatusPath(dataPath))) as ReachabilityRuntimeStatus | undefined) ?? null; + 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(", ") @@ -162,7 +206,9 @@ export async function readIssuedCertificates(dataPath: string, now: Date = new D export async function readReachabilityStatus(dataPath: string, now: Date = new Date()): Promise { const config = await readReachabilityConfig(dataPath); - const [certificates, lastChecks] = await Promise.all([readIssuedCertificates(dataPath, now), readRuntimeStatus(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, diff --git a/test/cli/reachability-status.test.ts b/test/cli/reachability-status.test.ts index 0a7d3b4..1092fac 100644 --- a/test/cli/reachability-status.test.ts +++ b/test/cli/reachability-status.test.ts @@ -1,7 +1,7 @@ import { spawn } from "child_process"; import { describe, expect, it } from "vitest"; import { directory as randomDirectory } from "tempy"; -import { writeReachabilityConfig } from "../../src/reachability/state.js"; +import { writeReachabilityConfig, writeReachabilityRuntimeStatus } from "../../src/reachability/state.js"; const runStatus = (args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number | null }> => new Promise((resolve, reject) => { @@ -24,12 +24,25 @@ describe("bitsocial reachability status", () => { 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 }); + 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"); diff --git a/test/reachability/state.test.ts b/test/reachability/state.test.ts index 1e13262..875d65a 100644 --- a/test/reachability/state.test.ts +++ b/test/reachability/state.test.ts @@ -3,6 +3,7 @@ import path from "path"; import { describe, expect, it } from "vitest"; import { directory as randomDirectory } from "tempy"; import { + acmeIssuerKey, caddyDataDir, readIssuedCertificates, readReachabilityConfig, @@ -36,7 +37,8 @@ const validConfig = (): ReachabilityConfig => ({ wsPort: 4002, announce: ["/ip4/203.0.113.7/tcp/443/tls/ws"], publicIps: ["203.0.113.7"], - staging: false + staging: false, + acmeDirectory: "https://acme-v02.api.letsencrypt.org/directory" }); const writeRawConfig = async (dataPath: string, content: string) => { @@ -89,7 +91,8 @@ describe("reachability config file", () => { ["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 }] + ["staging missing", { staging: undefined }], + ["acmeDirectory missing", { acmeDirectory: undefined }] ])("rejects %s", async (_label, overrides) => { const dataPath = randomDirectory(); await writeRawConfig(dataPath, JSON.stringify({ ...validConfig(), ...overrides })); @@ -97,6 +100,16 @@ describe("reachability config file", () => { }); }); +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([]); @@ -146,6 +159,37 @@ describe("reachability status", () => { 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()); @@ -154,10 +198,18 @@ describe("reachability status", () => { expect((await readReachabilityStatus(dataPath)).lastChecks).toEqual(lastChecks); }); - it("survives a damaged runtime status file", async () => { + 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), "{ broken"); + await fs.writeFile(reachabilityRuntimeStatusPath(dataPath), content); expect((await readReachabilityStatus(dataPath)).lastChecks).toBeNull(); }); });