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
62 changes: 44 additions & 18 deletions src/cli/commands/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import {
PKCLogger,
setupDebugLogger,
loadKuboConfigFile,
parseMultiAddrKuboRpcToUrl,
parseMultiAddrIpfsGatewayToUrl
kuboRpcUrlFromKuboConfig,
ipfsGatewayUrlFromKuboConfig,
hostForUrl,
resolveKuboRepoPath,
toConnectableHostname
} from "../../util.js";
import type { PKCLoggerType } from "../../util.js";
import { startDaemonServer } from "../../webui/daemon-server.js";
Expand All @@ -26,14 +29,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 +84,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 +323,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 +387,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 Expand Up @@ -562,6 +557,7 @@ export default class Daemon extends Command {
};

let keepKuboUpInterval: NodeJS.Timeout | undefined;
let reachabilityRuntime: import("../../reachability/runtime.js").ReachabilityRuntime | undefined;
let stopRepoGcScheduler: (() => void) | undefined;
const { asyncExitHook } = await import("exit-hook");
const killKuboProcessGroup = (pid: number, signal: NodeJS.Signals) => {
Expand Down Expand Up @@ -654,6 +650,7 @@ export default class Daemon extends Command {
// Start killing Kubo immediately, in parallel with daemon server destroy.
// This way Kubo receives SIGINT right away, even if daemonServer.destroy() hangs.
const kuboKillPromise = killKuboProcess();
const reachabilityStopPromise = reachabilityRuntime?.stop().catch((error) => log.error("Error stopping reachability", error));

if (daemonServer)
try {
Expand All @@ -664,6 +661,7 @@ export default class Daemon extends Command {
}

await kuboKillPromise;
await reachabilityStopPromise;
};
// Run kubo/daemon cleanup on SIGINT/SIGTERM/beforeExit. Capture the unsubscribe handle
// and the shutdown fn so the startup-failure catch (and the force-quit guard below) can
Expand All @@ -681,6 +679,7 @@ export default class Daemon extends Command {
// synchronously SIGKILL every live kubo's process group. This is a no-op if
// killKuboProcess() already ran (it clears kuboProcess and liveKuboPids).
process.on("exit", () => {
reachabilityRuntime?.killCaddySync();
if (kuboProcess?.pid) {
killKuboProcessGroup(kuboProcess.pid, "SIGKILL");
}
Expand Down Expand Up @@ -742,6 +741,33 @@ export default class Daemon extends Command {
if (!pkcOptionsFromFlag?.kuboRpcClientsOptions) await keepKuboUp();
await createOrConnectRpc();

// Only for a kubo this daemon runs. Failing here must never take the node down: browsers
// being unable to dial it is a far smaller problem than it being offline.
if (!pkcOptionsFromFlag?.kuboRpcClientsOptions) {
try {
const { ReachabilityRuntime } = await import("../../reachability/runtime.js");
const reachabilityLog = PKCLogger("bitsocial-cli:reachability");
reachabilityRuntime = new ReachabilityRuntime({
dataPath: mergedPkcOptions.dataPath!,
kuboRepoPath: resolveKuboRepoPath(mergedPkcOptions.dataPath!),
kuboRpcUrl: kuboRpcEndpoint,
log: (message) => {
console.log(message);
reachabilityLog(message);
},
// stdout, because stderr goes only to the log file and operators need to see these.
warn: (message) => {
console.log(`Warning: ${message}`);
reachabilityLog.error(message);
}
});
await reachabilityRuntime.start();
} catch (error) {
log.error("Reachability failed to start", error);
console.log(`Warning: reachability failed to start: ${error instanceof Error ? error.message : String(error)}`);
}
}

// Runs against whichever kubo the daemon ends up talking to, including one started by
// another program (--pkcOptions.kuboRpcClientsOptions). pkc-js also GCs, but only from
// a started local community's IPNS sync — a daemon that is up with no community
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;
}
}
Loading
Loading