diff --git a/README.md b/README.md index 1caa5b1a..122c1125 100644 --- a/README.md +++ b/README.md @@ -940,7 +940,7 @@ DESCRIPTION any machine. Within the web ui users are able to browse, create and manage their communities fully P2P. Options can be passed to the RPC's instance through flag --pkcOptions.optionName. For a list of pkc options (https://github.com/pkcprotocol/pkc-js?tab=readme-ov-file#pkcoptions) - If you need to modify ipfs config, you should head to {bitsocial-data-path}/.ipfs-bitsocial-cli/config and modify the + If you need to modify ipfs config, you should head to {bitsocial-data-path}/.bitsocial-cli.ipfs/config (or $IPFS_PATH/config if set) and modify the config file diff --git a/package-lock.json b/package-lock.json index 7efa7553..339e8f33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,12 @@ "hasInstallScript": true, "license": "GPL-3.0-or-later", "dependencies": { + "@achingbrain/nat-port-mapper": "4.0.5", "@bitsocial/bso-resolver": "0.0.10", + "@libp2p/identify": "4.1.18", + "@libp2p/noise": "17.0.7", + "@libp2p/websockets": "10.1.25", + "@libp2p/yamux": "8.0.7", "@multiformats/multiaddr": "13.0.1", "@oclif/core": "4.8.0", "@oclif/plugin-help": "6.2.36", @@ -23,6 +28,7 @@ "exit-hook": "4.0.0", "express": "4.19.2", "kubo": "0.43.1", + "libp2p": "3.5.1", "p-limit": "7.3.0", "strip-json-comments": "5.0.3", "tcp-port-used": "1.0.2", diff --git a/package.json b/package.json index 4404c705..fc4dd3df 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" } @@ -116,7 +119,12 @@ "wait-on": "6.0.1" }, "dependencies": { + "@achingbrain/nat-port-mapper": "4.0.5", "@bitsocial/bso-resolver": "0.0.10", + "@libp2p/identify": "4.1.18", + "@libp2p/noise": "17.0.7", + "@libp2p/websockets": "10.1.25", + "@libp2p/yamux": "8.0.7", "@multiformats/multiaddr": "13.0.1", "@oclif/core": "4.8.0", "@oclif/plugin-help": "6.2.36", @@ -129,6 +137,7 @@ "exit-hook": "4.0.0", "express": "4.19.2", "kubo": "0.43.1", + "libp2p": "3.5.1", "p-limit": "7.3.0", "strip-json-comments": "5.0.3", "tcp-port-used": "1.0.2", diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index 9368b497..b99f34db 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -11,8 +11,10 @@ import { PKCLogger, setupDebugLogger, loadKuboConfigFile, - parseMultiAddrKuboRpcToUrl, - parseMultiAddrIpfsGatewayToUrl + kuboRpcUrlFromKuboConfig, + ipfsGatewayUrlFromKuboConfig, + hostForUrl, + toConnectableHostname } from "../../util.js"; import type { PKCLoggerType } from "../../util.js"; import { startDaemonServer } from "../../webui/daemon-server.js"; @@ -26,14 +28,6 @@ import { createDaemonFileLogger, type DaemonFileLogger } from "../../common-util import fs from "fs"; import fsPromise from "fs/promises"; -/** Replace wildcard bind addresses with loopback for connectivity checks (macOS rejects connect to 0.0.0.0 with EINVAL) */ -function toConnectableHostname(hostname: string): string { - if (process.platform === "darwin") { - if (hostname === "0.0.0.0") return "127.0.0.1"; - if (hostname === "::") return "::1"; - } - return hostname; -} import { EOL } from "node:os"; import { formatWithOptions } from "node:util"; import { createRequire } from "node:module"; @@ -89,7 +83,7 @@ export async function runKeepKuboUpTick(deps: KeepKuboUpTickDeps): Promise export default class Daemon extends Command { static override description = `Run a network-connected Bitsocial node. Once the daemon is running you can create and start your communities and receive publications from users. The daemon will also serve web ui on http that can be accessed through a browser on any machine. Within the web ui users are able to browse, create and manage their communities fully P2P. Options can be passed to the RPC's instance through flag --pkcOptions.optionName. For a list of pkc options (https://github.com/pkcprotocol/pkc-js?tab=readme-ov-file#pkcoptions) - If you need to modify ipfs config, you should head to {bitsocial-data-path}/.ipfs-bitsocial-cli/config and modify the config file + If you need to modify ipfs config, you should head to {bitsocial-data-path}/.bitsocial-cli.ipfs/config (or $IPFS_PATH/config if set) and modify the config file `; static override flags = { @@ -328,14 +322,14 @@ export default class Daemon extends Command { const ipfsConfig = await loadKuboConfigFile(pkcOptionsFromFlag?.dataPath || defaultPkcOptions.dataPath!); const kuboRpcEndpoint = pkcOptionsFromFlag?.kuboRpcClientsOptions ? new URL(pkcOptionsFromFlag.kuboRpcClientsOptions[0]!.toString()) - : ipfsConfig?.["Addresses"]?.["API"] - ? await parseMultiAddrKuboRpcToUrl(ipfsConfig?.["Addresses"]?.["API"]) - : defaults.KUBO_RPC_URL; + : process.env["KUBO_RPC_URL"] + ? defaults.KUBO_RPC_URL + : ((await kuboRpcUrlFromKuboConfig(ipfsConfig)) ?? defaults.KUBO_RPC_URL); const ipfsGatewayEndpoint = pkcOptionsFromFlag?.ipfsGatewayUrls ? new URL(pkcOptionsFromFlag.ipfsGatewayUrls[0]) - : ipfsConfig?.["Addresses"]?.["Gateway"] - ? await parseMultiAddrIpfsGatewayToUrl(ipfsConfig?.["Addresses"]?.["Gateway"]) - : defaults.IPFS_GATEWAY_URL; + : process.env["IPFS_GATEWAY_URL"] + ? defaults.IPFS_GATEWAY_URL + : ((await ipfsGatewayUrlFromKuboConfig(ipfsConfig)) ?? defaults.IPFS_GATEWAY_URL); defaultPkcOptions.kuboRpcClientsOptions = [kuboRpcEndpoint.toString()]; const mergedPkcOptions = { ...defaultPkcOptions, ...pkcOptionsFromFlag }; @@ -392,7 +386,7 @@ export default class Daemon extends Command { if (mainProcessExited || kuboProcess || pendingKuboStart) return; if (isKuboApiPortTaken) { const connectableEndpoint = new URL(kuboRpcEndpoint.toString()); - connectableEndpoint.hostname = connectHostname; + connectableEndpoint.hostname = hostForUrl(connectHostname); const versionUrl = new URL("version", connectableEndpoint); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 2000); diff --git a/src/cli/commands/reachability/setup.ts b/src/cli/commands/reachability/setup.ts new file mode 100644 index 00000000..9b2e60ff --- /dev/null +++ b/src/cli/commands/reachability/setup.ts @@ -0,0 +1,93 @@ +import { spawn } from "child_process"; +import readline from "readline/promises"; +import { Command, Flags } from "@oclif/core"; +import defaults from "../../../common-utils/defaults.js"; +import { initKuboRepoIfNeeded, resolveKuboBinaryPath } from "../../../ipfs/startIpfs.js"; +import { detectContainer } from "../../../reachability/discovery.js"; +import { NeedsAnswerError, realSetupDependencies, runReachabilitySetup, type SetupOptions } from "../../../reachability/setup.js"; +import { loadKuboConfigFile, PKCLogger, resolveKuboRepoPath, resolveKuboRpcUrl } from "../../../util.js"; + +// Test hooks: Pebble in place of Let's Encrypt, and loopback in place of a public address. +function testOverrides(env: NodeJS.ProcessEnv): SetupOptions["overrides"] { + const acmeDirectory = env["BITSOCIAL_TEST_REACHABILITY_ACME_DIRECTORY"]; + if (!acmeDirectory) return undefined; + const number = (value: string | undefined) => (value ? Number(value) : undefined); + const httpsPort = number(env["BITSOCIAL_TEST_REACHABILITY_HTTPS_PORT"]); + const httpPort = number(env["BITSOCIAL_TEST_REACHABILITY_HTTP_PORT"]); + const trustedRoots = env["BITSOCIAL_TEST_REACHABILITY_ACME_ROOT"]; + const publicIps = env["BITSOCIAL_TEST_REACHABILITY_PUBLIC_IPS"]; + return { + acmeDirectory, + ...(trustedRoots ? { acmeTrustedRootsPath: trustedRoots } : {}), + ...(publicIps ? { publicIps: publicIps.split(",") } : {}), + ...(httpsPort ? { httpsPort } : {}), + ...(httpPort ? { httpPort } : {}) + }; +} + +const runSudoSetcap = (binaryPath: string) => + new Promise((resolve) => { + const child = spawn("sudo", ["setcap", "cap_net_bind_service=+ep", binaryPath], { stdio: "inherit" }); + child.once("error", () => resolve(false)); + child.once("exit", (code) => resolve(code === 0)); + }); + +async function confirmOnTerminal(question: string): Promise { + const terminal = readline.createInterface({ input: process.stdin, output: process.stdout }); + try { + return /^y(es)?$/i.test((await terminal.question(`${question} [y/N] `)).trim()); + } finally { + terminal.close(); + } +} + +export default class Setup extends Command { + static override description = + "Make this node dialable from browsers: discover public addresses, obtain a certificate, configure kubo and verify. Safe to rerun"; + + static override flags = { + "pkcOptions.dataPath": Flags.directory({ description: "Data path of the node", required: false }), + staging: Flags.boolean({ description: "Use the Let's Encrypt staging CA. Its certificates are not trusted by browsers", default: false }), + "non-interactive": Flags.boolean({ description: "Never ask. Exit non-zero with the reason if an answer would be needed", default: false }) + }; + + static override examples = ["bitsocial reachability setup", "bitsocial reachability setup --staging", "bitsocial reachability setup --non-interactive"]; + + async run(): Promise { + const { flags } = await this.parse(Setup); + const dataPath = flags["pkcOptions.dataPath"] || defaults.PKC_DATA_PATH; + const log = PKCLogger("bitsocial-cli:reachability:setup"); + const kuboRpcUrl = await resolveKuboRpcUrl(dataPath, process.env["KUBO_RPC_URL"], defaults.KUBO_RPC_URL); + const gatewayUrl = defaults.IPFS_GATEWAY_URL; + // A running daemon holds the repo lock, and `ipfs init` errors out on it, so only init a missing repo. + if (!(await loadKuboConfigFile(dataPath))) await initKuboRepoIfNeeded(log, dataPath, kuboRpcUrl, gatewayUrl); + + const canAsk = !flags["non-interactive"] && process.stdin.isTTY === true; + const overrides = testOverrides(process.env); + try { + await runReachabilitySetup( + { + dataPath, + kuboRepoPath: resolveKuboRepoPath(dataPath), + kuboRpcUrl, + staging: flags.staging, + nonInteractive: flags["non-interactive"], + ...(overrides ? { overrides } : {}) + }, + { + ...realSetupDependencies(), + log: (message) => this.log(message), + warn: (message) => this.logToStderr(message), + ...(canAsk ? { confirm: confirmOnTerminal, runSudoSetcap } : {}), + platform: process.platform, + inContainer: await detectContainer(), + isCi: Boolean(process.env["CI"]), + kuboBinaryPath: await resolveKuboBinaryPath() + } + ); + } catch (error) { + if (error instanceof NeedsAnswerError) this.error(error.message, { exit: 2 }); + throw error; + } + } +} diff --git a/src/cli/commands/reachability/status.ts b/src/cli/commands/reachability/status.ts new file mode 100644 index 00000000..70ed9468 --- /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/ipfs/startIpfs.ts b/src/ipfs/startIpfs.ts index 280c58e5..b8cd9ccc 100644 --- a/src/ipfs/startIpfs.ts +++ b/src/ipfs/startIpfs.ts @@ -6,7 +6,7 @@ import * as remeda from "remeda"; import assert from "assert"; import tcpPortUsed from "tcp-port-used"; import { path as ipfsExePathFunc } from "kubo"; -import { PKCLogger } from "../util.js"; +import { PKCLogger, resolveKuboRepoPath, toConnectableHostname } from "../util.js"; async function getKuboExePath(): Promise { return ipfsExePathFunc(); @@ -237,7 +237,7 @@ async function ensureIpfsPortsAreAvailable(log: any, configPath: string, apiUrl: if (apiMultiAddr) addCheck("IPFS API", apiMultiAddr.host, apiMultiAddr.port, addresses.API); else if (apiUrl?.hostname) { const fallbackPort = Number(apiUrl.port || (apiUrl.protocol === "https:" ? 443 : 80)); - if (Number.isFinite(fallbackPort) && fallbackPort > 0) addCheck("IPFS API", apiUrl.hostname, fallbackPort, apiUrl.toString()); + if (Number.isFinite(fallbackPort) && fallbackPort > 0) addCheck("IPFS API", toConnectableHostname(apiUrl.hostname), fallbackPort, apiUrl.toString()); } const gatewayMultiAddr = typeof addresses.Gateway === "string" ? await parseTcpMultiaddr(addresses.Gateway) : undefined; @@ -245,7 +245,7 @@ async function ensureIpfsPortsAreAvailable(log: any, configPath: string, apiUrl: else if (gatewayUrl?.hostname) { const fallbackPort = Number(gatewayUrl.port || (gatewayUrl.protocol === "https:" ? 443 : 80)); if (Number.isFinite(fallbackPort) && fallbackPort > 0) - addCheck("IPFS Gateway", gatewayUrl.hostname, fallbackPort, gatewayUrl.toString()); + addCheck("IPFS Gateway", toConnectableHostname(gatewayUrl.hostname), fallbackPort, gatewayUrl.toString()); } const swarmAddresses: string[] = Array.isArray(addresses.Swarm) @@ -269,6 +269,34 @@ async function ensureIpfsPortsAreAvailable(log: any, configPath: string, apiUrl: } } } +// Shared with `reachability setup`, which can run before the daemon has ever created the repo. +export async function initKuboRepoIfNeeded(log: any, dataPath: string, apiUrl: URL, gatewayUrl: URL): Promise { + const ipfsDataPath = resolveKuboRepoPath(dataPath); + await fs.promises.mkdir(ipfsDataPath, { recursive: true }); + const kuboExePath = await getKuboExePath(); + const env = { IPFS_PATH: ipfsDataPath, DEBUG_COLORS: "1" }; + let configJustInitialized = false; + try { + await _spawnAsync(log, kuboExePath, ["init"], { env, hideWindows: true }); + configJustInitialized = true; + } catch (e) { + const error = e; + if (!error?.message?.includes("ipfs configuration file already exists!")) throw new Error("Failed to call ipfs init" + error); + } + if (configJustInitialized) { + await _spawnAsync(log, kuboExePath, ["config", "profile", "apply", `server`], { + env, + hideWindows: true + }); + log("Called 'ipfs config profile apply server' successfully"); + await mergeCliDefaultsIntoIpfsConfig(log, path.join(ipfsDataPath, "config"), apiUrl, gatewayUrl); + } else { + log("IPFS config already exists; skipping config overrides to preserve user changes."); + } +} + +export const resolveKuboBinaryPath = getKuboExePath; + export async function startKuboNode( apiUrl: URL, gatewayUrl: URL, @@ -281,7 +309,7 @@ export async function startKuboNode( // throws as unhandledRejections and the promise never settles, which wedges the daemon's // pendingKuboStart tracking and hangs its shutdown (issue #70). const log = PKCLogger("bitsocial-cli:ipfs:startKuboNode"); - const ipfsDataPath = process.env["IPFS_PATH"] || path.join(dataPath, ".bitsocial-cli.ipfs"); + const ipfsDataPath = resolveKuboRepoPath(dataPath); await fs.promises.mkdir(ipfsDataPath, { recursive: true }); const ipfsConfigPath = path.join(ipfsDataPath, "config"); @@ -293,24 +321,7 @@ export async function startKuboNode( const env = { IPFS_PATH: ipfsDataPath, DEBUG_COLORS: "1" }; - let configJustInitialized = false; - try { - await _spawnAsync(log, kuboExePath, ["init"], { env, hideWindows: true }); - configJustInitialized = true; - } catch (e) { - const error = e; - if (!error?.message?.includes("ipfs configuration file already exists!")) throw new Error("Failed to call ipfs init" + error); - } - if (configJustInitialized) { - await _spawnAsync(log, kuboExePath, ["config", "profile", "apply", `server`], { - env, - hideWindows: true - }); - log("Called 'ipfs config profile apply server' successfully"); - await mergeCliDefaultsIntoIpfsConfig(log, ipfsConfigPath, apiUrl, gatewayUrl); - } else { - log("IPFS config already exists; skipping config overrides to preserve user changes."); - } + await initKuboRepoIfNeeded(log, dataPath, apiUrl, gatewayUrl); // Replaces the deprecated `--enable-namesys-pubsub` daemon flag; must run for existing repos too. await ensureIpnsPubsubEnabled(log, ipfsConfigPath); diff --git a/src/reachability/caddy.ts b/src/reachability/caddy.ts new file mode 100644 index 00000000..7f6cb29b --- /dev/null +++ b/src/reachability/caddy.ts @@ -0,0 +1,212 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "child_process"; +import { createHash } from "crypto"; +import fs from "fs/promises"; +import net from "net"; +import path from "path"; +import decompress from "decompress"; +import { caddyDataDir, currentCertificates, readIssuedCertificates, reachabilityDir } from "./state.js"; + +// To bump Caddy, change the version and all six hashes in one commit. They are pinned here rather than +// read from the checksums file next to the download, because that file shares the download's origin. +export const CADDY_VERSION = "2.11.7"; +export const CADDY_RELEASE_SHA256: Record = { + "linux-x64": "727b91701a392de6ebc5027509f548bf39979e5216340d0faed8fa5e69c84f8b", + "linux-arm64": "d8fc6d179a5d283028a472a5618564f6ad8a86fed513e64f032b3b0b7cc45e42", + "darwin-x64": "110c53e5c093e4cfce6eda787f94f23bef913c309f620fab26b4f678031841ad", + "darwin-arm64": "cda3030e5d5b13eb9f0b6fb541037f633bddd958c070c5f561d7a5cccb581665", + "win32-x64": "0a1edc0b799512051c57071ce0e798d3f2cf67dc98171366f1d2b326072e3b06", + "win32-arm64": "34804a5e61106fa6718682f5be6c9edc4cceec9601ba7e25beec1e176cfc3b47" +}; + +export const LETS_ENCRYPT_DIRECTORY = "https://acme-v02.api.letsencrypt.org/directory"; +export const LETS_ENCRYPT_STAGING_DIRECTORY = "https://acme-staging-v02.api.letsencrypt.org/directory"; + +const RELEASE_OS: Record = { linux: "linux", darwin: "mac", win32: "windows" }; +const RELEASE_ARCH: Record = { x64: "amd64", arm64: "arm64" }; + +export function caddyReleaseFor(platform: string, arch: string): { url: string; sha256: string; binaryName: string } { + const sha256 = CADDY_RELEASE_SHA256[`${platform}-${arch}`]; + if (!sha256) throw new Error(`Caddy has no pinned release for ${platform}-${arch}. Supported: ${Object.keys(CADDY_RELEASE_SHA256).join(", ")}`); + const extension = platform === "win32" ? "zip" : "tar.gz"; + const file = `caddy_${CADDY_VERSION}_${RELEASE_OS[platform]}_${RELEASE_ARCH[arch]}.${extension}`; + return { + url: `https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/${file}`, + sha256, + binaryName: platform === "win32" ? "caddy.exe" : "caddy" + }; +} + +export const caddyBinaryPath = (dataPath: string, platform: string = process.platform) => + path.join(reachabilityDir(dataPath), "bin", `caddy-${CADDY_VERSION}${platform === "win32" ? ".exe" : ""}`); + +export const caddyfilePath = (dataPath: string) => path.join(reachabilityDir(dataPath), "Caddyfile"); + +async function downloadWithRetry(url: string, fetchImpl: typeof fetch): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + const response = await fetchImpl(url, { signal: AbortSignal.timeout(300_000) }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return Buffer.from(await response.arrayBuffer()); + } catch (error) { + lastError = error; + if (attempt < 3) await new Promise((resolve) => setTimeout(resolve, 2000 * attempt)); + } + } + throw new Error(`Failed to download Caddy from ${url}: ${(lastError as Error).message}`); +} + +// Downloads on first use, not at install time. The archive is hash-checked before anything is unpacked, +// and the binary only appears at its final path once complete, so a crash cannot leave a partial one. +export async function ensureCaddyBinary( + dataPath: string, + { fetchImpl = fetch, log = () => {} }: { fetchImpl?: typeof fetch; log?: (message: string) => void } = {} +): Promise { + const finalPath = caddyBinaryPath(dataPath); + try { + await fs.access(finalPath); + return finalPath; + } catch { + /* not downloaded yet */ + } + const release = caddyReleaseFor(process.platform, process.arch); + log(`Downloading Caddy ${CADDY_VERSION} from ${release.url}`); + const archive = await downloadWithRetry(release.url, fetchImpl); + const actualHash = createHash("sha256").update(archive).digest("hex"); + if (actualHash !== release.sha256) + throw new Error(`Caddy download failed verification, refusing to run it.\n URL: ${release.url}\n Expected sha256: ${release.sha256}\n Actual sha256: ${actualHash}`); + + const binDirectory = path.dirname(finalPath); + await fs.mkdir(binDirectory, { recursive: true }); + const unpackDirectory = await fs.mkdtemp(path.join(binDirectory, "unpack-")); + try { + const files = await decompress(archive, unpackDirectory, { filter: (file) => file.path === release.binaryName }); + if (files.length !== 1) throw new Error(`Caddy archive did not contain ${release.binaryName}`); + const unpackedPath = path.join(unpackDirectory, release.binaryName); + await fs.chmod(unpackedPath, 0o755); + await fs.rename(unpackedPath, finalPath); + } finally { + await fs.rm(unpackDirectory, { recursive: true, force: true }); + } + return finalPath; +} + +export interface CaddyfileSpec { + ips: string[]; + domains: string[]; + wsPort: number; + adminPort: number; + dataDirectory: string; + acmeDirectory: string; + httpsPort?: number; + httpPort?: number; + acmeTrustedRootsPath?: string; +} + +// Hostnames reach the Caddyfile from --domain, so anything but a plain name must not get through: +// a space, brace or newline would let the value add directives of its own. +const HOSTNAME_PATTERN = /^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; + +const assertPort = (label: string, port: number) => { + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`${label} is not a valid port: ${port}`); +}; + +// Forward slashes work on every OS Caddy runs on, and backslashes would need Caddyfile escaping. +const quotedPath = (value: string) => { + if (/["\r\n]/.test(value)) throw new Error(`Path cannot be used in a Caddyfile: ${value}`); + return `"${value.replaceAll("\\", "/")}"`; +}; + +export function generateCaddyfile(spec: CaddyfileSpec): string { + assertPort("wsPort", spec.wsPort); + assertPort("adminPort", spec.adminPort); + for (const ip of spec.ips) if (net.isIP(ip) === 0) throw new Error(`Not an IP address: ${ip}`); + for (const domain of spec.domains) if (!HOSTNAME_PATTERN.test(domain)) throw new Error(`Not a valid domain name: ${domain}`); + if (spec.httpsPort !== undefined) assertPort("httpsPort", spec.httpsPort); + if (spec.httpPort !== undefined) assertPort("httpPort", spec.httpPort); + + const portSuffix = spec.httpsPort && spec.httpsPort !== 443 ? `:${spec.httpsPort}` : ""; + const siteAddress = (host: string) => `https://${host}${portSuffix}`; + const ipSite = (ip: string) => siteAddress(net.isIPv6(ip) ? `[${ip}]` : ip); + const defaultSni = spec.ips.find((ip) => net.isIPv4(ip)) ?? spec.ips[0]; + + const issuer = (profile?: string) => + [ + "\ttls {", + "\t\tissuer acme {", + `\t\t\tdir ${spec.acmeDirectory}`, + ...(spec.acmeTrustedRootsPath ? [`\t\t\ttrusted_roots ${quotedPath(spec.acmeTrustedRootsPath)}`] : []), + ...(profile ? [`\t\t\tprofile ${profile}`] : []), + "\t\t}", + "\t}" + ].join("\n"); + const site = (addresses: string[], profile?: string) => + `${addresses.join(", ")} {\n${issuer(profile)}\n\treverse_proxy 127.0.0.1:${spec.wsPort}\n}`; + + const globalOptions = [ + `admin 127.0.0.1:${spec.adminPort}`, + "persist_config off", + `storage file_system ${quotedPath(spec.dataDirectory)}`, + // Without this Caddy binds port 80 for redirects and fails to start when anything holds it. + "auto_https disable_redirects", + "skip_install_trust", + // Browsers send no SNI when dialling an IP. Behind NAT the local address is not the public IP + // either, so Caddy could not otherwise pick the certificate. + ...(defaultSni ? [`default_sni ${defaultSni}`] : []), + ...(spec.httpPort ? [`http_port ${spec.httpPort}`] : []), + ...(spec.httpsPort ? [`https_port ${spec.httpsPort}`] : []), + // HTTP/3 would bind UDP on the HTTPS port, which is kubo's WebTransport listener on UDP 443. + "servers {\n\t\tprotocols h1 h2\n\t}" + ]; + + return [ + `{\n${globalOptions.map((line) => `\t${line}`).join("\n")}\n}`, + ...(spec.ips.length > 0 ? [site(spec.ips.map(ipSite), "shortlived")] : []), + ...spec.domains.map((domain) => site([siteAddress(domain)])) + ].join("\n\n") + "\n"; +} + +export function startCaddy(dataPath: string, binaryPath: string, onOutput?: (line: string) => void): ChildProcessWithoutNullStreams { + const child = spawn(binaryPath, ["run", "--config", caddyfilePath(dataPath), "--adapter", "caddyfile"], { + env: { + ...process.env, + XDG_CONFIG_HOME: path.join(reachabilityDir(dataPath), "caddy-config"), + XDG_DATA_HOME: caddyDataDir(dataPath) + }, + windowsHide: true + }); + child.stdout.on("data", (data: Buffer) => onOutput?.(data.toString())); + child.stderr.on("data", (data: Buffer) => onOutput?.(data.toString())); + return child; +} + +const adminRequest = async (adminPort: number, route: string, init: RequestInit) => { + const origin = `http://127.0.0.1:${adminPort}`; + // fetch sends Sec-Fetch-Mode: cors, and Caddy then refuses any request without an allowed Origin. + const headers = { Origin: origin, ...init.headers }; + const response = await fetch(`${origin}${route}`, { signal: AbortSignal.timeout(10_000), ...init, headers }); + if (!response.ok) throw new Error(`Caddy admin ${route} returned HTTP ${response.status}: ${await response.text()}`); +}; + +// Through the admin API rather than signals, so the same code stops Caddy on every OS. +export const stopCaddy = (adminPort: number) => adminRequest(adminPort, "/stop", { method: "POST" }); + +export const reloadCaddy = (adminPort: number, caddyfile: string) => + adminRequest(adminPort, "/load", { method: "POST", headers: { "Content-Type": "text/caddyfile" }, body: caddyfile }); + +// Issuance succeeding is the proof that the internet reached us, so this is the real result of setup. +// Only certificates from this CA count: a leftover --staging one must not pass for a production result. +export async function waitForCertificates( + dataPath: string, + acmeDirectory: string, + names: string[], + timeoutMs = 90_000 +): Promise<{ issued: string[]; missing: string[] }> { + const deadline = Date.now() + timeoutMs; + for (;;) { + const unexpired = (await readIssuedCertificates(dataPath)).filter((certificate) => certificate.hoursLeft > 0); + const issued = names.filter((name) => currentCertificates(unexpired, acmeDirectory, [name]).length > 0); + if (issued.length === names.length || Date.now() >= deadline) return { issued, missing: names.filter((name) => !issued.includes(name)) }; + await new Promise((resolve) => setTimeout(resolve, 500)); + } +} diff --git a/src/reachability/discovery.ts b/src/reachability/discovery.ts new file mode 100644 index 00000000..b5438881 --- /dev/null +++ b/src/reachability/discovery.ts @@ -0,0 +1,176 @@ +import { execFile } from "child_process"; +import { promises as fs } from "fs"; +import net from "net"; +import os from "os"; +import { promisify } from "util"; +import { multiaddr } from "@multiformats/multiaddr"; +import { toConnectableHostname } from "../util.js"; + +const execFileAsync = promisify(execFile); + +const NON_PUBLIC_V4 = new net.BlockList(); +for (const [address, prefix] of [ + ["0.0.0.0", 8], + ["10.0.0.0", 8], + ["100.64.0.0", 10], // CGNAT + ["127.0.0.0", 8], + ["169.254.0.0", 16], + ["172.16.0.0", 12], + ["192.0.0.0", 24], + ["192.0.2.0", 24], + ["192.168.0.0", 16], + ["198.18.0.0", 15], + ["198.51.100.0", 24], + ["203.0.113.0", 24], + ["224.0.0.0", 4], + ["240.0.0.0", 4] +] as const) + NON_PUBLIC_V4.addSubnet(address, prefix, "ipv4"); + +const GLOBAL_V6 = new net.BlockList(); +GLOBAL_V6.addSubnet("2000::", 3, "ipv6"); +const DOCUMENTATION_V6 = new net.BlockList(); +DOCUMENTATION_V6.addSubnet("2001:db8::", 32, "ipv6"); + +export function isPublicIp(ip: string): boolean { + if (net.isIPv4(ip)) return !NON_PUBLIC_V4.check(ip, "ipv4"); + if (net.isIPv6(ip)) return GLOBAL_V6.check(ip, "ipv6") && !DOCUMENTATION_V6.check(ip, "ipv6"); + return false; +} + +export function isCgnatIp(ip: string): boolean { + if (!net.isIPv4(ip)) return false; + const blockList = new net.BlockList(); + blockList.addSubnet("100.64.0.0", 10, "ipv4"); + return blockList.check(ip, "ipv4"); +} + +export function publicIpsFromInterfaces(interfaces: NodeJS.Dict = os.networkInterfaces()): string[] { + const addresses = Object.values(interfaces).flatMap((infos) => infos ?? []); + return [...new Set(addresses.filter((info) => !info.internal && isPublicIp(info.address)).map((info) => info.address))]; +} + +export function lanIpv4FromInterfaces(interfaces: NodeJS.Dict = os.networkInterfaces()): string | undefined { + return Object.values(interfaces) + .flatMap((infos) => infos ?? []) + .find((info) => !info.internal && info.family === "IPv4" && !isPublicIp(info.address))?.address; +} + +// kubo's view of how others reach it; the IPs from its addresses that are public. +export function publicIpsFromMultiaddrs(addresses: string[]): string[] { + const ips = new Set(); + for (const address of addresses) { + try { + const host = multiaddr(address) + .getComponents() + .find((component) => component.name === "ip4" || component.name === "ip6")?.value; + if (host && isPublicIp(host)) ips.add(host); + } catch { + continue; + } + } + return [...ips]; +} + +export async function detectContainer(readFile: (path: string) => Promise = (path) => fs.readFile(path, "utf-8")): Promise { + if (process.platform !== "linux") return false; + try { + await readFile("/.dockerenv"); + return true; + } catch { + /* not Docker, or not visible */ + } + try { + return /docker|containerd|kubepods|podman|libpod|lxc/.test(await readFile("/proc/self/cgroup")); + } catch { + return false; + } +} + +export type PortState = "free" | "busy" | "no-permission"; + +export const probeTcpPort = (port: number, host = "0.0.0.0"): Promise => + new Promise((resolve) => { + const server = net.createServer(); + server.once("error", (error: NodeJS.ErrnoException) => resolve(error.code === "EACCES" ? "no-permission" : "busy")); + server.listen({ port, host, exclusive: true }, () => server.close(() => resolve("free"))); + }); + +export const isTcpPortFree = async (port: number, host: string) => (await probeTcpPort(port, host)) === "free"; + +export type PortHolder = "nginx" | "apache" | "caddy" | "iis" | "other"; + +export function classifyPortHolder(processName: string | undefined): PortHolder | undefined { + if (!processName) return undefined; + const name = processName.toLowerCase(); + if (name.includes("nginx")) return "nginx"; + if (name.includes("apache") || name.includes("httpd")) return "apache"; + if (name.includes("caddy")) return "caddy"; + if (name === "system" || name.includes("w3wp") || name.includes("http.sys")) return "iis"; + return "other"; +} + +export const parseSsHolder = (output: string) => output.match(/users:\(\("([^"]+)"/)?.[1]; +export const parseLsofHolder = (output: string) => output.split("\n").find((line) => line.startsWith("c"))?.slice(1); +export function parseNetstatPid(output: string, port: number): string | undefined { + for (const line of output.split(/\r?\n/)) { + const columns = line.trim().split(/\s+/); + if (columns[0] === "TCP" && columns[1]?.endsWith(`:${port}`) && columns[3] === "LISTENING") return columns[4]; + } + return undefined; +} + +// Best effort: naming the server only makes the printed snippet more useful, so every failure, +// including lacking permission to see other users' processes, just means "unknown". +export async function findPortHolder(port: number, platform: string = process.platform): Promise { + try { + if (platform === "linux") return classifyPortHolder(parseSsHolder((await execFileAsync("ss", ["-ltnpH", `sport = :${port}`])).stdout)); + if (platform === "darwin") return classifyPortHolder(parseLsofHolder((await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fc"])).stdout)); + if (platform === "win32") { + const pid = parseNetstatPid((await execFileAsync("netstat", ["-ano", "-p", "TCP"])).stdout, port); + if (!pid) return undefined; + if (pid === "4") return "iis"; // the System process, which is how HTTP.sys appears + const { stdout } = await execFileAsync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"]); + return classifyPortHolder(stdout.match(/^"([^"]+)"/)?.[1]); + } + } catch { + /* unknown */ + } + return undefined; +} + +const kuboPost = (kuboRpcUrl: URL, route: string) => { + const url = new URL(kuboRpcUrl.toString()); + url.hostname = toConnectableHostname(url.hostname).includes(":") ? `[${toConnectableHostname(url.hostname)}]` : toConnectableHostname(url.hostname); + url.pathname = `${url.pathname.replace(/\/$/, "")}/${route}`; + return fetch(url, { method: "POST", signal: AbortSignal.timeout(5000) }); +}; + +export async function kuboIdentity(kuboRpcUrl: URL): Promise<{ peerId: string; addresses: string[] } | undefined> { + try { + const response = await kuboPost(kuboRpcUrl, "id"); + if (!response.ok) return undefined; + const body = (await response.json()) as { ID: string; Addresses: string[] | null }; + return { peerId: body.ID, addresses: body.Addresses ?? [] }; + } catch { + return undefined; + } +} + +export async function kuboListenAddresses(kuboRpcUrl: URL): Promise { + try { + const response = await kuboPost(kuboRpcUrl, "swarm/addrs/listen"); + if (!response.ok) return undefined; + return ((await response.json()) as { Strings: string[] | null }).Strings ?? []; + } catch { + return undefined; + } +} + +export async function shutdownKubo(kuboRpcUrl: URL): Promise { + try { + await kuboPost(kuboRpcUrl, "shutdown"); + } catch { + /* the connection dropping is what a successful shutdown can look like */ + } +} diff --git a/src/reachability/guidance.ts b/src/reachability/guidance.ts new file mode 100644 index 00000000..9b706c61 --- /dev/null +++ b/src/reachability/guidance.ts @@ -0,0 +1,69 @@ +import type { PortHolder } from "./discovery.js"; + +const EOL = "\n"; + +export const ACME_NOTICE = + "This requests certificates from Let's Encrypt. By running this command you accept their Subscriber Agreement: " + + "https://letsencrypt.org/repository/. No email address is sent."; + +export const setcapCommand = (binaryPath: string) => `sudo setcap cap_net_bind_service=+ep ${JSON.stringify(binaryPath)}`; +export const UNPRIVILEGED_PORT_SYSCTL = "sudo sysctl -w net.ipv4.ip_unprivileged_port_start=443"; + +export function bindDeniedGuidance(options: { inContainer: boolean; caddyPath: string; platform: string }): string { + if (options.inContainer) + return [ + "Caddy may not bind port 443 inside this container. Add one of these to your compose file:", + " cap_add:", + " - NET_BIND_SERVICE", + "or map host port 443 to a port above 1024 inside the container." + ].join(EOL); + return [ + "Caddy may not bind port 443 as a non-root user. Run one of these, then rerun setup:", + ` ${setcapCommand(options.caddyPath)}`, + ` ${UNPRIVILEGED_PORT_SYSCTL} (also lets kubo bind UDP 443; persist it in /etc/sysctl.d/)` + ].join(EOL); +} + +// kubo logs nothing and keeps running when it cannot bind a listener, so this is the only signal. +export function webTransportNotListeningGuidance(options: { inContainer: boolean; kuboPath: string; platform: string }): string { + const intro = "kubo is not listening on UDP 443, so WebTransport still depends on UDP 4001."; + if (options.inContainer) return `${intro} Add cap_add: NET_BIND_SERVICE to your compose file and publish 443/udp.`; + if (options.platform !== "linux") return `${intro} Check that nothing else holds UDP 443.`; + return [ + `${intro} Run one of these, then restart the daemon:`, + ` ${UNPRIVILEGED_PORT_SYSCTL} (persist it in /etc/sysctl.d/)`, + ` ${setcapCommand(options.kuboPath)} (lost whenever kubo is updated)` + ].join(EOL); +} + +export const windowsFirewallGuidance = () => + [ + "Allow inbound traffic through Windows Firewall, from an elevated prompt:", + ' netsh advfirewall firewall add rule name="bitsocial reachability TCP" dir=in action=allow protocol=TCP localport=443', + ' netsh advfirewall firewall add rule name="bitsocial reachability UDP" dir=in action=allow protocol=UDP localport=443' + ].join(EOL); + +export function busyPortMessage(holder: PortHolder | undefined): string { + const name: Record = { + nginx: "nginx", + apache: "Apache", + caddy: "another Caddy", + iis: "IIS or another HTTP.sys service", + other: "another program" + }; + return `TCP 443 is held by ${holder ? name[holder] : "another program"}, so this node cannot serve its own IP certificate.`; +} + +export const IPV6_ONLY_CAVEAT = "Only IPv6 succeeded. Browsers on IPv4-only networks still cannot reach this node over WSS."; +export const NO_IPV6_HINT = "This machine has no global IPv6 address. Enabling it would give browsers a second path."; + +export const unreachableStatement = (reason: string) => + [ + `Neither WSS nor WebTransport can reach this machine from the internet: ${reason}.`, + "Any one of these would change that: a public IPv4 address from your ISP, global IPv6, a router that allows UPnP or manual port forwarding, or a domain on a reverse proxy that can reach this node (--domain)." + ].join(EOL); + +// A real WebSocket upgrade through the terminator to kubo, which answers 101 when the whole path works. +export const testCommandHint = (host: string, port: number) => + "Test from another network while the daemon runs (expect 101):\n" + + ` curl -sS --http1.1 -o /dev/null -w '%{http_code}\\n' -H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' https://${host.includes(":") ? `[${host}]` : host}${port === 443 ? "" : `:${port}`}/`; diff --git a/src/reachability/kubo-config.ts b/src/reachability/kubo-config.ts new file mode 100644 index 00000000..537d249c --- /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/libp2p-dial.ts b/src/reachability/libp2p-dial.ts new file mode 100644 index 00000000..b4d0152c --- /dev/null +++ b/src/reachability/libp2p-dial.ts @@ -0,0 +1,34 @@ +// A real handshake, not a TCP or TLS probe: it proves the address reaches this kubo and not merely +// something answering on that port, because libp2p checks the remote identity against the /p2p part. +// The WebSocket client validates TLS against the system's trust roots, as a browser would. +export async function dialWithLibp2p(address: string, { timeoutMs = 20_000 }: { timeoutMs?: number } = {}): Promise<{ peerId: string }> { + // Imported here because libp2p is slow to load and only setup needs it. + const [{ createLibp2p }, { webSockets }, { noise }, { yamux }, { identify }, { multiaddr }] = await Promise.all([ + import("libp2p"), + import("@libp2p/websockets"), + import("@libp2p/noise"), + import("@libp2p/yamux"), + import("@libp2p/identify"), + import("@multiformats/multiaddr") + ]); + const parsed = multiaddr(address); + if (!parsed.getComponents().some((component) => component.name === "p2p")) + throw new Error(`Cannot verify ${address} without knowing whose node to expect: it has no /p2p/ part`); + + const node = await createLibp2p({ + addresses: { listen: [] }, + transports: [webSockets()], + connectionEncrypters: [noise()], + streamMuxers: [yamux()], + services: { identify: identify() }, + // The default gater refuses private addresses, and verifying them is the operator's call. + connectionGater: { denyDialMultiaddr: () => false } + }); + try { + // libp2p types Multiaddr from its own nested copy of the package; the runtime object is the same. + const connection = await node.dial(parsed as unknown as Parameters[0], { signal: AbortSignal.timeout(timeoutMs) }); + return { peerId: connection.remotePeer.toString() }; + } finally { + await node.stop(); + } +} diff --git a/src/reachability/nat.ts b/src/reachability/nat.ts new file mode 100644 index 00000000..35441ac4 --- /dev/null +++ b/src/reachability/nat.ts @@ -0,0 +1,44 @@ +// Asks the router to forward ports. Used only when no interface holds a public address. +export const ROUTER_MAPPED_PORTS: { port: number; protocol: "tcp" | "udp" }[] = [ + { port: 443, protocol: "tcp" }, + { port: 80, protocol: "tcp" }, + { port: 443, protocol: "udp" } +]; + +const MAPPING_TTL_MS = 60 * 60 * 1000; + +export interface NatRouter { + externalIp(): Promise; + mapPorts(internalHost: string): Promise<{ mapped: string[]; refused: string[] }>; + release(): Promise; +} + +export async function discoverNatRouter(timeoutMs = 5000): Promise { + const { upnpNat } = await import("@achingbrain/nat-port-mapper"); + const client = upnpNat({ description: "bitsocial reachability", autoRefresh: false, ttl: MAPPING_TTL_MS }); + try { + for await (const gateway of client.findGateways({ signal: AbortSignal.timeout(timeoutMs) })) { + return { + externalIp: () => gateway.externalIp({ signal: AbortSignal.timeout(timeoutMs) }), + mapPorts: async (internalHost) => { + const mapped: string[] = []; + const refused: string[] = []; + for (const { port, protocol } of ROUTER_MAPPED_PORTS) { + const label = `${protocol.toUpperCase()} ${port}`; + try { + await gateway.map(port, internalHost, { protocol, externalPort: port, signal: AbortSignal.timeout(timeoutMs) }); + mapped.push(label); + } catch { + refused.push(label); + } + } + return { mapped, refused }; + }, + release: () => gateway.stop({ signal: AbortSignal.timeout(timeoutMs) }) + }; + } + } catch { + // No router answered in time, which is the normal result on a host with a public address. + } + return undefined; +} diff --git a/src/reachability/setup.ts b/src/reachability/setup.ts new file mode 100644 index 00000000..3be10047 --- /dev/null +++ b/src/reachability/setup.ts @@ -0,0 +1,326 @@ +import type { ChildProcess } from "child_process"; +import fs from "fs/promises"; +import net from "net"; +import path from "path"; +import { + caddyfilePath, + ensureCaddyBinary, + generateCaddyfile, + LETS_ENCRYPT_DIRECTORY, + LETS_ENCRYPT_STAGING_DIRECTORY, + reloadCaddy, + startCaddy, + stopCaddy, + waitForCertificates, + type CaddyfileSpec +} from "./caddy.js"; +import { + findPortHolder, + isCgnatIp, + isTcpPortFree, + kuboIdentity, + kuboListenAddresses, + lanIpv4FromInterfaces, + probeTcpPort, + publicIpsFromInterfaces, + publicIpsFromMultiaddrs, + shutdownKubo, + type PortHolder, + type PortState +} from "./discovery.js"; +import { + ACME_NOTICE, + bindDeniedGuidance, + busyPortMessage, + IPV6_ONLY_CAVEAT, + NO_IPV6_HINT, + setcapCommand, + testCommandHint, + unreachableStatement, + webTransportNotListeningGuidance, + windowsFirewallGuidance +} from "./guidance.js"; +import { localWsSwarmAddress, planKuboConfig } from "./kubo-config.js"; +import { discoverNatRouter, type NatRouter } from "./nat.js"; +import { caddyDataDir, readReachabilityConfig, reachabilityDir, writeJsonAtomic, writeReachabilityConfig, type ReachabilityConfig, type ReachabilityMode } from "./state.js"; + +export class NeedsAnswerError extends Error {} + +export interface SetupOptions { + dataPath: string; + kuboRepoPath: string; + kuboRpcUrl: URL; + staging: boolean; + nonInteractive: boolean; + // Set only by tests, through the BITSOCIAL_TEST_REACHABILITY_* environment variables. + overrides?: { acmeDirectory?: string; acmeTrustedRootsPath?: string; publicIps?: string[]; httpsPort?: number; httpPort?: number }; +} + +export interface SetupDependencies { + log: (message: string) => void; + warn: (message: string) => void; + // undefined when nobody can answer, i.e. stdin is not a TTY or --non-interactive was passed. + confirm?: (question: string) => Promise; + runSudoSetcap?: (binaryPath: string) => Promise; + platform: string; + inContainer: boolean; + isCi: boolean; + kuboBinaryPath: string; + discoverRouter: () => Promise; + probePort: (port: number) => Promise; + findHolder: (port: number) => Promise; + publicIpsFromInterfaces: () => string[]; + lanIpv4: () => string | undefined; + ensureCaddy: (dataPath: string) => Promise; + certificateTimeoutMs?: number; +} + +export const realSetupDependencies = (): Pick => ({ + discoverRouter: () => discoverNatRouter(), + probePort: (port) => probeTcpPort(port), + findHolder: (port) => findPortHolder(port), + publicIpsFromInterfaces: () => publicIpsFromInterfaces(), + lanIpv4: () => lanIpv4FromInterfaces(), + ensureCaddy: (dataPath) => ensureCaddyBinary(dataPath) +}); + +// CI must never spend real Let's Encrypt rate limit, whatever a workflow or test passes in. +export function assertAcmeDirectoryAllowed(directory: string, isCi: boolean) { + if (isCi && directory === LETS_ENCRYPT_DIRECTORY) throw new Error("Refusing to use production Let's Encrypt from CI. Use Pebble or --staging."); +} + +export function chooseAcmeDirectory(options: Pick): string { + return options.overrides?.acmeDirectory ?? (options.staging ? LETS_ENCRYPT_STAGING_DIRECTORY : LETS_ENCRYPT_DIRECTORY); +} + +const announceForIp = (ip: string, httpsPort: number) => `/${net.isIPv6(ip) ? "ip6" : "ip4"}/${ip}/tcp/${httpsPort}/tls/ws`; + +export function deriveMode(parts: { ip: boolean; domain: boolean; proxy: boolean }): ReachabilityMode { + if (parts.ip && parts.domain) return "ip+domain"; + if (parts.ip && parts.proxy) return "ip+reverse-proxy"; + if (parts.ip) return "ip"; + if (parts.domain) return "domain"; + if (parts.proxy) return "reverse-proxy"; + return "webtransport-only"; +} + +const freePort = (host = "127.0.0.1"): Promise => + new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, host, () => { + const { port } = server.address() as net.AddressInfo; + server.close(() => resolve(port)); + }); + }); + +async function chooseWsPort(previous: ReachabilityConfig | undefined): Promise { + if (previous) return previous.wsPort; // kubo itself may hold it, so a busy check would be wrong + return (await isTcpPortFree(4002, "127.0.0.1")) ? 4002 : freePort(); +} + +const adminUp = async (adminPort: number) => { + try { + await fetch(`http://127.0.0.1:${adminPort}/config/`, { headers: { Origin: `http://127.0.0.1:${adminPort}` }, signal: AbortSignal.timeout(2000) }); + return true; + } catch { + return false; + } +}; + +export async function discoverPublicIps( + options: SetupOptions, + deps: SetupDependencies +): Promise<{ publicIps: string[]; router?: NatRouter; refused: string[]; sawCgnat: boolean }> { + if (options.overrides?.publicIps) return { publicIps: options.overrides.publicIps, refused: [], sawCgnat: false }; + const fromInterfaces = deps.publicIpsFromInterfaces(); + const identity = await kuboIdentity(options.kuboRpcUrl); + const fromKubo = identity ? publicIpsFromMultiaddrs(identity.addresses) : []; + const ips = new Set([...fromInterfaces, ...fromKubo]); + let sawCgnat = false; + let router: NatRouter | undefined; + const refused: string[] = []; + + if (!fromInterfaces.some((ip) => net.isIPv4(ip))) { + router = await deps.discoverRouter(); + const lanIp = deps.lanIpv4(); + if (router && lanIp) { + const result = await router.mapPorts(lanIp); + refused.push(...result.refused); + if (result.mapped.length > 0) deps.log(`Router forwarded ${result.mapped.join(", ")} to this machine.`); + if (result.refused.length > 0) deps.warn(`Router refused to forward ${result.refused.join(", ")}.`); + const external = await router.externalIp().catch(() => undefined); + if (external) { + if (isCgnatIp(external)) sawCgnat = true; + else if (publicIpsFromMultiaddrs([`/ip4/${external}`]).length > 0) ips.add(external); + } + } + } + return { publicIps: [...ips], ...(router ? { router } : {}), refused, sawCgnat }; +} + +// Starts Caddy (or reloads the one the daemon already runs) until the names are issued. +async function issueCertificates( + spec: CaddyfileSpec, + names: string[], + options: SetupOptions, + deps: SetupDependencies +): Promise<{ issued: string[]; failure?: string }> { + const binaryPath = await deps.ensureCaddy(options.dataPath); + const caddyfile = generateCaddyfile(spec); + await fs.mkdir(reachabilityDir(options.dataPath), { recursive: true }); + await fs.writeFile(caddyfilePath(options.dataPath), caddyfile); + + const timeoutMs = deps.certificateTimeoutMs ?? 90_000; + if (await adminUp(spec.adminPort)) { + await reloadCaddy(spec.adminPort, caddyfile); + const { issued } = await waitForCertificates(options.dataPath, spec.acmeDirectory, names, timeoutMs); + return { issued }; + } + + for (let attempt = 1; ; attempt++) { + let output = ""; + const caddy = startCaddy(options.dataPath, binaryPath, (line) => (output += line)); + const exited = new Promise((resolve) => (caddy as ChildProcess).once("exit", () => resolve())); + let exitedEarly = false; + void exited.then(() => (exitedEarly = true)); + const wait = await Promise.race([ + waitForCertificates(options.dataPath, spec.acmeDirectory, names, timeoutMs), + exited.then(() => undefined) + ]); + if (!exitedEarly) { + await stopCaddy(spec.adminPort).catch(() => caddy.kill()); + await exited; + } + if (wait) return { issued: wait.issued }; + + const denied = /permission denied/i.test(output) && /bind|listen/i.test(output); + if (!denied || attempt > 1) return { issued: [], failure: output.trim().split("\n").at(-1) ?? "Caddy exited" }; + const guidance = bindDeniedGuidance({ inContainer: deps.inContainer, caddyPath: binaryPath, platform: deps.platform }); + if (deps.inContainer || !deps.confirm || !deps.runSudoSetcap || deps.platform !== "linux") throw new NeedsAnswerError(guidance); + if (!(await deps.confirm(`Caddy needs permission to bind port 443. Run: ${setcapCommand(binaryPath)} ?`))) throw new NeedsAnswerError(guidance); + if (!(await deps.runSudoSetcap(binaryPath))) throw new NeedsAnswerError(guidance); + } +} + +async function applyKuboConfig( + config: ReachabilityConfig, + options: Pick, + enabled: boolean +): Promise<{ config: ReachabilityConfig; kuboConfigChanged: boolean }> { + const configPath = path.join(options.kuboRepoPath, "config"); + const current = JSON.parse(await fs.readFile(configPath, "utf-8")); + const { kuboConfig, kuboEdits } = planKuboConfig(current, config, enabled); + const kuboConfigChanged = JSON.stringify(kuboConfig) !== JSON.stringify(current); + if (kuboConfigChanged) await writeJsonAtomic(configPath, kuboConfig); + const next: ReachabilityConfig = { ...config, enabled }; + if (kuboEdits) next.kuboEdits = kuboEdits; + else delete next.kuboEdits; + await writeReachabilityConfig(options.dataPath, next); + return { config: next, kuboConfigChanged }; +} + +// The daemon restarts kubo within seconds. Without a daemon there is nothing to restart or to check yet. +async function restartAndVerifyKubo(config: ReachabilityConfig, restart: boolean, options: SetupOptions, deps: SetupDependencies) { + if (!(await kuboIdentity(options.kuboRpcUrl))) { + deps.log("The daemon is not running. Start it with: bitsocial daemon"); + return; + } + if (restart) await shutdownKubo(options.kuboRpcUrl); + const deadline = Date.now() + (deps.certificateTimeoutMs ?? 60_000); + // Waits for the new kubo: the old one can answer for a moment after the shutdown request. + let listen: string[] | undefined; + while (Date.now() < deadline) { + listen = await kuboListenAddresses(options.kuboRpcUrl); + if (listen?.some((address) => address.includes(localWsSwarmAddress(config.wsPort)))) break; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + if (!listen?.some((address) => address.includes(localWsSwarmAddress(config.wsPort)))) { + deps.warn(`kubo has not come back listening on ${localWsSwarmAddress(config.wsPort)}. Check the daemon, then run: bitsocial reachability status`); + return; + } + if (!listen.some((address) => address.includes("/udp/443/quic-v1/webtransport"))) + deps.warn(webTransportNotListeningGuidance({ inContainer: deps.inContainer, kuboPath: deps.kuboBinaryPath, platform: deps.platform })); +} + +export async function runReachabilitySetup(options: SetupOptions, deps: SetupDependencies): Promise { + const acmeDirectory = chooseAcmeDirectory(options); + assertAcmeDirectoryAllowed(acmeDirectory, deps.isCi); + + const previous = await readReachabilityConfig(options.dataPath); + const httpsPort = options.overrides?.httpsPort ?? 443; + const wsPort = await chooseWsPort(previous); + const caddyAdminPort = previous?.caddyAdminPort ?? (await freePort()); + + const { publicIps, refused, sawCgnat } = await discoverPublicIps(options, deps); + if (publicIps.length > 0) deps.log(`Public addresses: ${publicIps.join(", ")}`); + + const ourCaddyRunning = await adminUp(caddyAdminPort); + const portState = ourCaddyRunning ? "free" : await deps.probePort(httpsPort); + let issuedIps: string[] = []; + let unreachableReason: string | undefined; + + if (publicIps.length === 0) { + unreachableReason = sawCgnat ? "this network is behind carrier-grade NAT and has no global IPv6" : "no public IPv4 or global IPv6 address was found"; + } else if (portState === "busy") { + deps.warn(busyPortMessage(await deps.findHolder(httpsPort))); + unreachableReason = "TCP 443 is in use by another program"; + } else { + deps.log(ACME_NOTICE); + const spec: CaddyfileSpec = { + ips: publicIps, + domains: [], + wsPort, + adminPort: caddyAdminPort, + dataDirectory: caddyDataDir(options.dataPath), + acmeDirectory, + ...(options.overrides?.httpsPort ? { httpsPort: options.overrides.httpsPort } : {}), + ...(options.overrides?.httpPort ? { httpPort: options.overrides.httpPort } : {}), + ...(options.overrides?.acmeTrustedRootsPath ? { acmeTrustedRootsPath: options.overrides.acmeTrustedRootsPath } : {}) + }; + const { issued, failure } = await issueCertificates(spec, publicIps, options, deps); + issuedIps = issued; + if (issuedIps.length === 0) unreachableReason = failure ?? "Let's Encrypt could not reach this machine on TCP 80 or 443"; + // Keep only what worked, so the daemon's Caddy does not retry a name forever. + await fs.writeFile(caddyfilePath(options.dataPath), generateCaddyfile({ ...spec, ips: issuedIps })); + } + + if (issuedIps.length > 0 && issuedIps.every((ip) => net.isIPv6(ip))) deps.warn(IPV6_ONLY_CAVEAT); + if (!publicIps.some((ip) => net.isIPv6(ip)) && issuedIps.length > 0) deps.log(NO_IPV6_HINT); + if (issuedIps.length === 0 && unreachableReason) { + const portHint = refused.length > 0 ? `${unreachableReason}; the router refused ${refused.join(", ")}` : unreachableReason; + deps.warn(unreachableStatement(portHint)); + } + if (deps.platform === "win32" && issuedIps.length > 0) deps.log(windowsFirewallGuidance()); + + const announce = issuedIps.map((ip) => announceForIp(ip, httpsPort)); + const { config, kuboConfigChanged } = await applyKuboConfig( + { + version: 1, + enabled: true, + mode: deriveMode({ ip: issuedIps.length > 0, domain: false, proxy: false }), + wsPort, + announce, + publicIps: issuedIps, + staging: options.staging, + acmeDirectory, + caddyAdminPort, + ...(options.overrides?.httpsPort ? { httpsPort: options.overrides.httpsPort } : {}), + ...(options.overrides?.httpPort ? { httpPort: options.overrides.httpPort } : {}), + ...(options.overrides?.acmeTrustedRootsPath ? { acmeTrustedRootsPath: options.overrides.acmeTrustedRootsPath } : {}), + ...(previous?.kuboEdits ? { kuboEdits: previous.kuboEdits } : {}) + }, + options, + true + ); + await restartAndVerifyKubo(config, kuboConfigChanged, options, deps); + + if (announce.length > 0) { + deps.log("Announced browser addresses:"); + for (const address of announce) deps.log(` ${address}`); + deps.log(testCommandHint(issuedIps[0]!, httpsPort)); + } else { + deps.log("WebTransport on UDP 443 is configured. No WSS address is announced."); + } + return config; +} diff --git a/src/reachability/state.ts b/src/reachability/state.ts new file mode 100644 index 00000000..a287df63 --- /dev/null +++ b/src/reachability/state.ts @@ -0,0 +1,229 @@ +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; + caddyAdminPort: number; + // Stored so the daemon regenerates the same Caddyfile. Non-default only in tests. + httpsPort?: number; + httpPort?: number; + acmeTrustedRootsPath?: 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"); + if (!Number.isInteger(parsed.caddyAdminPort)) fail("caddyAdminPort must be a port number"); +} + +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. +export 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/src/util.ts b/src/util.ts index d84886f4..bb698fe2 100644 --- a/src/util.ts +++ b/src/util.ts @@ -53,8 +53,14 @@ export function getLanIpV4Address(): string | undefined { return undefined; } +// The one place that decides where the kubo repo lives. Everything that spawns kubo or reads or +// writes its config must go through this, so they can never disagree on the directory (issue #200). +export function resolveKuboRepoPath(pkcDataPath: string): string { + return process.env["IPFS_PATH"] || path.join(pkcDataPath, ".bitsocial-cli.ipfs"); +} + export async function loadKuboConfigFile(pkcDataPath: string): Promise { - const kuboConfigPath = path.join(pkcDataPath, ".ipfs-bitsocial-cli", "config"); + const kuboConfigPath = path.join(resolveKuboRepoPath(pkcDataPath), "config"); if (!fs.existsSync(kuboConfigPath)) return undefined; @@ -62,6 +68,12 @@ export async function loadKuboConfigFile(pkcDataPath: string): Promise { + if (envValue) return new URL(envValue); + return (await kuboRpcUrlFromKuboConfig(await loadKuboConfigFile(pkcDataPath))) ?? fallback; +} + async function parseMultiAddr(multiAddrString: string) { const module = await import("@multiformats/multiaddr"); return module.multiaddr(multiAddrString); @@ -77,18 +89,52 @@ function multiAddrToHostPort(multiAddrObj: { getComponents: () => { name: string return { host, port }; } +export const hostForUrl = (host: string) => (host.includes(":") && !host.startsWith("[") ? `[${host}]` : host); + +/** The host to dial for a connectivity check. Sockets need IPv6 hosts without the brackets a URL keeps, + * and a wildcard bind address is reached over loopback (macOS rejects connect to 0.0.0.0 with EINVAL). */ +export function toConnectableHostname(hostname: string): string { + const bare = hostname.replace(/^\[(.*)\]$/, "$1"); + if (bare === "0.0.0.0") return "127.0.0.1"; + if (bare === "::") return "::1"; + return bare; +} + export async function parseMultiAddrKuboRpcToUrl(kuboMultiAddrString: string) { const multiAddrObj = await parseMultiAddr(kuboMultiAddrString); const parsed = multiAddrToHostPort(multiAddrObj); if (!parsed) throw new Error(`Unable to parse kubo RPC multiaddr: ${kuboMultiAddrString}`); - return new URL(`http://${parsed.host}:${parsed.port}/api/v0`); + return new URL(`http://${hostForUrl(parsed.host)}:${parsed.port}/api/v0`); } export async function parseMultiAddrIpfsGatewayToUrl(ipfsGatewaymultiAddrString: string) { const multiAddrObj = await parseMultiAddr(ipfsGatewaymultiAddrString); const parsed = multiAddrToHostPort(multiAddrObj); if (!parsed) throw new Error(`Unable to parse IPFS gateway multiaddr: ${ipfsGatewaymultiAddrString}`); - return new URL(`http://${parsed.host}:${parsed.port}`); + return new URL(`http://${hostForUrl(parsed.host)}:${parsed.port}`); +} + +// Kubo accepts Addresses.API and Addresses.Gateway as a string or a list, and either can be empty +// (disabled) or a /unix socket, which has no URL form here. Take the first usable entry; undefined +// tells the caller to fall back to its defaults. +async function firstUsableKuboAddress(addresses: unknown, parse: (multiAddr: string) => Promise): Promise { + for (const address of Array.isArray(addresses) ? addresses : [addresses]) { + if (typeof address !== "string" || !address) continue; + try { + return await parse(address); + } catch { + continue; + } + } + return undefined; +} + +export function kuboRpcUrlFromKuboConfig(kuboConfig: any): Promise { + return firstUsableKuboAddress(kuboConfig?.Addresses?.API, parseMultiAddrKuboRpcToUrl); +} + +export function ipfsGatewayUrlFromKuboConfig(kuboConfig: any): Promise { + return firstUsableKuboAddress(kuboConfig?.Addresses?.Gateway, parseMultiAddrIpfsGatewayToUrl); } /** Recursively replaces all `null` values with `undefined`. diff --git a/test/cli/daemon-honours-kubo-config-addresses.test.ts b/test/cli/daemon-honours-kubo-config-addresses.test.ts new file mode 100644 index 00000000..3a02b586 --- /dev/null +++ b/test/cli/daemon-honours-kubo-config-addresses.test.ts @@ -0,0 +1,69 @@ +// Regression test for issue #200: an operator who edits Addresses.API / Addresses.Gateway in the kubo +// config must get a daemon that talks to those ports. An explicit KUBO_RPC_URL / IPFS_GATEWAY_URL still +// wins, because it can legitimately name a kubo other than the one in this data path's repo. +import { spawn, type ChildProcess } from "child_process"; +import path from "path"; +import { afterEach, describe, expect, it } from "vitest"; +import { directory as randomDirectory } from "tempy"; +import { allocateFreePort, ensureKuboNodeStopped, killChildProcess, waitForCondition } from "../helpers/daemon-helpers.js"; +import { preInitKuboWithEphemeralSwarm } from "../helpers/kubo-helpers.js"; + +describe("kubo endpoint precedence (issue #200)", () => { + let daemon: ChildProcess | undefined; + let kuboApiUrl: string | undefined; + + afterEach(async () => { + await killChildProcess(daemon); + if (kuboApiUrl) await ensureKuboNodeStopped(kuboApiUrl); + daemon = undefined; + kuboApiUrl = undefined; + }, 60_000); + + const startDaemon = async (envOverrides: Record) => { + const dataPath = randomDirectory(); + const [configApiPort, configGatewayPort, rpcPort] = await Promise.all([ + allocateFreePort("0.0.0.0"), + allocateFreePort("0.0.0.0"), + allocateFreePort("127.0.0.1") + ]); + kuboApiUrl = `http://127.0.0.1:${configApiPort}/api/v0`; + await preInitKuboWithEphemeralSwarm( + path.join(dataPath, ".bitsocial-cli.ipfs"), + new URL(kuboApiUrl), + new URL(`http://127.0.0.1:${configGatewayPort}`) + ); + const { KUBO_RPC_URL: _kubo, IPFS_GATEWAY_URL: _gateway, IPFS_PATH: _repo, ...inheritedEnv } = process.env; + daemon = spawn( + "node", + ["./bin/run", "daemon", "--logPath", randomDirectory(), "--pkcOptions.dataPath", dataPath, "--pkcRpcUrl", `ws://localhost:${rpcPort}`], + { stdio: ["pipe", "pipe", "pipe"], env: { ...inheritedEnv, ...envOverrides } } + ); + let output = ""; + daemon.stdout!.on("data", (data: Buffer) => (output += data.toString())); + daemon.stderr!.on("data", (data: Buffer) => (output += data.toString())); + return { configApiPort, configGatewayPort, printedOptions: async () => { + const ready = await waitForCondition(() => output.includes("Communities in data path"), 90_000, 250); + expect(ready, `daemon never became ready:\n${output}`).toBe(true); + return output.slice(output.indexOf("PKC options:")); + } }; + }; + + it("uses Addresses.API and Addresses.Gateway over the built-in defaults", { timeout: 120_000 }, async () => { + const { configApiPort, configGatewayPort, printedOptions } = await startDaemon({}); + const options = await printedOptions(); + expect(options).toContain(`Kubo IPFS API listening on: http://127.0.0.1:${configApiPort}/api/v0`); + expect(options).toContain(`Kubo IPFS Gateway listening on: http://127.0.0.1:${configGatewayPort}`); + expect((await fetch(`${kuboApiUrl}/version`, { method: "POST" })).ok).toBe(true); + }); + + it("lets an explicit KUBO_RPC_URL and IPFS_GATEWAY_URL win over the kubo config", { timeout: 120_000 }, async () => { + const [envApiPort, envGatewayPort] = await Promise.all([allocateFreePort("0.0.0.0"), allocateFreePort("0.0.0.0")]); + const { printedOptions } = await startDaemon({ + KUBO_RPC_URL: `http://127.0.0.1:${envApiPort}/api/v0`, + IPFS_GATEWAY_URL: `http://127.0.0.1:${envGatewayPort}` + }); + const options = await printedOptions(); + expect(options).toContain(`Kubo IPFS API listening on: http://127.0.0.1:${envApiPort}/api/v0`); + expect(options).toContain(`Kubo IPFS Gateway listening on: http://127.0.0.1:${envGatewayPort}`); + }); +}); diff --git a/test/cli/reachability-setup.integration.test.ts b/test/cli/reachability-setup.integration.test.ts new file mode 100644 index 00000000..8d2599cc --- /dev/null +++ b/test/cli/reachability-setup.integration.test.ts @@ -0,0 +1,101 @@ +// `reachability setup` end to end against a throwaway daemon: Pebble in place of Let's Encrypt, 127.0.0.1 +// in place of a public address. Only an env-gated manual run can prove reachability from the internet. +import { spawn } from "child_process"; +import fs from "fs/promises"; +import path from "path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { directory as randomDirectory } from "tempy"; +import { allocateFreePort, ensureKuboNodeStopped, startPkcDaemonWithDynamicPorts, stopPkcDaemon, type DynamicDaemonResult } from "../helpers/daemon-helpers.js"; +import { startPebble, type RunningPebble } from "../helpers/pebble.js"; + +const runCli = (args: string[], env: Record): Promise<{ stdout: string; stderr: string; exitCode: number | null }> => + new Promise((resolve, reject) => { + const child = spawn("node", ["./bin/run", ...args], { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (data: Buffer) => (stdout += data.toString())); + child.stderr.on("data", (data: Buffer) => (stderr += data.toString())); + child.once("error", reject); + child.once("close", (exitCode) => resolve({ stdout, stderr, exitCode })); + }); + +describe("bitsocial reachability setup", { timeout: 240_000 }, () => { + let pebble: RunningPebble; + let daemon: DynamicDaemonResult; + let dataPath: string; + let httpsPort: number; + let env: Record; + + beforeAll(async () => { + dataPath = randomDirectory(); + pebble = await startPebble(randomDirectory()); + daemon = await startPkcDaemonWithDynamicPorts((e) => ["--pkcOptions.dataPath", dataPath, "--pkcRpcUrl", e.rpcWsUrl]); + httpsPort = await allocateFreePort("0.0.0.0"); + env = { + KUBO_RPC_URL: daemon.kuboRpcUrl, + IPFS_GATEWAY_URL: daemon.gatewayUrl, + BITSOCIAL_TEST_REACHABILITY_ACME_DIRECTORY: pebble.directoryUrl, + BITSOCIAL_TEST_REACHABILITY_ACME_ROOT: pebble.listenerRootPath, + BITSOCIAL_TEST_REACHABILITY_PUBLIC_IPS: "127.0.0.1", + BITSOCIAL_TEST_REACHABILITY_HTTPS_PORT: String(httpsPort), + BITSOCIAL_TEST_REACHABILITY_HTTP_PORT: String(await allocateFreePort("0.0.0.0")) + }; + }, 240_000); + + afterAll(async () => { + await stopPkcDaemon(daemon?.daemonProcess); + if (daemon?.kuboApiUrl) await ensureKuboNodeStopped(daemon.kuboApiUrl); + await pebble?.stop(); + }, 60_000); + + it("issues a certificate, configures kubo and restarts it", async () => { + const result = await runCli(["reachability", "setup", "--non-interactive", "--pkcOptions.dataPath", dataPath], env); + expect(result.exitCode, `${result.stdout}\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain(`/ip4/127.0.0.1/tcp/${httpsPort}/tls/ws`); + expect(result.stdout).toContain("Let's Encrypt"); + }); + + it("wrote the kubo config, and kubo came back listening on the local ws port", async () => { + const config = JSON.parse(await fs.readFile(path.join(dataPath, ".bitsocial-cli.ipfs", "config"), "utf-8")); + expect(config.AutoTLS.Enabled).toBe(false); + expect(config.Swarm.AddrFilters).not.toContain("/ip4/127.0.0.0/ipcidr/8"); + expect(config.Addresses.AppendAnnounce).toEqual([`/ip4/127.0.0.1/tcp/${httpsPort}/tls/ws`]); + const stored = JSON.parse(await fs.readFile(path.join(dataPath, "reachability", "config.json"), "utf-8")); + const listen = (await (await fetch(`${daemon.kuboApiUrl}/swarm/addrs/listen`, { method: "POST" })).json()) as { Strings: string[] }; + expect(listen.Strings.some((address) => address.includes(`/ip4/127.0.0.1/tcp/${stored.wsPort}/ws`))).toBe(true); + }); + + it("status --json reflects it", async () => { + const { stdout } = await runCli(["reachability", "status", "--json", "--pkcOptions.dataPath", dataPath], env); + const status = JSON.parse(stdout); + expect(status).toMatchObject({ configured: true, enabled: true, mode: "ip", addresses: [`/ip4/127.0.0.1/tcp/${httpsPort}/tls/ws`] }); + expect(status.certificates[0].names).toContain("127.0.0.1"); + }); + + it("a rerun neither re-issues nor changes the kubo config", async () => { + const configPath = path.join(dataPath, ".bitsocial-cli.ipfs", "config"); + const before = await fs.readFile(configPath, "utf-8"); + const status = async () => JSON.parse((await runCli(["reachability", "status", "--json", "--pkcOptions.dataPath", dataPath], env)).stdout).certificates; + const certificatesBefore = await status(); + const result = await runCli(["reachability", "setup", "--non-interactive", "--pkcOptions.dataPath", dataPath], env); + expect(result.exitCode, `${result.stdout}\n${result.stderr}`).toBe(0); + expect(await fs.readFile(configPath, "utf-8")).toBe(before); + expect(await status()).toEqual(certificatesBefore); + }); + + it("refuses production Let's Encrypt when CI is set", async () => { + const { BITSOCIAL_TEST_REACHABILITY_ACME_DIRECTORY: _omit, ...withoutPebble } = env; + const { BITSOCIAL_TEST_REACHABILITY_ACME_DIRECTORY: _inherited, ...rest } = process.env; + const child = await new Promise<{ code: number | null; stderr: string }>((resolve) => { + const proc = spawn("node", ["./bin/run", "reachability", "setup", "--non-interactive", "--pkcOptions.dataPath", randomDirectory()], { + env: { ...rest, ...withoutPebble, CI: "true" }, + stdio: ["ignore", "ignore", "pipe"] + }); + let stderr = ""; + proc.stderr.on("data", (data: Buffer) => (stderr += data.toString())); + proc.once("close", (code) => resolve({ code, stderr })); + }); + expect(child.code).not.toBe(0); + expect(child.stderr).toContain("from CI"); + }); +}); diff --git a/test/cli/reachability-status.test.ts b/test/cli/reachability-status.test.ts new file mode 100644 index 00000000..1c9b8650 --- /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", caddyAdminPort: 2019 }); + 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", caddyAdminPort: 2019 }); + 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/fixtures/pebble/listener-cert.pem b/test/fixtures/pebble/listener-cert.pem new file mode 100644 index 00000000..af0e86fc --- /dev/null +++ b/test/fixtures/pebble/listener-cert.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBsjCCAVegAwIBAgIUVeY6aK6rh+TYCo7Ad0Z37yh4ZFkwCgYIKoZIzj0EAwIw +HzEdMBsGA1UEAwwUcGViYmxlLXRlc3QtbGlzdGVuZXIwIBcNMjYxMTAyMTMwNjEw +WhgPMjEyNjEwMDkxMzA2MTBaMB8xHTAbBgNVBAMMFHBlYmJsZS10ZXN0LWxpc3Rl +bmVyMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnTKPk4jOCgv7xsQQOmM4zpLM +VkfTDZfCflMdkQn6VKCzbaHZIOJqhBqtxmt0R259ugQyE++/35VwHTsAjxHGTaNv +MG0wHQYDVR0OBBYEFBxkHRJSF8POLkWpNVz3mIIGOb+HMB8GA1UdIwQYMBaAFBxk +HRJSF8POLkWpNVz3mIIGOb+HMA8GA1UdEwEB/wQFMAMBAf8wGgYDVR0RBBMwEYcE +fwAAAYIJbG9jYWxob3N0MAoGCCqGSM49BAMCA0kAMEYCIQDiQLVL8HB1Wg3WYO2p +cxo3/feQdt5EXfEGTR22SxUYsQIhANu/qzuh8WCF5fjNNPDQ3XBjEBPmDu5pHt1N +GRUoYv0R +-----END CERTIFICATE----- diff --git a/test/fixtures/pebble/listener-key.pem b/test/fixtures/pebble/listener-key.pem new file mode 100644 index 00000000..0cf806d2 --- /dev/null +++ b/test/fixtures/pebble/listener-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQguDYPXycrEXBXuk9N +yXGwHgNrNMkAUj5cEaVAhM5fweuhRANCAASdMo+TiM4KC/vGxBA6YzjOksxWR9MN +l8J+Ux2RCfpUoLNtodkg4mqEGq3Ga3RHbn26BDIT77/flXAdOwCPEcZN +-----END PRIVATE KEY----- diff --git a/test/helpers/pebble.ts b/test/helpers/pebble.ts new file mode 100644 index 00000000..e0c50db8 --- /dev/null +++ b/test/helpers/pebble.ts @@ -0,0 +1,104 @@ +import { spawn, type ChildProcess } from "child_process"; +import { createHash } from "crypto"; +import { existsSync } from "fs"; +import fs from "fs/promises"; +import path from "path"; +import { Agent, fetch as undiciFetch } from "undici"; +import decompress from "decompress"; +import { allocateFreePort, killChildProcess, waitForCondition } from "./daemon-helpers.js"; + +// Let's Encrypt's test CA. Never talks to anything outside this machine, which is why CI can use it. +const PEBBLE_VERSION = "2.10.1"; +const PEBBLE_SHA256: Record = { + "darwin-amd64": "4d1cd69f03bfecd094b944842c27b28bddc459ff466efc2f17cecdea37fd7c62", + "darwin-arm64": "0e4a894a8672a8cfa6dcbff05619cb2b19072ea0bbecd05df101f087d08235b0", + "linux-amd64": "c626f4b966f972aee217b0e121adf3eb9490d4ac57e490be730d0343d99a326e", + "linux-arm64": "31883c79adbc7d21ebb36e84ed698e464e5c0486461c24049b20b3ec47ca9402", + "windows-amd64": "2539f3903302d75f897522b23b1b52b3300f5f992245f13d3bdf3967cf23af6d", + "windows-arm64": "787d2aec8a77c86c5f76280fdf9025f183171ef2115bfeb9ef7e32e0a2ee0f12" +}; +const LISTENER_CERT = path.resolve("test/fixtures/pebble/listener-cert.pem"); +const LISTENER_KEY = path.resolve("test/fixtures/pebble/listener-key.pem"); + +const pebbleOs = process.platform === "win32" ? "windows" : process.platform; +const pebbleArch = process.arch === "x64" ? "amd64" : process.arch; + +async function ensurePebbleBinary(directory: string): Promise { + const target = `${pebbleOs}-${pebbleArch}`; + const sha256 = PEBBLE_SHA256[target]; + if (!sha256) throw new Error(`No pinned Pebble for ${target}`); + const binaryName = pebbleOs === "windows" ? "pebble.exe" : "pebble"; + const binaryPath = path.join(directory, binaryName); + if (existsSync(binaryPath)) return binaryPath; + + const response = await fetch(`https://github.com/letsencrypt/pebble/releases/download/v${PEBBLE_VERSION}/pebble-${target}.zip`); + if (!response.ok) throw new Error(`Pebble download returned HTTP ${response.status}`); + const archive = Buffer.from(await response.arrayBuffer()); + const actual = createHash("sha256").update(archive).digest("hex"); + if (actual !== sha256) throw new Error(`Pebble sha256 mismatch: expected ${sha256}, got ${actual}`); + const files = await decompress(archive, directory, { + filter: (file) => path.basename(file.path) === binaryName, + map: (file) => ({ ...file, path: binaryName }) + }); + if (files.length !== 1) throw new Error(`${binaryName} not found in the Pebble archive`); + await fs.chmod(binaryPath, 0o755); // the release archive ships it without the exec bit + return binaryPath; +} + +export interface RunningPebble { + directoryUrl: string; + listenerRootPath: string; + issuingRootPath: string; + stop: () => Promise; +} + +// Skip-validation mode: orders succeed without dialling back, since CI cannot be reached on 80 or 443. +export async function startPebble(workDirectory: string): Promise { + await fs.mkdir(workDirectory, { recursive: true }); + const binaryPath = await ensurePebbleBinary(workDirectory); + const [acmePort, managementPort] = await Promise.all([allocateFreePort(), allocateFreePort()]); + const configPath = path.join(workDirectory, "pebble.json"); + await fs.writeFile( + configPath, + JSON.stringify({ + pebble: { + listenAddress: `127.0.0.1:${acmePort}`, + managementListenAddress: `127.0.0.1:${managementPort}`, + certificate: LISTENER_CERT, + privateKey: LISTENER_KEY, + httpPort: 5002, + tlsPort: 5001, + externalAccountBindingRequired: false, + profiles: { + default: { description: "default", validityPeriod: 7_776_000 }, + shortlived: { description: "matches Let's Encrypt's 160 hours", validityPeriod: 576_000 } + } + } + }) + ); + const child: ChildProcess = spawn(binaryPath, ["-config", configPath], { + env: { ...process.env, PEBBLE_VA_ALWAYS_VALID: "1", PEBBLE_VA_NOSLEEP: "1", PEBBLE_WFE_NONCEREJECT: "0" }, + stdio: "ignore" + }); + + const dispatcher = new Agent({ connect: { rejectUnauthorized: false } }); + const directoryUrl = `https://127.0.0.1:${acmePort}/dir`; + const reachable = await waitForCondition(async () => { + try { + return (await undiciFetch(directoryUrl, { dispatcher })).ok; + } catch { + return false; + } + }, 20_000, 200); + if (!reachable) { + await killChildProcess(child); + throw new Error("Pebble did not start"); + } + // Generated fresh on every Pebble start, so it has to be fetched rather than committed. + const rootResponse = await undiciFetch(`https://127.0.0.1:${managementPort}/roots/0`, { dispatcher }); + const issuingRootPath = path.join(workDirectory, "issuing-root.pem"); + await fs.writeFile(issuingRootPath, await rootResponse.text()); + await dispatcher.close(); + + return { directoryUrl, listenerRootPath: LISTENER_CERT, issuingRootPath, stop: () => killChildProcess(child) }; +} diff --git a/test/kubo/kubo-repo-path.test.ts b/test/kubo/kubo-repo-path.test.ts new file mode 100644 index 00000000..1d7c97de --- /dev/null +++ b/test/kubo/kubo-repo-path.test.ts @@ -0,0 +1,115 @@ +// Issue #200: the daemon read its kubo config from /.ipfs-bitsocial-cli while kubo itself +// ran at /.bitsocial-cli.ipfs (or $IPFS_PATH), so the read always came back empty and the +// daemon silently fell back to env vars and defaults. +import fs from "fs/promises"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { directory as randomDirectory } from "tempy"; +import { + ipfsGatewayUrlFromKuboConfig, + kuboRpcUrlFromKuboConfig, + loadKuboConfigFile, + parseMultiAddrIpfsGatewayToUrl, + parseMultiAddrKuboRpcToUrl, + resolveKuboRepoPath, + toConnectableHostname +} from "../../src/util.js"; + +describe("kubo repo path resolution", () => { + let previousIpfsPath: string | undefined; + + beforeEach(() => { + previousIpfsPath = process.env["IPFS_PATH"]; + delete process.env["IPFS_PATH"]; + }); + + afterEach(() => { + if (previousIpfsPath === undefined) delete process.env["IPFS_PATH"]; + else process.env["IPFS_PATH"] = previousIpfsPath; + }); + + const writeConfig = async (repoPath: string, config: unknown) => { + await fs.mkdir(repoPath, { recursive: true }); + await fs.writeFile(path.join(repoPath, "config"), JSON.stringify(config)); + }; + + it("defaults to /.bitsocial-cli.ipfs", () => { + expect(resolveKuboRepoPath("some-data-path")).toBe(path.join("some-data-path", ".bitsocial-cli.ipfs")); + }); + + it("prefers $IPFS_PATH", () => { + process.env["IPFS_PATH"] = "custom-repo"; + expect(resolveKuboRepoPath("some-data-path")).toBe("custom-repo"); + }); + + it("loadKuboConfigFile reads the repo kubo actually runs from", async () => { + const dataPath = randomDirectory(); + await writeConfig(path.join(dataPath, ".bitsocial-cli.ipfs"), { Identity: { PeerID: "default-repo" } }); + expect((await loadKuboConfigFile(dataPath))?.Identity.PeerID).toBe("default-repo"); + }); + + it("loadKuboConfigFile honours $IPFS_PATH", async () => { + const dataPath = randomDirectory(); + const customRepo = randomDirectory(); + await writeConfig(path.join(dataPath, ".bitsocial-cli.ipfs"), { Identity: { PeerID: "default-repo" } }); + await writeConfig(customRepo, { Identity: { PeerID: "custom-repo" } }); + process.env["IPFS_PATH"] = customRepo; + expect((await loadKuboConfigFile(dataPath))?.Identity.PeerID).toBe("custom-repo"); + }); + + it("loadKuboConfigFile returns undefined before kubo has been initialised", async () => { + expect(await loadKuboConfigFile(randomDirectory())).toBeUndefined(); + }); +}); + +// These parsers were unreachable while the config read always failed, so #200 is what puts them in +// production. Every shape kubo accepts must either parse or fall back, never crash the daemon. +describe("kubo endpoints from the kubo config", () => { + it("parses IPv6 multiaddrs into valid URLs", async () => { + expect((await parseMultiAddrKuboRpcToUrl("/ip6/::1/tcp/5001")).toString()).toBe("http://[::1]:5001/api/v0"); + expect((await parseMultiAddrIpfsGatewayToUrl("/ip6/::1/tcp/8080")).toString()).toBe("http://[::1]:8080/"); + }); + + it("reads a single-string Addresses.API and Addresses.Gateway", async () => { + const config = { Addresses: { API: "/ip4/127.0.0.1/tcp/5001", Gateway: "/ip4/127.0.0.1/tcp/8080" } }; + expect((await kuboRpcUrlFromKuboConfig(config))?.toString()).toBe("http://127.0.0.1:5001/api/v0"); + expect((await ipfsGatewayUrlFromKuboConfig(config))?.toString()).toBe("http://127.0.0.1:8080/"); + }); + + it("takes the first usable TCP entry of an array, since kubo also accepts a list", async () => { + const config = { Addresses: { API: ["/unix/tmp/kubo.sock", "/ip4/127.0.0.1/tcp/5001", "/ip4/127.0.0.1/tcp/5002"] } }; + expect((await kuboRpcUrlFromKuboConfig(config))?.toString()).toBe("http://127.0.0.1:5001/api/v0"); + }); + + it.each([ + ["no config", undefined], + ["no Addresses", {}], + ["empty string", { Addresses: { API: "", Gateway: "" } }], + ["empty array", { Addresses: { API: [], Gateway: [] } }], + ["unix socket only", { Addresses: { API: "/unix/tmp/kubo.sock", Gateway: "/unix/tmp/gw.sock" } }] + ])("returns undefined so the caller falls back to defaults: %s", async (_label, config) => { + expect(await kuboRpcUrlFromKuboConfig(config)).toBeUndefined(); + expect(await ipfsGatewayUrlFromKuboConfig(config)).toBeUndefined(); + }); +}); + +// A URL keeps IPv6 hosts in brackets, but sockets need them bare. +describe("toConnectableHostname", () => { + it.each([ + ["0.0.0.0", "127.0.0.1"], + ["127.0.0.1", "127.0.0.1"], + ["localhost", "localhost"], + ["::", "::1"], + ["[::]", "::1"], + ["[::1]", "::1"], + ["::1", "::1"], + ["[2001:db8::7]", "2001:db8::7"] + ])("%s connects via %s", (hostname, expected) => { + expect(toConnectableHostname(hostname)).toBe(expected); + }); + + it("gives a hostname a socket can connect to from an IPv6 kubo config", async () => { + const url = await kuboRpcUrlFromKuboConfig({ Addresses: { API: "/ip6/::1/tcp/5001" } }); + expect(toConnectableHostname(url!.hostname)).toBe("::1"); + }); +}); diff --git a/test/reachability/caddy-pebble.integration.test.ts b/test/reachability/caddy-pebble.integration.test.ts new file mode 100644 index 00000000..47ca5bd0 --- /dev/null +++ b/test/reachability/caddy-pebble.integration.test.ts @@ -0,0 +1,159 @@ +// The real chain, on every OS CI runs: Caddy downloaded and hash-checked for this platform, a certificate +// for 127.0.0.1 from Pebble, and a js-libp2p client completing its handshake with kubo through Caddy. +import { execFile, spawn, type ChildProcess } from "child_process"; +import { X509Certificate } from "crypto"; +import fs from "fs/promises"; +import path from "path"; +import { promisify } from "util"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { directory as randomDirectory } from "tempy"; +import { path as resolveKuboBinary } from "kubo"; +import { allocateFreePort, killChildProcess, waitForCondition } from "../helpers/daemon-helpers.js"; +import { preInitKuboWithEphemeralSwarm } from "../helpers/kubo-helpers.js"; +import { startPebble, type RunningPebble } from "../helpers/pebble.js"; +import { caddyfilePath, ensureCaddyBinary, generateCaddyfile, reloadCaddy, startCaddy, stopCaddy, waitForCertificates } from "../../src/reachability/caddy.js"; +import { computeKuboConfig } from "../../src/reachability/kubo-config.js"; +import { caddyDataDir, readIssuedCertificates, reachabilityDir } from "../../src/reachability/state.js"; + +const execFileAsync = promisify(execFile); + +// A separate process, because NODE_EXTRA_CA_CERTS is read at startup and this is the way to make the +// global WebSocket that libp2p uses trust Pebble. It runs the same dial code setup will run. +const dialInChildProcess = async (address: string, issuingRootPath?: string): Promise<{ peerId?: string; error?: string }> => { + const script = ` + import { dialWithLibp2p } from "./dist/reachability/libp2p-dial.js"; + try { console.log(JSON.stringify(await dialWithLibp2p(process.argv[1]))); } + catch (error) { console.log(JSON.stringify({ error: String(error.message) })); } + process.exit(0);`; + const { NODE_EXTRA_CA_CERTS: _unset, ...baseEnv } = process.env; + const { stdout } = await execFileAsync(process.execPath, ["--input-type=module", "-e", script, address], { + env: { ...baseEnv, ...(issuingRootPath ? { NODE_EXTRA_CA_CERTS: issuingRootPath } : {}) }, + timeout: 60_000 + }); + return JSON.parse(stdout.trim().split("\n").at(-1)!); +}; + +describe("Caddy, Pebble and kubo", { timeout: 240_000 }, () => { + let pebble: RunningPebble; + let caddy: ChildProcess | undefined; + let kubo: ChildProcess | undefined; + let dataPath: string; + let adminPort: number; + let httpsPort: number; + let wsPort: number; + let kuboApiUrl: string; + let peerId: string; + let caddyOutput = ""; + let caddyfile: string; + + beforeAll(async () => { + dataPath = randomDirectory(); + pebble = await startPebble(randomDirectory()); + [adminPort, httpsPort, wsPort] = await Promise.all([allocateFreePort(), allocateFreePort("0.0.0.0"), allocateFreePort()]); + const [apiPort, gatewayPort] = await Promise.all([allocateFreePort(), allocateFreePort()]); + kuboApiUrl = `http://127.0.0.1:${apiPort}/api/v0`; + + const repo = path.join(dataPath, ".bitsocial-cli.ipfs"); + await preInitKuboWithEphemeralSwarm(repo, new URL(kuboApiUrl), new URL(`http://127.0.0.1:${gatewayPort}`)); + const configPath = path.join(repo, "config"); + const edited = computeKuboConfig(JSON.parse(await fs.readFile(configPath, "utf-8")), { wsPort, announce: [] }, true); + // Only the loopback ws listener: the shared UDP 443 ones cannot bind unprivileged on Linux CI. + edited.Addresses.Swarm = [`/ip4/127.0.0.1/tcp/${wsPort}/ws`]; + await fs.writeFile(configPath, JSON.stringify(edited, null, 4)); + peerId = edited.Identity.PeerID; + kubo = spawn(await resolveKuboBinary(), ["daemon"], { env: { ...process.env, IPFS_PATH: repo }, stdio: "ignore" }); + const kuboUp = await waitForCondition(async () => { + try { + return (await fetch(`${kuboApiUrl}/id`, { method: "POST" })).ok; + } catch { + return false; + } + }, 60_000, 250); + expect(kuboUp, "kubo did not start").toBe(true); + + const binaryPath = await ensureCaddyBinary(dataPath); + caddyfile = generateCaddyfile({ + ips: ["127.0.0.1"], + domains: [], + wsPort, + adminPort, + httpsPort, + httpPort: await allocateFreePort("0.0.0.0"), + dataDirectory: caddyDataDir(dataPath), + acmeDirectory: pebble.directoryUrl, + acmeTrustedRootsPath: pebble.listenerRootPath + }); + await fs.mkdir(reachabilityDir(dataPath), { recursive: true }); + await fs.writeFile(caddyfilePath(dataPath), caddyfile); + caddy = startCaddy(dataPath, binaryPath, (line) => (caddyOutput += line)); + }, 240_000); + + afterAll(async () => { + if (adminPort) await stopCaddy(adminPort).catch(() => {}); + await killChildProcess(caddy); + if (kuboApiUrl) await fetch(`${kuboApiUrl}/shutdown`, { method: "POST" }).catch(() => {}); + await killChildProcess(kubo); + await pebble?.stop(); + }, 60_000); + + it("issues a shortlived certificate covering 127.0.0.1", async () => { + const result = await waitForCertificates(dataPath, pebble.directoryUrl, ["127.0.0.1"], 60_000); + expect(result.missing, caddyOutput).toEqual([]); + const [certificate] = await readIssuedCertificates(dataPath); + expect(certificate!.names).toContain("127.0.0.1"); + expect(certificate!.hoursLeft).toBeGreaterThan(100); + expect(certificate!.hoursLeft).toBeLessThanOrEqual(160); + }); + + it("does not count that certificate for a different CA", async () => { + const result = await waitForCertificates(dataPath, "https://acme-staging-v02.api.letsencrypt.org/directory", ["127.0.0.1"], 0); + expect(result).toEqual({ issued: [], missing: ["127.0.0.1"] }); + }); + + it("stored a parseable certificate, not only the status summary", async () => { + const entries = await fs.readdir(path.join(caddyDataDir(dataPath), "certificates"), { recursive: true, withFileTypes: true }); + const file = entries.find((entry) => entry.name.endsWith(".crt"))!; + const certificate = new X509Certificate(await fs.readFile(path.join(file.parentPath, file.name))); + expect(certificate.checkIP("127.0.0.1")).toBe("127.0.0.1"); + }); + + it("completes a js-libp2p handshake with kubo through Caddy", async () => { + const result = await dialInChildProcess(`/ip4/127.0.0.1/tcp/${httpsPort}/tls/ws/p2p/${peerId}`, pebble.issuingRootPath); + expect(result, caddyOutput).toEqual({ peerId }); + }); + + it("refuses the dial when the certificate's CA is not trusted", async () => { + const result = await dialInChildProcess(`/ip4/127.0.0.1/tcp/${httpsPort}/tls/ws/p2p/${peerId}`); + expect(result.error).toBeTruthy(); + }); + + it("refuses the dial when the node is not the peer we expected", async () => { + const someoneElse = "12D3KooWHFrmLWTTDD4NodngtRMEVYgxrLDNS3uwHkpTKm5PkvY7"; + const result = await dialInChildProcess(`/ip4/127.0.0.1/tcp/${httpsPort}/tls/ws/p2p/${someoneElse}`, pebble.issuingRootPath); + expect(result.error).toBeTruthy(); + }); + + it("binds no UDP socket on the HTTPS port, which kubo needs for WebTransport", async () => { + const dgram = await import("dgram"); + const socket = dgram.createSocket("udp4"); + await new Promise((resolve, reject) => { + socket.once("error", reject); + socket.bind(httpsPort, "0.0.0.0", () => resolve()); + }); + await new Promise((resolve) => socket.close(() => resolve())); + }); + + it("reloads through the admin API without re-issuing", async () => { + const before = await readIssuedCertificates(dataPath); + await reloadCaddy(adminPort, caddyfile); + expect(await readIssuedCertificates(dataPath)).toEqual(before); + const result = await dialInChildProcess(`/ip4/127.0.0.1/tcp/${httpsPort}/tls/ws/p2p/${peerId}`, pebble.issuingRootPath); + expect(result, caddyOutput).toEqual({ peerId }); + }); + + it("stops through the admin API", async () => { + await stopCaddy(adminPort); + const exited = await waitForCondition(() => caddy!.exitCode !== null || caddy!.signalCode !== null, 15_000, 100); + expect(exited).toBe(true); + }); +}); diff --git a/test/reachability/caddy.test.ts b/test/reachability/caddy.test.ts new file mode 100644 index 00000000..02cb924c --- /dev/null +++ b/test/reachability/caddy.test.ts @@ -0,0 +1,143 @@ +import fs from "fs/promises"; +import { describe, expect, it, vi } from "vitest"; +import { directory as randomDirectory } from "tempy"; +import { + CADDY_RELEASE_SHA256, + caddyBinaryPath, + caddyReleaseFor, + ensureCaddyBinary, + generateCaddyfile, + LETS_ENCRYPT_DIRECTORY, + type CaddyfileSpec +} from "../../src/reachability/caddy.js"; + +const spec = (overrides: Partial = {}): CaddyfileSpec => ({ + ips: ["203.0.113.7", "2001:db8::7"], + domains: [], + wsPort: 4002, + adminPort: 2019, + dataDirectory: "/data/reachability/caddy-data", + acmeDirectory: LETS_ENCRYPT_DIRECTORY, + ...overrides +}); + +describe("caddy release pinning", () => { + it("pins a full sha256 for each platform we ship on", () => { + expect(Object.keys(CADDY_RELEASE_SHA256).sort()).toEqual(["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "win32-arm64", "win32-x64"]); + for (const sha256 of Object.values(CADDY_RELEASE_SHA256)) expect(sha256).toMatch(/^[0-9a-f]{64}$/); + }); + + it.each([ + ["linux", "x64", /caddy_[\d.]+_linux_amd64\.tar\.gz$/, "caddy"], + ["darwin", "arm64", /caddy_[\d.]+_mac_arm64\.tar\.gz$/, "caddy"], + ["win32", "x64", /caddy_[\d.]+_windows_amd64\.zip$/, "caddy.exe"] + ])("maps %s-%s to the release file", (platform, arch, pattern, binaryName) => { + const release = caddyReleaseFor(platform, arch); + expect(release.url).toMatch(pattern); + expect(release.url.startsWith("https://github.com/caddyserver/caddy/releases/download/")).toBe(true); + expect(release.binaryName).toBe(binaryName); + }); + + it("names the supported list for an unsupported platform", () => { + expect(() => caddyReleaseFor("freebsd", "x64")).toThrow(/freebsd-x64.*linux-x64/); + }); +}); + +describe("ensureCaddyBinary", () => { + it("refuses a download whose hash does not match and leaves nothing runnable", async () => { + const dataPath = randomDirectory(); + const fetchImpl = vi.fn(async () => new Response("not caddy")) as unknown as typeof fetch; + await expect(ensureCaddyBinary(dataPath, { fetchImpl })).rejects.toThrow(/failed verification/); + await expect(fs.access(caddyBinaryPath(dataPath))).rejects.toThrow(); + }); + + it("does not retry a hash mismatch", async () => { + const fetchImpl = vi.fn(async () => new Response("not caddy")) as unknown as typeof fetch; + await expect(ensureCaddyBinary(randomDirectory(), { fetchImpl })).rejects.toThrow(); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("does not download again once the binary exists", async () => { + const dataPath = randomDirectory(); + await fs.mkdir(caddyBinaryPath(dataPath).replace(/[^/\\]+$/, ""), { recursive: true }); + await fs.writeFile(caddyBinaryPath(dataPath), ""); + const fetchImpl = vi.fn() as unknown as typeof fetch; + expect(await ensureCaddyBinary(dataPath, { fetchImpl })).toBe(caddyBinaryPath(dataPath)); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); + +describe("generateCaddyfile", () => { + it("puts both IPs under one site with the shortlived profile", () => { + const output = generateCaddyfile(spec()); + expect(output).toContain("https://203.0.113.7, https://[2001:db8::7] {"); + expect(output).toContain("profile shortlived"); + expect(output).toContain("reverse_proxy 127.0.0.1:4002"); + }); + + it("gives a domain a normal certificate, without the shortlived profile", () => { + const output = generateCaddyfile(spec({ ips: [], domains: ["node.example"] })); + expect(output).toContain("https://node.example {"); + expect(output).not.toContain("profile"); + }); + + it("emits IP and domain sites together", () => { + const output = generateCaddyfile(spec({ domains: ["node.example"] })); + expect(output).toContain("https://203.0.113.7, https://[2001:db8::7] {"); + expect(output).toContain("https://node.example {"); + }); + + it("never enables HTTP/3, which would take kubo's UDP 443", () => { + expect(generateCaddyfile(spec())).toMatch(/protocols h1 h2\n/); + }); + + it("never binds port 80 for redirects", () => { + expect(generateCaddyfile(spec())).toContain("auto_https disable_redirects"); + }); + + it("keeps the admin API on loopback and asks for no email", () => { + const output = generateCaddyfile(spec()); + expect(output).toContain("admin 127.0.0.1:2019"); + expect(output).not.toContain("email"); + }); + + it("prefers the IPv4 address as default_sni, whatever the order", () => { + expect(generateCaddyfile(spec({ ips: ["2001:db8::7", "203.0.113.7"] }))).toContain("default_sni 203.0.113.7"); + expect(generateCaddyfile(spec({ ips: ["2001:db8::7"] }))).toContain("default_sni 2001:db8::7"); + }); + + it("uses the given ACME directory", () => { + expect(generateCaddyfile(spec({ acmeDirectory: "https://127.0.0.1:1/dir" }))).toContain("dir https://127.0.0.1:1/dir"); + }); + + it("quotes data paths and normalises Windows separators", () => { + const output = generateCaddyfile(spec({ dataDirectory: "C:\\Users\\some one\\caddy-data" })); + expect(output).toContain('storage file_system "C:/Users/some one/caddy-data"'); + }); + + it.each([ + "node.example {\n\tmalicious\n}", + "node.example other.example", + "node.example\nadmin 0.0.0.0:2019", + "*.example.com", + "localhost", + "-bad.example", + "", + "a".repeat(254) + ".example" + ])("rejects %j as a domain", (domain) => { + expect(() => generateCaddyfile(spec({ domains: [domain] }))).toThrow(/Not a valid domain/); + }); + + it("rejects a non-IP in the IP list", () => { + expect(() => generateCaddyfile(spec({ ips: ["203.0.113.7 { }"] }))).toThrow(/Not an IP/); + }); + + it("rejects a path that would break out of its quotes", () => { + expect(() => generateCaddyfile(spec({ dataDirectory: 'x"\nadmin off' }))).toThrow(/cannot be used/); + }); + + it("rejects invalid ports", () => { + expect(() => generateCaddyfile(spec({ wsPort: 0 }))).toThrow(/wsPort/); + expect(() => generateCaddyfile(spec({ adminPort: 70000 }))).toThrow(/adminPort/); + }); +}); diff --git a/test/reachability/discovery.test.ts b/test/reachability/discovery.test.ts new file mode 100644 index 00000000..944b4e96 --- /dev/null +++ b/test/reachability/discovery.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { + classifyPortHolder, + detectContainer, + isCgnatIp, + isPublicIp, + lanIpv4FromInterfaces, + parseLsofHolder, + parseNetstatPid, + parseSsHolder, + publicIpsFromInterfaces, + publicIpsFromMultiaddrs +} from "../../src/reachability/discovery.js"; + +const iface = (address: string, internal = false) => ({ address, internal, family: address.includes(":") ? "IPv6" : "IPv4" }) as any; + +describe("isPublicIp", () => { + it.each(["1.1.1.1", "8.8.8.8", "93.184.216.34", "2606:4700:4700::1111"])("%s is public", (ip) => expect(isPublicIp(ip)).toBe(true)); + it.each([ + "10.0.0.1", + "172.16.0.1", + "192.168.1.1", + "127.0.0.1", + "169.254.1.1", + "100.64.0.1", + "203.0.113.7", + "::1", + "fe80::1", + "fc00::1", + "2001:db8::1", + "not an ip" + ])("%s is not public", (ip) => expect(isPublicIp(ip)).toBe(false)); +}); + +it("isCgnatIp recognises only 100.64.0.0/10", () => { + expect(isCgnatIp("100.64.0.1")).toBe(true); + expect(isCgnatIp("100.127.255.255")).toBe(true); + expect(isCgnatIp("100.128.0.1")).toBe(false); + expect(isCgnatIp("2606:4700::1")).toBe(false); +}); + +describe("interface discovery", () => { + const interfaces = { lo: [iface("127.0.0.1", true)], eth0: [iface("192.168.1.5"), iface("93.184.216.34"), iface("2606:4700::1"), iface("fe80::1")] }; + + it("finds only public addresses", () => { + expect(publicIpsFromInterfaces(interfaces)).toEqual(["93.184.216.34", "2606:4700::1"]); + }); + + it("finds the LAN address to map the router to", () => { + expect(lanIpv4FromInterfaces(interfaces)).toBe("192.168.1.5"); + }); +}); + +it("publicIpsFromMultiaddrs keeps public IPs, skips private, dns and garbage", () => { + expect( + publicIpsFromMultiaddrs([ + "/ip4/93.184.216.34/tcp/4001/p2p/12D3KooWHFrmLWTTDD4NodngtRMEVYgxrLDNS3uwHkpTKm5PkvY7", + "/ip4/93.184.216.34/udp/4001/quic-v1", + "/ip4/192.168.1.5/tcp/4001", + "/dns4/node.example/tcp/443/tls/ws", + "/ip6/2606:4700::1/tcp/4001", + "garbage" + ]) + ).toEqual(["93.184.216.34", "2606:4700::1"]); +}); + +describe("port holder parsing", () => { + it("reads ss output", () => { + expect(parseSsHolder('LISTEN 0 511 0.0.0.0:443 0.0.0.0:* users:(("nginx",pid=1,fd=6))')).toBe("nginx"); + expect(parseSsHolder("LISTEN 0 511 0.0.0.0:443 0.0.0.0:*")).toBeUndefined(); + }); + + it("reads lsof -Fc output", () => { + expect(parseLsofHolder("p123\ncnginx\nf6\n")).toBe("nginx"); + }); + + it("reads the listening pid from netstat", () => { + const output = " TCP 0.0.0.0:80 0.0.0.0:0 LISTENING 100\r\n TCP 0.0.0.0:443 0.0.0.0:0 LISTENING 4\r\n TCP 1.2.3.4:443 5.6.7.8:9 ESTABLISHED 7\r\n"; + expect(parseNetstatPid(output, 443)).toBe("4"); + expect(parseNetstatPid(output, 8443)).toBeUndefined(); + }); + + it.each([ + ["nginx", "nginx"], + ["nginx: worker", "nginx"], + ["apache2", "apache"], + ["httpd.exe", "apache"], + ["caddy", "caddy"], + ["System", "iis"], + ["w3wp.exe", "iis"], + ["node", "other"] + ])("classifies %s as %s", (name, holder) => expect(classifyPortHolder(name)).toBe(holder)); + + it("stays unknown without a name", () => expect(classifyPortHolder(undefined)).toBeUndefined()); +}); + +describe.runIf(process.platform === "linux")("detectContainer", () => { + const reader = (files: Record) => async (path: string) => { + if (path in files) return files[path]!; + throw new Error("ENOENT"); + }; + it("sees /.dockerenv", async () => expect(await detectContainer(reader({ "/.dockerenv": "" }))).toBe(true)); + it("sees a container cgroup", async () => expect(await detectContainer(reader({ "/proc/self/cgroup": "0::/docker/abc" }))).toBe(true)); + it("sees a normal host", async () => expect(await detectContainer(reader({ "/proc/self/cgroup": "0::/user.slice/session.scope" }))).toBe(false)); +}); diff --git a/test/reachability/kubo-config.test.ts b/test/reachability/kubo-config.test.ts new file mode 100644 index 00000000..cf7403be --- /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/setup.test.ts b/test/reachability/setup.test.ts new file mode 100644 index 00000000..4f78af83 --- /dev/null +++ b/test/reachability/setup.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { LETS_ENCRYPT_DIRECTORY, LETS_ENCRYPT_STAGING_DIRECTORY } from "../../src/reachability/caddy.js"; +import { bindDeniedGuidance, busyPortMessage, testCommandHint, unreachableStatement, webTransportNotListeningGuidance } from "../../src/reachability/guidance.js"; +import { assertAcmeDirectoryAllowed, chooseAcmeDirectory, deriveMode, discoverPublicIps, type SetupDependencies, type SetupOptions } from "../../src/reachability/setup.js"; + +describe("ACME directory", () => { + it("is production by default and staging on request", () => { + expect(chooseAcmeDirectory({ staging: false })).toBe(LETS_ENCRYPT_DIRECTORY); + expect(chooseAcmeDirectory({ staging: true })).toBe(LETS_ENCRYPT_STAGING_DIRECTORY); + }); + + it("refuses production Let's Encrypt from CI", () => { + expect(() => assertAcmeDirectoryAllowed(LETS_ENCRYPT_DIRECTORY, true)).toThrow(/from CI/); + }); + + it("allows staging and Pebble from CI, and production elsewhere", () => { + expect(() => assertAcmeDirectoryAllowed(LETS_ENCRYPT_STAGING_DIRECTORY, true)).not.toThrow(); + expect(() => assertAcmeDirectoryAllowed("https://127.0.0.1:1/dir", true)).not.toThrow(); + expect(() => assertAcmeDirectoryAllowed(LETS_ENCRYPT_DIRECTORY, false)).not.toThrow(); + }); +}); + +it.each([ + [{ ip: true, domain: true, proxy: false }, "ip+domain"], + [{ ip: true, domain: false, proxy: true }, "ip+reverse-proxy"], + [{ ip: true, domain: false, proxy: false }, "ip"], + [{ ip: false, domain: true, proxy: false }, "domain"], + [{ ip: false, domain: false, proxy: true }, "reverse-proxy"], + [{ ip: false, domain: false, proxy: false }, "webtransport-only"] +] as const)("deriveMode(%j) is %s", (parts, mode) => expect(deriveMode(parts)).toBe(mode)); + +describe("per-OS guidance", () => { + it("never suggests sudo inside a container", () => { + const text = bindDeniedGuidance({ inContainer: true, caddyPath: "/x/caddy", platform: "linux" }); + expect(text).toContain("NET_BIND_SERVICE"); + expect(text).not.toContain("sudo"); + }); + + it("offers setcap and the sysctl on a Linux host", () => { + const text = bindDeniedGuidance({ inContainer: false, caddyPath: "/x/caddy", platform: "linux" }); + expect(text).toContain('setcap cap_net_bind_service=+ep "/x/caddy"'); + expect(text).toContain("net.ipv4.ip_unprivileged_port_start"); + }); + + it("tells a Linux operator that kubo needs the permission too", () => { + const text = webTransportNotListeningGuidance({ inContainer: false, kuboPath: "/x/ipfs", platform: "linux" }); + expect(text).toContain('setcap cap_net_bind_service=+ep "/x/ipfs"'); + expect(text).toContain("net.ipv4.ip_unprivileged_port_start"); + }); + + it("prints a test command with the right URL for each address family and port", () => { + expect(testCommandHint("93.184.216.34", 443)).toContain("https://93.184.216.34/"); + expect(testCommandHint("2606:4700::1", 443)).toContain("https://[2606:4700::1]/"); + expect(testCommandHint("127.0.0.1", 8443)).toContain("https://127.0.0.1:8443/"); + }); + + it("names HTTP.sys holding 443", () => expect(busyPortMessage("iis")).toContain("HTTP.sys")); + + it("states what would change an unreachable node", () => { + expect(unreachableStatement("behind CGNAT")).toMatch(/Neither WSS nor WebTransport.*behind CGNAT/); + }); +}); + +describe("discoverPublicIps", () => { + const options = { kuboRpcUrl: new URL("http://127.0.0.1:1/api/v0"), dataPath: "", kuboRepoPath: "", staging: false, nonInteractive: true } as SetupOptions; + const deps = (overrides: Partial) => + ({ log: () => {}, warn: () => {}, publicIpsFromInterfaces: () => [], lanIpv4: () => "192.168.1.5", discoverRouter: async () => undefined, ...overrides }) as SetupDependencies; + const router = (externalIp: string, refused: string[] = []) => ({ + externalIp: async () => externalIp, + mapPorts: async () => ({ mapped: ["TCP 443"], refused }), + release: async () => {} + }); + + it("skips the router when an interface already holds a public IPv4", async () => { + let asked = false; + const result = await discoverPublicIps(options, deps({ publicIpsFromInterfaces: () => ["93.184.216.34"], discoverRouter: async () => ((asked = true), undefined) })); + expect(result.publicIps).toEqual(["93.184.216.34"]); + expect(asked).toBe(false); + }); + + it("uses the router's external IP behind NAT", async () => { + const result = await discoverPublicIps(options, deps({ discoverRouter: async () => router("93.184.216.34") })); + expect(result.publicIps).toEqual(["93.184.216.34"]); + }); + + it("recognises CGNAT and does not treat it as public", async () => { + const result = await discoverPublicIps(options, deps({ discoverRouter: async () => router("100.64.1.1") })); + expect(result).toMatchObject({ publicIps: [], sawCgnat: true }); + }); + + it("reports ports the router refused", async () => { + const result = await discoverPublicIps(options, deps({ discoverRouter: async () => router("93.184.216.34", ["TCP 80"]) })); + expect(result.refused).toEqual(["TCP 80"]); + }); + + it("still checks the router for IPv4 on an IPv6-only interface", async () => { + const result = await discoverPublicIps(options, deps({ publicIpsFromInterfaces: () => ["2606:4700::1"], discoverRouter: async () => router("93.184.216.34") })); + expect(result.publicIps.sort()).toEqual(["2606:4700::1", "93.184.216.34"]); + }); + + it("finds nothing with no public IP and no router", async () => { + expect((await discoverPublicIps(options, deps({}))).publicIps).toEqual([]); + }); +}); diff --git a/test/reachability/state.test.ts b/test/reachability/state.test.ts new file mode 100644 index 00000000..c0f134ca --- /dev/null +++ b/test/reachability/state.test.ts @@ -0,0 +1,217 @@ +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", + caddyAdminPort: 2019 +}); + +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 }], + ["caddyAdminPort missing", { caddyAdminPort: 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(); + }); +});