Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
66 changes: 47 additions & 19 deletions src/cli/commands/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -89,7 +85,7 @@ export async function runKeepKuboUpTick(deps: KeepKuboUpTickDeps): Promise<void>
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 = {
Expand Down Expand Up @@ -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()];
Comment thread
Rinse12 marked this conversation as resolved.
Resolved
const mergedPkcOptions = { ...defaultPkcOptions, ...pkcOptionsFromFlag };
Expand Down Expand Up @@ -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<number>();
let warnedAboutKuboConfigMismatch = false;
const keepKuboUpOnce = async () => {
if (mainProcessExited) return;
const kuboApiPort = Number(kuboRpcEndpoint.port);
Expand All @@ -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;
Expand All @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions src/ipfs/startIpfs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
return ipfsExePathFunc();
Expand Down Expand Up @@ -237,15 +237,15 @@ 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;
if (gatewayMultiAddr) addCheck("IPFS Gateway", gatewayMultiAddr.host, gatewayMultiAddr.port, addresses.Gateway);
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)
Expand Down Expand Up @@ -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");

Expand Down
115 changes: 112 additions & 3 deletions src/util.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<any | undefined> {
const kuboConfigPath = path.join(pkcDataPath, ".ipfs-bitsocial-cli", "config");
const kuboConfigPath = path.join(resolveKuboRepoPath(pkcDataPath), "config");

if (!fs.existsSync(kuboConfigPath)) return undefined;

Expand All @@ -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<URL>): Promise<URL | undefined> {
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<URL | undefined> {
return firstUsableKuboAddress(kuboConfig?.Addresses?.API, parseMultiAddrKuboRpcToUrl);
}

export function ipfsGatewayUrlFromKuboConfig(kuboConfig: any): Promise<URL | undefined> {
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<string | undefined> {
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`.
Expand Down
Loading
Loading