diff --git a/PRD.md b/PRD.md index 935a0de..da16d95 100644 --- a/PRD.md +++ b/PRD.md @@ -57,6 +57,7 @@ threatcrush (CLI) # Daemon management threatcrush start # Start daemon (or systemctl start threatcrushd) threatcrush stop # Stop daemon +threatcrush restart # Restart daemon threatcrush status # Show daemon status, active modules, threat count threatcrush logs # Tail daemon logs threatcrush logs --module ssh-guard # Tail specific module logs diff --git a/apps/cli/src/commands/daemon.ts b/apps/cli/src/commands/daemon.ts index ee36d42..65be3d7 100644 --- a/apps/cli/src/commands/daemon.ts +++ b/apps/cli/src/commands/daemon.ts @@ -1,11 +1,11 @@ import { spawn } from 'node:child_process'; -import { existsSync } from 'node:fs'; +import { existsSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; import { openSync } from 'node:fs'; import chalk from 'chalk'; import { runDaemon } from '../daemon/index.js'; import { PATHS, ensureRuntimeDirs } from '../daemon/paths.js'; -import { findRunningDaemon, removePidFile } from '../daemon/pidfile.js'; +import { findRunningDaemon, isProcessAlive, removePidFile } from '../daemon/pidfile.js'; import { IpcClient } from '../core/ipc-client.js'; const DAEMON_ENTRY = join(__dirname, 'daemon.js'); @@ -73,11 +73,16 @@ export async function daemonStart(): Promise { } } -export async function daemonStop(): Promise { +/** + * Returns true when no daemon is running once this resolves — either it was + * already down, or we brought it down. `restart` needs that answer: starting a + * second daemon while the first is alive makes it unlink the live socket. + */ +export async function daemonStop(): Promise { const pid = findRunningDaemon(); if (!pid) { console.log(chalk.dim(' No running daemon found.')); - return; + return true; } try { @@ -89,19 +94,55 @@ export async function daemonStop(): Promise { try { process.kill(pid, 'SIGTERM'); } catch {} } - const deadline = Date.now() + 3000; - while (Date.now() < deadline) { - try { process.kill(pid, 0); } catch { break; } - await new Promise((r) => setTimeout(r, 100)); - } - - try { process.kill(pid, 0); } catch { + // `isProcessAlive` treats EPERM as alive. A raw `process.kill(pid, 0)` here + // would read the EPERM from signalling a root-owned daemon as "it's gone". + if (await waitForExit(pid, 3000)) { removePidFile(); console.log(chalk.green(` ✓ threatcrushd stopped.`)); - return; + return true; } try { process.kill(pid, 'SIGKILL'); } catch {} + + if (!(await waitForExit(pid, 1000))) { + console.log(chalk.red(` ✗ threatcrushd (pid ${pid}) is still running and could not be stopped.`)); + console.log(chalk.dim(' It may be owned by another user — try `sudo threatcrush stop`.\n')); + // Leave the pidfile: it still points at a live process. + return false; + } + removePidFile(); console.log(chalk.yellow(` ! threatcrushd was killed (SIGKILL).`)); + return true; +} + +export async function daemonRestart(): Promise { + if (!(await daemonStop())) return; + await releaseStaleSocket(); + await daemonStart(); +} + +async function waitForExit(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (isProcessAlive(pid)) { + if (Date.now() >= deadline) return false; + await new Promise((r) => setTimeout(r, 100)); + } + return true; +} + +/** + * A daemon that exits cleanly unlinks its own socket, but one we SIGKILLed + * leaves the file behind — and `daemonStart` reads socket existence as + * "it's up", so a leftover would make the restart report success without the + * new daemon ever binding. + */ +async function releaseStaleSocket(): Promise { + const deadline = Date.now() + 2000; + while (existsSync(PATHS.socket) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + } + if (existsSync(PATHS.socket)) { + try { unlinkSync(PATHS.socket); } catch {} + } } diff --git a/apps/cli/src/commands/modules.ts b/apps/cli/src/commands/modules.ts index a7ffa31..faba1d2 100644 --- a/apps/cli/src/commands/modules.ts +++ b/apps/cli/src/commands/modules.ts @@ -72,7 +72,7 @@ function printTrustRequired(name: string): void { function notifyDaemonIfRunning(): void { if (!findRunningDaemon()) return; console.log(chalk.dim(` ℹ threatcrushd is running — restart it to load/unload modules:`)); - console.log(chalk.dim(` ${chalk.white('threatcrush stop && threatcrush start')}`)); + console.log(chalk.dim(` ${chalk.white('threatcrush restart')}`)); } export async function modulesListCommand(): Promise { diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 53da808..9c2e846 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -16,7 +16,7 @@ import { pentestCommand } from "./commands/pentest.js"; import { orgsCommand } from "./commands/orgs.js"; import { serversCommand } from "./commands/servers.js"; import { connectCommand } from "./commands/connect.js"; -import { daemonForeground, daemonStart, daemonStop } from "./commands/daemon.js"; +import { daemonForeground, daemonRestart, daemonStart, daemonStop } from "./commands/daemon.js"; import { installServiceCommand, uninstallServiceCommand } from "./commands/service.js"; import { loginCommand, logoutCommand, whoamiCommand } from "./commands/login.js"; import { welcomeCommand } from "./commands/welcome.js"; @@ -391,6 +391,14 @@ program await daemonStop(); }); +program + .command("restart") + .description("Restart the ThreatCrush daemon (stop, then start)") + .action(async () => { + console.log(LOGO); + await daemonRestart(); + }); + program .command("daemon") .description("Run threatcrushd in the foreground (systemd / Docker ExecStart)") diff --git a/apps/sdk/README.md b/apps/sdk/README.md index f37f5a9..1983bfc 100644 --- a/apps/sdk/README.md +++ b/apps/sdk/README.md @@ -54,7 +54,7 @@ Install it locally: ```bash threatcrush modules install ./my-module -threatcrush stop && threatcrush start # reload the daemon +threatcrush restart # reload the daemon ``` ## What's exported diff --git a/apps/web/src/app/docs/page.tsx b/apps/web/src/app/docs/page.tsx index 71b9839..180f0ca 100644 --- a/apps/web/src/app/docs/page.tsx +++ b/apps/web/src/app/docs/page.tsx @@ -76,6 +76,11 @@ const supportedCommands = [ description: "Stop the daemon.", notes: "Command surface exists, but the real daemon/service lifecycle is still being built.", }, + { + name: "threatcrush restart", + description: "Restart the daemon.", + notes: "Command surface exists, but the real daemon/service lifecycle is still being built.", + }, { name: "threatcrush logs", description: "Inspect runtime logs.",