Skip to content
Merged
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
71 changes: 71 additions & 0 deletions apps/cli/src/__tests__/upgrade.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -133,3 +136,71 @@ describe("shadowedUpdateWarning", () => {
expect(text).toContain("rm <path>");
});
});

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");
});
});
77 changes: 77 additions & 0 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
Expand Down Expand Up @@ -146,6 +152,73 @@ async function reportIfStillStale(): Promise<void> {
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<void> {
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();
Expand Down Expand Up @@ -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) {
Expand Down
65 changes: 65 additions & 0 deletions apps/cli/src/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions apps/web/public/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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}"
Expand Down
Loading