diff --git a/setup/index.ts b/setup/index.ts index aea582f..65a8a6b 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -27,6 +27,8 @@ const STEPS: Record< ollama: () => import('./ollama.js'), 'llama-cpp': () => import('./llama-cpp.js'), sanoid: () => import('./sanoid.js'), + 'system-update': () => import('./system-update.js'), + 'system-update-cron': () => import('./system-update-cron.js'), service: () => import('./service.js'), hostd: () => import('./hostd.js'), verify: () => import('./verify.js'), diff --git a/setup/system-update-cron.test.ts b/setup/system-update-cron.test.ts new file mode 100644 index 0000000..0090524 --- /dev/null +++ b/setup/system-update-cron.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildCronEntry, + buildWrapper, + parseArgs, +} from './system-update-cron.js'; + +describe('parseArgs', () => { + it('defaults to 06:50 daily and process cwd', () => { + const result = parseArgs([]); + expect(result.schedule).toBe('50 6 * * *'); + expect(result.projectRoot.startsWith('/')).toBe(true); + expect(result.npxPath).toBe('/usr/local/bin/npx'); + }); + + it('accepts a custom schedule', () => { + expect( + parseArgs(['--schedule', '30 5 * * *', '--project-root', '/opt/clawdie']).schedule, + ).toBe('30 5 * * *'); + }); + + it('rejects a malformed schedule', () => { + expect(() => parseArgs(['--schedule', 'every minute'])).toThrow(); + }); + + it('rejects a relative project root', () => { + expect(() => parseArgs(['--project-root', 'relative/path'])).toThrow(); + }); +}); + +describe('buildWrapper', () => { + it('produces an executable shell script that exec\'s the system-update step', () => { + const wrapper = buildWrapper({ + schedule: '50 6 * * *', + projectRoot: '/home/clawdie/Clawdie-AI', + npxPath: '/usr/local/bin/npx', + }); + expect(wrapper.startsWith('#!/bin/sh')).toBe(true); + expect(wrapper).toContain('cd "/home/clawdie/Clawdie-AI"'); + expect(wrapper).toContain('"/usr/local/bin/npx" tsx setup/system-update.ts'); + }); +}); + +describe('buildCronEntry', () => { + it('emits a single cron line at the configured schedule', () => { + const entry = buildCronEntry({ + schedule: '50 6 * * *', + projectRoot: '/home/clawdie/Clawdie-AI', + npxPath: '/usr/local/bin/npx', + }); + const lines = entry.split('\n').filter((line) => line && !line.startsWith('#')); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatch( + /^50 6 \* \* \* root \/usr\/local\/sbin\/clawdie-system-update >> \/var\/log\/clawdie-system-update\.log 2>&1$/u, + ); + }); +}); diff --git a/setup/system-update-cron.ts b/setup/system-update-cron.ts new file mode 100644 index 0000000..5a00654 --- /dev/null +++ b/setup/system-update-cron.ts @@ -0,0 +1,161 @@ +/** + * Step: system-update-cron — Install /etc/cron.d/clawdie-system-update so + * the platform fetches FreeBSD security patches, audits installed packages, + * and patches every thin jail every morning before the 8am operator report. + * + * The platform never auto-reboots. If a kernel patch lands, system-update + * surfaces REBOOT_PENDING in the structured status; the operator decides. + * + * Schedule: 06:50 daily. Output of the run is appended to the system-update + * log so the morning report can pick it up. + * + * Must run on the FreeBSD host as root. + */ +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import { logger } from '../src/logger.js'; +import { commandExists, getPlatform, isRoot } from './platform.js'; +import { emitStatus } from './status.js'; + +const CRON_PATH = '/etc/cron.d/clawdie-system-update'; +const WRAPPER_PATH = '/usr/local/sbin/clawdie-system-update'; +const LOG_PATH = '/var/log/clawdie-system-update.log'; +const DEFAULT_SCHEDULE = '50 6 * * *'; + +export interface CronArgs { + schedule: string; + projectRoot: string; + npxPath: string; +} + +export function parseArgs(args: string[]): CronArgs { + let schedule = process.env.SYSTEM_UPDATE_CRON || DEFAULT_SCHEDULE; + let projectRoot = process.cwd(); + let npxPath = '/usr/local/bin/npx'; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--schedule') { + schedule = (args[i + 1] || '').trim(); + i++; + continue; + } + if (arg === '--project-root') { + projectRoot = (args[i + 1] || '').trim(); + i++; + continue; + } + if (arg === '--npx') { + npxPath = (args[i + 1] || '').trim(); + i++; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + if (!schedule || schedule.split(/\s+/u).filter(Boolean).length !== 5) { + throw new Error(`Invalid cron schedule: '${schedule}' (need 5 fields)`); + } + if (!path.isAbsolute(projectRoot)) { + throw new Error(`--project-root must be absolute: ${projectRoot}`); + } + + return { schedule, projectRoot, npxPath }; +} + +export function buildWrapper(args: CronArgs): string { + return [ + '#!/bin/sh', + '# clawdie-system-update — managed by setup/system-update-cron.ts', + '# Do not edit by hand; regenerate via npx tsx setup/index.ts --step system-update-cron', + 'set -eu', + `cd "${args.projectRoot}" || exit 1`, + `exec "${args.npxPath}" tsx setup/system-update.ts "$@"`, + '', + ].join('\n'); +} + +export function buildCronEntry(args: CronArgs): string { + return [ + '# Clawdie — daily system update (managed by setup/system-update-cron.ts)', + '# Runs before the 08:00 operator status report so the morning summary', + '# reflects the night\'s patch state. Never auto-reboots.', + `${args.schedule} root ${WRAPPER_PATH} >> ${LOG_PATH} 2>&1`, + '', + ].join('\n'); +} + +function writeIfChanged(filePath: string, content: string, mode: number): boolean { + const existing = fs.existsSync(filePath) + ? fs.readFileSync(filePath, 'utf-8') + : ''; + if (existing === content) return false; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, { mode }); + return true; +} + +export async function run(argv: string[]): Promise { + if (getPlatform() !== 'freebsd') { + emitStatus('SETUP_SYSTEM_UPDATE_CRON', { + STATUS: 'skipped', + REASON: 'not_freebsd', + }); + return; + } + + if (!isRoot()) { + emitStatus('SETUP_SYSTEM_UPDATE_CRON', { + STATUS: 'failed', + ERROR: 'root_required', + }); + process.exit(1); + } + + const args = parseArgs(argv); + + // Sanity-check that npx is reachable. + if (!fs.existsSync(args.npxPath) && !commandExists('npx')) { + emitStatus('SETUP_SYSTEM_UPDATE_CRON', { + STATUS: 'failed', + ERROR: `npx_not_found_at_${args.npxPath}`, + }); + process.exit(1); + } + + const wrapper = buildWrapper(args); + const cron = buildCronEntry(args); + + const wrapperChanged = writeIfChanged(WRAPPER_PATH, wrapper, 0o755); + if (wrapperChanged) { + logger.info({ path: WRAPPER_PATH }, 'system-update wrapper written'); + } + + const cronChanged = writeIfChanged(CRON_PATH, cron, 0o644); + if (cronChanged) { + logger.info({ path: CRON_PATH }, 'system-update cron entry written'); + } + + // Touch the log file so cron's append works on first run. + if (!fs.existsSync(LOG_PATH)) { + fs.writeFileSync(LOG_PATH, '', { mode: 0o640 }); + try { + execSync(`chown root:wheel ${LOG_PATH}`, { stdio: 'ignore' }); + } catch { + // best-effort + } + } + + emitStatus('SETUP_SYSTEM_UPDATE_CRON', { + STATUS: 'success', + SCHEDULE: args.schedule, + CRON_PATH, + WRAPPER_PATH, + LOG_PATH, + PROJECT_ROOT: args.projectRoot, + WRAPPER_CHANGED: wrapperChanged ? 'yes' : 'no', + CRON_CHANGED: cronChanged ? 'yes' : 'no', + }); +} diff --git a/setup/system-update.test.ts b/setup/system-update.test.ts index c50d35a..6b6eceb 100644 --- a/setup/system-update.test.ts +++ b/setup/system-update.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest'; import { parseArgs, parseFreeBSDVersion, + parsePkgAudit, parseReleaseList, + rebootPending, selectJailUpdateTargets, } from './system-update.js'; @@ -54,6 +56,47 @@ describe('parseReleaseList', () => { }); }); +describe('parsePkgAudit', () => { + it('reports clean when no problems are found', () => { + expect(parsePkgAudit('0 problem(s) found.')).toEqual({ + ok: true, + vulnerableCount: 0, + summary: '0 problem(s) found.', + }); + }); + + it('reports vulnerable count from a real-shaped audit output', () => { + const raw = [ + 'curl-8.7.1 is vulnerable:', + ' CVE-2024-1234', + '', + '1 problem(s) found.', + ].join('\n'); + const result = parsePkgAudit(raw); + expect(result.ok).toBe(false); + expect(result.vulnerableCount).toBe(1); + expect(result.summary).toBe('1 problem(s) found.'); + }); + + it('treats empty output as clean', () => { + expect(parsePkgAudit('').vulnerableCount).toBe(0); + }); +}); + +describe('rebootPending', () => { + it('returns false when running kernel matches installed userland', () => { + expect(rebootPending('15.0-RELEASE-p4', '15.0-RELEASE-p4')).toBe(false); + }); + + it('returns true when patch levels differ', () => { + expect(rebootPending('15.0-RELEASE-p4', '15.0-RELEASE-p8')).toBe(true); + }); + + it('ignores surrounding whitespace', () => { + expect(rebootPending('15.0-RELEASE-p4\n', ' 15.0-RELEASE-p4 ')).toBe(false); + }); +}); + describe('selectJailUpdateTargets', () => { const rows = [ { diff --git a/setup/system-update.ts b/setup/system-update.ts index 1475e34..ff60401 100644 --- a/setup/system-update.ts +++ b/setup/system-update.ts @@ -97,6 +97,30 @@ export function parseReleaseList(raw: string): string[] { .filter(Boolean); } +export interface PkgAuditResult { + ok: boolean; + vulnerableCount: number; + summary: string; +} + +export function parsePkgAudit(raw: string): PkgAuditResult { + const text = raw.trim(); + if (!text) { + return { ok: true, vulnerableCount: 0, summary: '0 problem(s) found' }; + } + const match = text.match(/(\d+)\s+problem\(s\)\s+found/iu); + const count = match ? parseInt(match[1], 10) : 0; + return { + ok: count === 0, + vulnerableCount: count, + summary: text.split('\n').slice(-1)[0] || text, + }; +} + +export function rebootPending(runningKernel: string, installedUserland: string): boolean { + return runningKernel.trim() !== installedUserland.trim(); +} + export function selectJailUpdateTargets( rows: BastilleJailRow[], dbRuntime: DbRuntime, @@ -207,6 +231,29 @@ export async function run(argv: string[]): Promise { dbJailNames, ); + // pkg audit runs first — read-only CVE scan against installed packages. + // It always runs (even in dry-run) since it never changes system state. + let pkgAudit: PkgAuditResult = { ok: true, vulnerableCount: 0, summary: 'not_run' }; + try { + const auditOut = execSync('pkg audit -F', { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + pkgAudit = parsePkgAudit(auditOut); + } catch (err) { + // pkg audit exits non-zero when vulnerabilities exist — capture stdout regardless. + const e = err as { stdout?: string; message?: string }; + if (typeof e.stdout === 'string') { + pkgAudit = parsePkgAudit(e.stdout); + } else { + pkgAudit = { + ok: false, + vulnerableCount: -1, + summary: e.message || 'pkg audit failed', + }; + } + } + emitStatus('SYSTEM_UPDATE', { STATUS: opts.dryRun ? 'dry-run' : 'planned', HOST_BEFORE: hostBefore.raw, @@ -216,6 +263,8 @@ export async function run(argv: string[]): Promise { DB_RUNTIME: opts.dbRuntime, DB_JAIL: targets.dbJail || '-', SKIPPED_THICK: formatJailList(targets.skippedThickJails), + PKG_AUDIT: pkgAudit.summary, + PKG_AUDIT_VULNS: String(pkgAudit.vulnerableCount), }); logger.info( @@ -258,6 +307,21 @@ export async function run(argv: string[]): Promise { ? jailsAfter.find((row) => row.name === targets.dbJail)?.release || '-' : '-'; + // Reboot is pending when the running kernel string (uname -r) differs + // from the userland on disk (freebsd-version). The platform never auto- + // reboots — it surfaces this flag for the operator to decide. + let runningKernel = ''; + let installedUserland = ''; + try { + runningKernel = execSync('uname -r', { encoding: 'utf-8' }).trim(); + installedUserland = execSync('freebsd-version', { encoding: 'utf-8' }).trim(); + } catch { + // best-effort + } + const reboot = runningKernel && installedUserland + ? rebootPending(runningKernel, installedUserland) + : false; + emitStatus('SYSTEM_UPDATE', { STATUS: 'success', HOST_AFTER: hostAfter.raw, @@ -266,6 +330,10 @@ export async function run(argv: string[]): Promise { THIN_UPDATED: formatJailList(targets.thinJails), DB_UPDATED: dbAfter, SKIPPED_THICK: formatJailList(targets.skippedThickJails), + PKG_AUDIT: pkgAudit.summary, + PKG_AUDIT_VULNS: String(pkgAudit.vulnerableCount), + REBOOT_PENDING: reboot ? 'yes' : 'no', + ...(reboot ? { RUNNING_KERNEL: runningKernel, INSTALLED_USERLAND: installedUserland } : {}), }); }