Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
30 changes: 12 additions & 18 deletions src/cli/commands/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -89,7 +83,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 +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 };
Expand Down Expand Up @@ -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);
Expand Down
108 changes: 108 additions & 0 deletions src/cli/commands/reachability/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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, SetupRefusedError, 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<boolean>((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 askOnTerminal(question: string): Promise<string> {
const terminal = readline.createInterface({ input: process.stdin, output: process.stdout });
try {
return await terminal.question(`${question} `);
} finally {
terminal.close();
}
}

const confirmOnTerminal = async (question: string) => /^y(es)?$/i.test((await askOnTerminal(`${question} [y/N]`)).trim());

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 }),
domain: Flags.string({ description: "A domain pointing at this machine, to get a certificate for or to forward through your own proxy" }),
announce: Flags.string({ description: "A multiaddr your own proxy serves, e.g. /dns4/node.example/tcp/443/tls/ws. Announced only after a real libp2p dial succeeds" }),
force: Flags.boolean({ description: "Continue when the domain does not resolve to this machine, for proxies in front of it", default: 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 --domain node.example",
"bitsocial reachability setup --domain node.example --announce /dns4/node.example/tcp/443/tls/ws",
"bitsocial reachability setup --non-interactive"
];

async run(): Promise<void> {
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"],
force: flags.force,
...(flags.domain ? { domain: flags.domain } : {}),
...(flags.announce ? { announce: flags.announce } : {}),
...(overrides ? { overrides } : {})
},
{
...realSetupDependencies(),
log: (message) => this.log(message),
warn: (message) => this.logToStderr(message),
...(canAsk ? { confirm: confirmOnTerminal, ask: askOnTerminal, 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 });
if (error instanceof SetupRefusedError) this.error(error.message, { exit: 1 });
throw error;
}
}
}
46 changes: 46 additions & 0 deletions src/cli/commands/reachability/status.ts
Original file line number Diff line number Diff line change
@@ -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<ReachabilityStatus> {
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;
}
}
55 changes: 33 additions & 22 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 All @@ -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<void> {
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 = <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,
Expand All @@ -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");

Expand All @@ -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 = <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);
Expand Down
Loading
Loading