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/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index 9368b497..be6a9c7b 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -11,8 +11,12 @@ import { PKCLogger, setupDebugLogger, loadKuboConfigFile, - parseMultiAddrKuboRpcToUrl, - parseMultiAddrIpfsGatewayToUrl + kuboRpcUrlFromKuboConfig, + ipfsGatewayUrlFromKuboConfig, + hostForUrl, + toConnectableHostname, + resolveKuboRepoPath, + describeKuboEndpointMismatch } from "../../util.js"; import type { PKCLoggerType } from "../../util.js"; import { startDaemonServer } from "../../webui/daemon-server.js"; @@ -26,14 +30,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 +85,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 +324,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 }; @@ -374,6 +370,7 @@ export default class Daemon extends Command { // Every kubo we've spawned that hasn't exited yet. Exit cleanup kills all of these, // so a kubo that slipped out of kuboProcess tracking still dies with the daemon (issue #70) const liveKuboPids = new Set(); + let warnedAboutKuboConfigMismatch = false; const keepKuboUpOnce = async () => { if (mainProcessExited) return; const kuboApiPort = Number(kuboRpcEndpoint.port); @@ -392,8 +389,11 @@ export default class Daemon extends Command { if (mainProcessExited || kuboProcess || pendingKuboStart) return; if (isKuboApiPortTaken) { const connectableEndpoint = new URL(kuboRpcEndpoint.toString()); - connectableEndpoint.hostname = connectHostname; - const versionUrl = new URL("version", connectableEndpoint); + connectableEndpoint.hostname = hostForUrl(connectHostname); + // Relative to ".../api/v0" a bare "version" resolves to "/api/version", which kubo + // answers 404, so a healthy kubo looked like a foreign listener and startup failed. + const versionPath = `${connectableEndpoint.pathname.replace(/\/$/, "")}/version`; + const versionUrl = new URL(versionPath, connectableEndpoint); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 2000); let isHealthyKubo = false; @@ -417,6 +417,34 @@ export default class Daemon extends Command { }:${kuboApiPort} (configured as ${kuboRpcEndpoint.toString()}) is already in use.` ); } + // The daemon starts kubo from the repo's config, which it never rewrites for an existing + // repo, so an env override naming other ports than Addresses.* is dialled but never served. + if (!warnedAboutKuboConfigMismatch) { + warnedAboutKuboConfigMismatch = true; + const kuboConfigPath = path.join(resolveKuboRepoPath(mergedPkcOptions.dataPath!), "config"); + const mismatches = [ + process.env["KUBO_RPC_URL"] && !pkcOptionsFromFlag?.kuboRpcClientsOptions + ? await describeKuboEndpointMismatch({ + envVar: "KUBO_RPC_URL", + configField: "Addresses.API", + configPath: kuboConfigPath, + configured: ipfsConfig?.Addresses?.API, + chosen: kuboRpcEndpoint + }) + : undefined, + process.env["IPFS_GATEWAY_URL"] && !pkcOptionsFromFlag?.ipfsGatewayUrls + ? await describeKuboEndpointMismatch({ + envVar: "IPFS_GATEWAY_URL", + configField: "Addresses.Gateway", + configPath: kuboConfigPath, + configured: ipfsConfig?.Addresses?.Gateway, + chosen: ipfsGatewayEndpoint + }) + : undefined + ]; + // stdout: the daemon routes stderr to its log file only, and this is for the operator. + for (const message of mismatches) if (message) console.log(`Warning: ${message}`); + } let spawnedProcess: ChildProcessWithoutNullStreams | undefined; const startPromise = startKuboNode( kuboRpcEndpoint, diff --git a/src/ipfs/startIpfs.ts b/src/ipfs/startIpfs.ts index 280c58e5..775903e9 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) @@ -281,7 +281,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"); diff --git a/src/util.ts b/src/util.ts index d84886f4..f6e903eb 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,6 +1,7 @@ import os from "os"; import path from "path"; import fs from "fs"; +import net from "net"; import * as fsPromises from "fs/promises"; import stripJsonComments from "strip-json-comments"; import PKCLogger from "@pkcprotocol/pkc-logger"; @@ -53,8 +54,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; @@ -77,18 +84,120 @@ 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 or a client URL. 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(toConnectableHostname(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(toConnectableHostname(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); +} + +const urlPort = (url: URL) => Number(url.port || (url.protocol === "https:" ? 443 : 80)); + +/** Every usable listener in a kubo Addresses.API / Addresses.Gateway value (string or list), with + * the host exactly as bound. Empty entries, /unix sockets and unparsable entries are skipped. */ +async function kuboListenersFromAddresses(addresses: unknown): Promise<{ host: string; port: number }[]> { + const listeners: { host: string; port: number }[] = []; + for (const address of Array.isArray(addresses) ? addresses : [addresses]) { + if (typeof address !== "string" || !address) continue; + try { + const parsed = multiAddrToHostPort(await parseMultiAddr(address)); + if (parsed) listeners.push(parsed); + } catch { + continue; + } + } + return listeners; +} + +const isWildcardBind = (host: string) => host === "0.0.0.0" || host === "::"; + +const isLoopbackLiteral = (host: string) => host === "127.0.0.1" || host === "::1"; + +/** Whether two connectable hostnames reach the same listener. `localhost` resolves to either loopback + * literal and the connect falls back across families, so it matches both; `127.0.0.1` and `::1` are + * different listeners, since a kubo bound on one refuses a dial to the other. */ +const sameConnectableHost = (a: string, b: string) => + a === b || (a === "localhost" && isLoopbackLiteral(b)) || (b === "localhost" && isLoopbackLiteral(a)); + +/** For a locally managed kubo the daemon never rewrites Addresses.* of an existing repo, so an env + * override that differs from the config leaves kubo on one port and the daemon dialling another. + * `configured` is the raw Addresses.API / Addresses.Gateway value, since kubo listens on every entry + * of a list and a wildcard bind is reachable on any host. Returns the warning to print, or undefined + * when the override reaches a configured listener or that cannot be judged offline. */ +export async function describeKuboEndpointMismatch({ + envVar, + configField, + configPath, + configured, + chosen +}: { + envVar: string; + configField: string; + configPath: string; + configured: unknown; + chosen: URL; +}): Promise { + const listeners = await kuboListenersFromAddresses(configured); + if (listeners.length === 0) return undefined; + const chosenHost = toConnectableHostname(chosen.hostname); + // A DNS name other than localhost needs a lookup to compare; stay quiet rather than guess. + if (chosenHost !== "localhost" && net.isIP(chosenHost) === 0) return undefined; + // Kubo serves its RPC and gateway over plain http, so an https override never reaches it. + const reachable = + chosen.protocol === "http:" && + listeners.some( + (listener) => + listener.port === urlPort(chosen) && (isWildcardBind(listener.host) || sameConnectableHost(listener.host, chosenHost)) + ); + if (reachable) return undefined; + const configuredText = typeof configured === "string" ? configured : JSON.stringify(configured); + return ( + `${envVar} is ${chosen} but ${configField} in ${configPath} is ${configuredText}. ` + + `The daemon starts kubo from that config and dials ${envVar}, so it will not reach kubo. ` + + `Unset ${envVar} to follow the config, or edit ${configField} to match.` + ); } /** 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..94f09573 --- /dev/null +++ b/test/cli/daemon-honours-kubo-config-addresses.test.ts @@ -0,0 +1,256 @@ +// Regression tests 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. +// +// "Communities in data path" is printed whether or not pkc-js can reach kubo, so readiness and the +// printed options prove nothing about which kubo the daemon uses. Each test therefore creates a +// community through the daemon: pkc-js imports the started community's signer into kubo as an IPNS +// key named after the community address (`key/import`), so the key list of a kubo says whether the +// daemon talked to it. +import { spawn, type ChildProcess } from "child_process"; +import dns from "node:dns"; +import path from "path"; +import { afterEach, describe, expect, it } from "vitest"; +import { directory as randomDirectory } from "tempy"; +import { path as resolveKuboBinary } from "kubo"; +import defaults from "../../src/common-utils/defaults.js"; +import { ensureIpnsPubsubEnabled, ensureKuboRoutingConfigMatchesHttpRouters } from "../../src/ipfs/startIpfs.js"; +import { + allocateFreePort, + ensureKuboNodeStopped, + isAddressInUseError, + killChildProcess, + waitForCondition, + waitForKuboReady, + withKuboBindRetry +} from "../helpers/daemon-helpers.js"; +import { preInitKuboWithEphemeralSwarm } from "../helpers/kubo-helpers.js"; + +dns.setDefaultResultOrder("ipv4first"); // to be able to resolve localhost + +const READY_TIMEOUT_MS = 90_000; + +const runCli = (args: string[], timeoutMs: number): Promise<{ stdout: string; stderr: string; exitCode: number | null }> => + new Promise((resolve, reject) => { + const proc = spawn("node", ["./bin/run", ...args], { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (data: Buffer) => (stdout += data.toString())); + proc.stderr.on("data", (data: Buffer) => (stderr += data.toString())); + const timer = setTimeout(() => { + proc.kill("SIGKILL"); + reject(new Error(`Command timed out after ${timeoutMs}ms: bitsocial ${args.join(" ")}\nstdout: ${stdout}\nstderr: ${stderr}`)); + }, timeoutMs); + proc.on("close", (exitCode) => { + clearTimeout(timer); + resolve({ stdout, stderr, exitCode }); + }); + }); + +// `community create` starts the community and prints its address last. +const createCommunityThroughDaemon = async (rpcWsUrl: string): Promise => { + const result = await runCli(["community", "create", "--description", "issue 200 regression", "--pkcRpcUrl", rpcWsUrl], 90_000); + expect(result.exitCode, `stderr: ${result.stderr}\nstdout: ${result.stdout}`).toBe(0); + const address = result.stdout.trim().split("\n").pop() ?? ""; + expect(address, `community create printed no address:\n${result.stdout}`).not.toBe(""); + return address; +}; + +const kuboKeyNames = async (kuboApiUrl: string): Promise => { + const response = await fetch(`${kuboApiUrl}/key/list`, { method: "POST" }); + expect(response.ok, `${kuboApiUrl}/key/list returned HTTP ${response.status}`).toBe(true); + const body = (await response.json()) as { Keys: { Name: string }[] }; + return body.Keys.map((key) => key.Name); +}; + +const isKuboListening = async (kuboApiUrl: string): Promise => { + try { + return (await fetch(`${kuboApiUrl}/version`, { method: "POST" })).ok; + } catch { + return false; + } +}; + +type DaemonHandle = { + dataPath: string; + rpcWsUrl: string; + configApiPort: number; + configGatewayPort: number; + // Client-side URL of the kubo described by the repo's config, reachable whatever host it binds. + configKuboApiUrl: string; + output: () => string; + waitForReady: () => Promise; +}; + +describe("kubo endpoint precedence (issue #200)", () => { + let daemon: ChildProcess | undefined; + let standaloneKubo: ChildProcess | undefined; + const kuboApiUrlsToStop: string[] = []; + + afterEach(async () => { + await killChildProcess(daemon); + await killChildProcess(standaloneKubo); + for (const url of kuboApiUrlsToStop.splice(0)) await ensureKuboNodeStopped(url); + daemon = undefined; + standaloneKubo = undefined; + }, 60_000); + + // A kubo the daemon did not start, standing in for an operator-run kubo named by KUBO_RPC_URL. + // Its Routing and Ipns config are pre-seeded the way the daemon seeds its own kubo, so pkc-js + // finds nothing to change on connect and does not shut it down (issue #143). + const startStandaloneKubo = async (apiUrl: URL, gatewayUrl: URL): Promise => { + const repoPath = randomDirectory(); + await preInitKuboWithEphemeralSwarm(repoPath, apiUrl, gatewayUrl); + const configPath = path.join(repoPath, "config"); + const silent = Object.assign(() => {}, { error: () => {}, trace: () => {} }); + await ensureIpnsPubsubEnabled(silent, configPath); + await ensureKuboRoutingConfigMatchesHttpRouters(silent, configPath, defaults.HTTP_TRACKERS); + const child = spawn(await resolveKuboBinary(), ["daemon"], { + env: { ...process.env, IPFS_PATH: repoPath }, + stdio: ["ignore", "pipe", "pipe"] + }); + let output = ""; + child.stdout!.on("data", (data: Buffer) => (output += data.toString())); + child.stderr!.on("data", (data: Buffer) => (output += data.toString())); + const clientUrl = `http://127.0.0.1:${apiUrl.port}/api/v0`; + kuboApiUrlsToStop.push(clientUrl); + const ready = await waitForKuboReady(clientUrl, 60_000); + if (!ready) { + await killChildProcess(child); + // Let withKuboBindRetry pick new ports if kubo lost the bind race. + throw new Error(isAddressInUseError(output) ? output : `standalone kubo never became ready:\n${output}`); + } + return child; + }; + + // Pre-inits the repo under dataPath with Addresses.API / Addresses.Gateway on `bindHost`, then + // starts the daemon with no KUBO_RPC_URL / IPFS_GATEWAY_URL / IPFS_PATH from the outer + // environment, plus `env` on top. + const startDaemon = async ({ bindHost = "127.0.0.1", env = {} }: { bindHost?: string; env?: Record } = {}) => { + const { result } = await withKuboBindRetry( + async (endpoints): Promise => { + const dataPath = randomDirectory(); + const configApiPort = endpoints.kuboPort; + const configGatewayPort = endpoints.gatewayPort; + await preInitKuboWithEphemeralSwarm( + path.join(dataPath, ".bitsocial-cli.ipfs"), + new URL(`http://${bindHost}:${configApiPort}/api/v0`), + new URL(`http://${bindHost}:${configGatewayPort}`) + ); + const configKuboApiUrl = `http://127.0.0.1:${configApiPort}/api/v0`; + kuboApiUrlsToStop.push(configKuboApiUrl); + const { KUBO_RPC_URL: _kubo, IPFS_GATEWAY_URL: _gateway, IPFS_PATH: _repo, ...inheritedEnv } = process.env; + const child = spawn( + "node", + [ + "./bin/run", + "daemon", + "--logPath", + randomDirectory(), + "--pkcOptions.dataPath", + dataPath, + "--pkcRpcUrl", + endpoints.rpcWsUrl + ], + { stdio: ["pipe", "pipe", "pipe"], env: { ...inheritedEnv, ...env } } + ); + daemon = child; + let output = ""; + child.stdout!.on("data", (data: Buffer) => (output += data.toString())); + child.stderr!.on("data", (data: Buffer) => (output += data.toString())); + const waitForReady = async () => { + const ready = await waitForCondition( + () => output.includes("Communities in data path") || child.exitCode !== null, + READY_TIMEOUT_MS, + 250 + ); + if (child.exitCode !== null) throw new Error(`daemon exited with code ${child.exitCode}:\n${output}`); + expect(ready, `daemon never became ready:\n${output}`).toBe(true); + return output.slice(output.indexOf("PKC options:")); + }; + return { + dataPath, + rpcWsUrl: endpoints.rpcWsUrl, + configApiPort, + configGatewayPort, + configKuboApiUrl, + output: () => output, + waitForReady + }; + }, + { cleanup: async () => killChildProcess(daemon) } + ); + return result; + }; + + it("uses Addresses.API and Addresses.Gateway over the built-in defaults", { timeout: 180_000 }, async () => { + const handle = await startDaemon(); + const options = await handle.waitForReady(); + expect(options).toContain(`Kubo IPFS API listening on: ${handle.configKuboApiUrl}`); + expect(options).toContain(`Kubo IPFS Gateway listening on: http://127.0.0.1:${handle.configGatewayPort}`); + + const address = await createCommunityThroughDaemon(handle.rpcWsUrl); + expect(await kuboKeyNames(handle.configKuboApiUrl), "the community key did not land in the kubo from the config").toContain( + address + ); + }); + + it("lets an explicit KUBO_RPC_URL and IPFS_GATEWAY_URL win over the kubo config", { timeout: 180_000 }, async () => { + const { result: envKubo, endpoints: envEndpoints } = await withKuboBindRetry((endpoints) => + startStandaloneKubo(new URL(endpoints.kuboRpcUrl), new URL(endpoints.gatewayUrl)) + ); + standaloneKubo = envKubo; + const envKuboApiUrl = `http://127.0.0.1:${envEndpoints.kuboPort}/api/v0`; + + const handle = await startDaemon({ + env: { KUBO_RPC_URL: envKuboApiUrl, IPFS_GATEWAY_URL: `http://127.0.0.1:${envEndpoints.gatewayPort}` } + }); + const options = await handle.waitForReady(); + expect(options).toContain(envKuboApiUrl); + expect(options).not.toContain(handle.configKuboApiUrl); + + const address = await createCommunityThroughDaemon(handle.rpcWsUrl); + expect(await kuboKeyNames(envKuboApiUrl), "the community key did not land in the kubo named by KUBO_RPC_URL").toContain(address); + // The daemon adopted the running kubo instead of starting the one described by the config. + expect(await isKuboListening(handle.configKuboApiUrl), "the daemon started the kubo from the config anyway").toBe(false); + }); + + // Linux connects to 0.0.0.0 as loopback, so a wildcard URL handed to pkc-js works there by + // accident. macOS refuses it (commit 4652547), which is why the daemon's own checks go through + // toConnectableHostname. pkc-js and the GC scheduler must get a dialable URL too. + it.skipIf(process.platform !== "darwin")( + "connects pkc-js to a kubo whose config binds the wildcard address (macOS)", + { timeout: 180_000 }, + async () => { + const handle = await startDaemon({ bindHost: "0.0.0.0" }); + await handle.waitForReady(); + + const address = await createCommunityThroughDaemon(handle.rpcWsUrl); + expect(await kuboKeyNames(handle.configKuboApiUrl), "the community key did not land in kubo").toContain(address); + } + ); + + // For a locally managed kubo the daemon never rewrites Addresses.* of an existing repo, so an env + // override that differs from the config leaves kubo on one port and the daemon dialling another. + it("warns when the env overrides name ports the repo's kubo config does not listen on", { timeout: 180_000 }, async () => { + const [envApiPort, envGatewayPort] = await Promise.all([allocateFreePort("127.0.0.1"), allocateFreePort("127.0.0.1")]); + const envKuboApiUrl = `http://127.0.0.1:${envApiPort}/api/v0`; + const envGatewayUrl = `http://127.0.0.1:${envGatewayPort}`; + const handle = await startDaemon({ env: { KUBO_RPC_URL: envKuboApiUrl, IPFS_GATEWAY_URL: envGatewayUrl } }); + + const warned = await waitForCondition( + () => handle.output().includes("Addresses.API") && handle.output().includes("Addresses.Gateway"), + READY_TIMEOUT_MS, + 250 + ); + const output = handle.output(); + expect(warned, `daemon never warned about the mismatch:\n${output}`).toBe(true); + const configPath = path.join(handle.dataPath, ".bitsocial-cli.ipfs", "config"); + expect(output).toContain( + `KUBO_RPC_URL is ${envKuboApiUrl} but Addresses.API in ${configPath} is /ip4/127.0.0.1/tcp/${handle.configApiPort}.` + ); + expect(output).toContain( + `IPFS_GATEWAY_URL is ${envGatewayUrl}/ but Addresses.Gateway in ${configPath} is /ip4/127.0.0.1/tcp/${handle.configGatewayPort}.` + ); + }); +}); diff --git a/test/kubo/kubo-repo-path.test.ts b/test/kubo/kubo-repo-path.test.ts new file mode 100644 index 00000000..13961c0b --- /dev/null +++ b/test/kubo/kubo-repo-path.test.ts @@ -0,0 +1,231 @@ +// 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, + describeKuboEndpointMismatch +} 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/"); + }); + + // A wildcard bind address says where kubo listens, not what a client can dial: macOS refuses + // connect() to 0.0.0.0 (commit 4652547). These URLs go to pkc-js and the GC scheduler as they + // are, so they must already be on loopback. + it.each([ + ["/ip4/0.0.0.0/tcp/5001", "http://127.0.0.1:5001/api/v0"], + ["/ip6/::/tcp/5001", "http://[::1]:5001/api/v0"] + ])("maps the wildcard bind %s to a loopback RPC URL", async (multiAddr, expected) => { + expect((await parseMultiAddrKuboRpcToUrl(multiAddr)).toString()).toBe(expected); + }); + + it.each([ + ["/ip4/0.0.0.0/tcp/8080", "http://127.0.0.1:8080/"], + ["/ip6/::/tcp/8080", "http://[::1]:8080/"] + ])("maps the wildcard bind %s to a loopback gateway URL", async (multiAddr, expected) => { + expect((await parseMultiAddrIpfsGatewayToUrl(multiAddr)).toString()).toBe(expected); + }); + + 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"); + }); +}); + +// For a locally managed kubo the daemon never rewrites Addresses.* of an existing repo, so an env +// override that differs from the config leaves kubo on one port and the daemon dialling another. +describe("describeKuboEndpointMismatch", () => { + const configPath = "/data/.bitsocial-cli.ipfs/config"; + const describeApi = (configured: unknown, chosen: string) => + describeKuboEndpointMismatch({ + envVar: "KUBO_RPC_URL", + configField: "Addresses.API", + configPath, + configured, + chosen: new URL(chosen) + }); + const chosen = "http://127.0.0.1:5001/api/v0"; + + it("says nothing before kubo has a config, or when the API is disabled or a unix socket", async () => { + expect(await describeApi(undefined, chosen)).toBeUndefined(); + expect(await describeApi("", chosen)).toBeUndefined(); + expect(await describeApi([], chosen)).toBeUndefined(); + expect(await describeApi("/unix/tmp/kubo-api.sock", chosen)).toBeUndefined(); + }); + + it("says nothing when the env override reaches the configured listener", async () => { + expect(await describeApi("/ip4/127.0.0.1/tcp/5001", chosen)).toBeUndefined(); + expect(await describeApi("/ip6/::1/tcp/5001", "http://[::1]:5001/api/v0")).toBeUndefined(); + }); + + it("treats a wildcard bind as reachable on any host with that port", async () => { + for (const bind of ["/ip4/0.0.0.0/tcp/5001", "/ip6/::/tcp/5001"]) { + expect(await describeApi(bind, chosen)).toBeUndefined(); + expect(await describeApi(bind, "http://[::1]:5001/api/v0")).toBeUndefined(); + expect(await describeApi(bind, "http://192.168.1.10:5001/api/v0")).toBeUndefined(); + // What the Docker image's ENV writes on first init, and then keeps dialling. + expect(await describeApi(bind, "http://0.0.0.0:5001/api/v0")).toBeUndefined(); + } + expect(await describeApi("/ip4/0.0.0.0/tcp/5001", "http://192.168.1.10:5002/api/v0")).toContain( + "KUBO_RPC_URL is http://192.168.1.10:5002/api/v0" + ); + }); + + it("accepts localhost for a loopback listener, and a localhost bind for a loopback literal", async () => { + expect(await describeApi("/ip4/127.0.0.1/tcp/5001", "http://localhost:5001/api/v0")).toBeUndefined(); + expect(await describeApi("/ip6/::1/tcp/5001", "http://localhost:5001/api/v0")).toBeUndefined(); + expect(await describeApi("/dns4/localhost/tcp/5001", chosen)).toBeUndefined(); + }); + + it("checks every entry when Addresses.API is a list, since kubo listens on all of them", async () => { + const list = ["/ip4/127.0.0.1/tcp/5001", "/ip4/127.0.0.1/tcp/5002"]; + expect(await describeApi(list, "http://127.0.0.1:5002/api/v0")).toBeUndefined(); + expect(await describeApi(list, "http://127.0.0.1:5003/api/v0")).toContain( + 'Addresses.API in /data/.bitsocial-cli.ipfs/config is ["/ip4/127.0.0.1/tcp/5001","/ip4/127.0.0.1/tcp/5002"].' + ); + }); + + it("says nothing for a DNS name it cannot judge offline", async () => { + expect(await describeApi("/ip4/127.0.0.1/tcp/5001", "http://host.docker.internal:5001/api/v0")).toBeUndefined(); + }); + + it("names the env var, the config field and the configured value when the ports differ", async () => { + const message = await describeApi("/ip4/127.0.0.1/tcp/5001", "http://127.0.0.1:5002/api/v0"); + expect(message).toBe( + "KUBO_RPC_URL is http://127.0.0.1:5002/api/v0 but Addresses.API in /data/.bitsocial-cli.ipfs/config is /ip4/127.0.0.1/tcp/5001. " + + "The daemon starts kubo from that config and dials KUBO_RPC_URL, so it will not reach kubo. " + + "Unset KUBO_RPC_URL to follow the config, or edit Addresses.API to match." + ); + }); + + it("warns when only the host differs", async () => { + expect(await describeApi("/ip4/127.0.0.1/tcp/5001", "http://192.168.1.10:5001/api/v0")).toContain( + "KUBO_RPC_URL is http://192.168.1.10:5001/api/v0" + ); + }); + + it("warns when the env override uses https, since kubo serves plain http", async () => { + expect(await describeApi("/ip4/127.0.0.1/tcp/5001", "https://localhost:5001/api/v0")).toContain( + "KUBO_RPC_URL is https://localhost:5001/api/v0" + ); + }); + + it("still warns when the IP families of two loopback literals differ", async () => { + // A kubo bound on /ip4/127.0.0.1 refuses a dial to [::1]. + expect(await describeApi("/ip4/127.0.0.1/tcp/5001", "http://[::1]:5001/api/v0")).toContain( + "KUBO_RPC_URL is http://[::1]:5001/api/v0" + ); + }); + + it("treats a URL without a port as the scheme default", async () => { + expect( + await describeKuboEndpointMismatch({ + envVar: "IPFS_GATEWAY_URL", + configField: "Addresses.Gateway", + configPath, + configured: "/ip4/127.0.0.1/tcp/80", + chosen: new URL("http://127.0.0.1/") + }) + ).toBeUndefined(); + }); +});