diff --git a/apps/cli/src/__tests__/upgrade.test.ts b/apps/cli/src/__tests__/upgrade.test.ts index efccf72..9bcef00 100644 --- a/apps/cli/src/__tests__/upgrade.test.ts +++ b/apps/cli/src/__tests__/upgrade.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from "vitest"; import { + daemonRefreshPlan, globalHolders, globalInstallCommand, globalListCommand, parseVersionOutput, shadowedUpdateWarning, + staleDaemonWarning, + systemdOwnsDaemon, type PackageManager, type Runner, } from "../upgrade.js"; @@ -133,3 +136,71 @@ describe("shadowedUpdateWarning", () => { expect(text).toContain("rm "); }); }); + +describe("systemdOwnsDaemon", () => { + it("matches when the unit's MainPID is the running daemon", () => { + expect(systemdOwnsDaemon("1138793\n", 1138793)).toBe(true); + }); + + it("treats MainPID=0 as not managed", () => { + // systemd reports 0 for a unit that is not running — a bare `threatcrush + // start` daemon must not be mistaken for a supervised one. + expect(systemdOwnsDaemon("0\n", 1138793)).toBe(false); + }); + + it("is false when systemctl is absent or the pids differ", () => { + expect(systemdOwnsDaemon(null, 1138793)).toBe(false); + expect(systemdOwnsDaemon("42\n", 1138793)).toBe(false); + expect(systemdOwnsDaemon("1138793\n", null)).toBe(false); + }); +}); + +describe("daemonRefreshPlan", () => { + it("does nothing when no daemon is running", () => { + expect(daemonRefreshPlan({ daemonRunning: false, systemdManaged: false })).toEqual({ + action: "none", + }); + }); + + it("restarts a daemon we started ourselves", () => { + expect(daemonRefreshPlan({ daemonRunning: true, systemdManaged: false })).toEqual({ + action: "restart", + }); + }); + + // Restarting a systemd-managed daemon ourselves would swap the supervised + // copy for an unsupervised one that systemd knows nothing about. + it("hands over the command for a systemd-managed daemon", () => { + expect(daemonRefreshPlan({ daemonRunning: true, systemdManaged: true })).toEqual({ + action: "manual", + command: "sudo systemctl restart threatcrushd.service", + }); + }); +}); + +describe("staleDaemonWarning", () => { + it("stays quiet when the daemon matches the CLI", () => { + expect(staleDaemonWarning("0.11.7", "0.11.7")).toBeNull(); + }); + + it("stays quiet when the daemon is down or unreadable", () => { + expect(staleDaemonWarning("0.11.7", null)).toBeNull(); + expect(staleDaemonWarning(null, "0.11.3")).toBeNull(); + }); + + // The failure this exists for: a CLI on 0.11.6 beside a daemon still running + // 0.11.3 out of a store path for a version no longer installed. + it("names both versions and how to fix it", () => { + const text = (staleDaemonWarning("0.11.6", "0.11.3") ?? []).join("\n"); + expect(text).toContain("0.11.6"); + expect(text).toContain("0.11.3"); + expect(text).toContain("threatcrush restart"); + }); + + it("uses the supplied restart command", () => { + const text = ( + staleDaemonWarning("0.11.6", "0.11.3", "sudo systemctl restart threatcrushd") ?? [] + ).join("\n"); + expect(text).toContain("sudo systemctl restart threatcrushd"); + }); +}); diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 11d6d7a..ea35388 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -33,15 +33,21 @@ import { hardenCommand } from "./commands/harden.js"; import { blockCommand, unblockCommand, blocklistCommand, allowlistCommand } from "./commands/firewall.js"; import { PATHS } from "./daemon/paths.js"; import { + DAEMON_UNIT, activeCliPath, activeCliVersion, + daemonRefreshPlan, globalHolders, globalInstallCommand, globalListCommand, latestPublishedVersion, shadowedUpdateWarning, + staleDaemonWarning, + systemdOwnsDaemon, type PackageManager, } from "./upgrade.js"; +import { IpcClient } from "./core/ipc-client.js"; +import { findRunningDaemon } from "./daemon/pidfile.js"; import { PKG_VERSION } from "./core/version.js"; const LOGO = ` @@ -146,6 +152,73 @@ async function reportIfStillStale(): Promise { console.log(); } +/** The running daemon's pid and version, or nulls when it is not up. */ +async function liveDaemon(): Promise<{ pid: number | null; version: string | null }> { + const pid = findRunningDaemon(); + if (!pid) return { pid: null, version: null }; + + const client = new IpcClient(); + try { + await client.connect(); + const live = await client.status(); + return { pid, version: live.version ?? null }; + } catch { + // Daemon is up but not answering IPC — we still know it is running. + return { pid, version: null }; + } finally { + client.close(); + } +} + +/** + * Bring the running daemon onto the build we just installed. + * + * The CLI upgrade replaces files on disk and nothing else, so without this the + * daemon serves the old version indefinitely while `update` reports success. + */ +async function refreshDaemonAfterUpgrade(): Promise { + const before = await liveDaemon(); + const plan = daemonRefreshPlan({ + daemonRunning: before.pid !== null, + systemdManaged: systemdOwnsDaemon( + execQuiet(`systemctl show ${DAEMON_UNIT} -p MainPID --value`), + before.pid, + ), + }); + + if (plan.action === "none") return; + + if (plan.action === "manual") { + // Restarting this one ourselves would swap a supervised daemon for an + // unsupervised one, so hand the command over instead of running it. + console.log(chalk.yellow(" ! The daemon is managed by systemd and is still on the old build.")); + console.log(chalk.dim(` Restart it with: ${plan.command}\n`)); + return; + } + + console.log(chalk.dim(" Restarting threatcrushd onto the new build...\n")); + await daemonRestart(); + + const after = await liveDaemon(); + const warning = staleDaemonWarning(PKG_VERSION, after.version); + if (warning) { + console.log(chalk.yellow(` ${warning[0]}`)); + for (const line of warning.slice(1)) { + console.log(chalk.dim(` ${line}`)); + } + console.log(); + } +} + +/** Runs a command for its stdout, swallowing failure and a missing binary. */ +function execQuiet(cmd: string): string | null { + try { + return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }); + } catch { + return null; + } +} + // ─── Program ─── const program = new Command(); @@ -534,6 +607,10 @@ program console.log(chalk.dim(" Updated bundle: CLI only\n")); } await reportIfStillStale(); + await refreshDaemonAfterUpgrade(); + // A running `monitor --tui` loaded its code at launch, so it keeps + // rendering the old build however current everything on disk is. + console.log(chalk.dim(" If a `threatcrush monitor --tui` is open, quit and relaunch it.\n")); } catch (err) { console.log(chalk.red("\n ✗ Update failed. Try manually:\n")); for (const cmd of commands) { diff --git a/apps/cli/src/upgrade.ts b/apps/cli/src/upgrade.ts index 389056f..088ea46 100644 --- a/apps/cli/src/upgrade.ts +++ b/apps/cli/src/upgrade.ts @@ -105,6 +105,71 @@ export async function latestPublishedVersion(pkgName: string, timeoutMs = 8000): } } +export const DAEMON_UNIT = "threatcrushd.service"; + +/** + * Whether a systemd unit — rather than a bare `threatcrush start` — owns the + * daemon that is currently up. + * + * This decides how the daemon may be restarted, and getting it wrong is worse + * than not restarting at all: `threatcrush restart` against a systemd-managed + * daemon stops the supervised copy and starts an unsupervised one in its place, + * which systemd then knows nothing about. + */ +export function systemdOwnsDaemon(mainPidOutput: string | null, daemonPid: number | null): boolean { + if (!mainPidOutput || !daemonPid) return false; + const mainPid = Number.parseInt(mainPidOutput.trim(), 10); + // systemd reports MainPID=0 for a unit that is not running. + return Number.isFinite(mainPid) && mainPid > 0 && mainPid === daemonPid; +} + +export type DaemonRefresh = + | { action: "none" } + | { action: "restart" } + | { action: "manual"; command: string }; + +/** + * What to do about the running daemon once a new CLI is on disk. + * + * Upgrading the CLI does not touch a daemon that is already running: it keeps + * executing the bundle it was spawned from, even after that version has been + * uninstalled. One host sat on a daemon running 0.11.3 out of a pnpm store path + * for a version no longer installed at all, while the CLI beside it reported + * 0.11.6 and every upgrade printed success. + */ +export function daemonRefreshPlan(opts: { + daemonRunning: boolean; + systemdManaged: boolean; + unit?: string; +}): DaemonRefresh { + if (!opts.daemonRunning) return { action: "none" }; + if (opts.systemdManaged) { + return { action: "manual", command: `sudo systemctl restart ${opts.unit ?? DAEMON_UNIT}` }; + } + return { action: "restart" }; +} + +/** + * Lines to print when the daemon is still serving a different version than the + * CLI we just installed. Returns null when they agree or the daemon is down. + */ +export function staleDaemonWarning( + cliVersion: string | null, + daemonVersion: string | null, + restartCommand = "threatcrush restart", +): string[] | null { + if (!cliVersion || !daemonVersion || cliVersion === daemonVersion) return null; + + return [ + "⚠ The CLI was updated, but the running daemon is still on the old build.", + ` CLI: ${cliVersion}`, + ` Daemon: ${daemonVersion}`, + "", + " A running daemon keeps executing the bundle it started from. Restart it:", + ` ${restartCommand}`, + ]; +} + /** * Lines to print when the update ran clean but PATH still resolves to an older * copy — a stale shell hash, or a second install the package manager we used diff --git a/apps/web/public/install.sh b/apps/web/public/install.sh index 1f2d9d3..4d85a7d 100644 --- a/apps/web/public/install.sh +++ b/apps/web/public/install.sh @@ -320,6 +320,42 @@ installed_version() { # PATH - not that it is the copy we just installed. Without this check the # installer prints "installed successfully" next to the old version number and # the user has no idea why the upgrade did nothing. +# Installing a new CLI replaces files on disk and nothing else. A daemon that is +# already running keeps executing the bundle it was spawned from — even after +# that version's directory is gone — so the new build never takes effect until +# it is restarted, while every install reports success. +refresh_daemon() { + command_exists threatcrush || return 0 + + # A systemd-managed daemon must be restarted through systemd. `threatcrush + # restart` here would stop the supervised copy and start an unsupervised one + # in its place, which systemd would know nothing about. + if command_exists systemctl && [ "$(systemctl is-active threatcrushd.service 2>/dev/null)" = "active" ]; then + say "" + say "${YELLOW}! threatcrushd is managed by systemd and is still on the old build.${RESET}" + say " ${DIM}Restart it with:${RESET} ${GREEN}sudo systemctl restart threatcrushd${RESET}" + return 0 + fi + + for PIDFILE in "$HOME/.threatcrush/run/threatcrushd.pid" /var/run/threatcrush/threatcrushd.pid; do + [ -f "$PIDFILE" ] || continue + DAEMON_PID=$(cat "$PIDFILE" 2>/dev/null || echo "") + [ -n "$DAEMON_PID" ] || continue + kill -0 "$DAEMON_PID" 2>/dev/null || continue + + say "" + say "${DIM}Restarting threatcrushd onto the new build...${RESET}" + if threatcrush restart >/dev/null 2>&1; then + say "${GREEN}✓ threatcrushd restarted.${RESET}" + else + say "${YELLOW}! Could not restart threatcrushd — run:${RESET} ${GREEN}threatcrush restart${RESET}" + fi + return 0 + done + + return 0 +} + warn_if_shadowed() { EXPECTED="$1" ACTIVE="$2" @@ -427,6 +463,7 @@ if command_exists threatcrush; then EXPECTED_VERSION=$(installed_version "$PKG_NAME" 2>/dev/null || echo "") say "${GREEN}✓ ThreatCrush ${VERSION} installed successfully!${RESET}" warn_if_shadowed "$EXPECTED_VERSION" "$VERSION" + refresh_daemon say "" say " ${BOLD}Detected install mode:${RESET} ${INSTALL_MODE}" say " ${BOLD}Platform kind:${RESET} ${PLATFORM_KIND}"