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
1 change: 1 addition & 0 deletions PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 53 additions & 12 deletions apps/cli/src/commands/daemon.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -73,11 +73,16 @@ export async function daemonStart(): Promise<void> {
}
}

export async function daemonStop(): Promise<void> {
/**
* 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<boolean> {
const pid = findRunningDaemon();
if (!pid) {
console.log(chalk.dim(' No running daemon found.'));
return;
return true;
}

try {
Expand All @@ -89,19 +94,55 @@ export async function daemonStop(): Promise<void> {
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<void> {
if (!(await daemonStop())) return;
await releaseStaleSocket();
await daemonStart();
}

async function waitForExit(pid: number, timeoutMs: number): Promise<boolean> {
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<void> {
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 {}
}
}
2 changes: 1 addition & 1 deletion apps/cli/src/commands/modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down
10 changes: 9 additions & 1 deletion apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)")
Expand Down
2 changes: 1 addition & 1 deletion apps/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/app/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Loading