import { spawn, spawnSync } from 'node:child_process'; import type { ChildProcess } from 'node:child_process'; import { mkdirSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; // Supervises Radicale, the CalDAV/CardDAV server this sidecar fronts. // // WHY A REAL SERVER AND NOT OUR OWN. See docs/nextcloud-replacement.md. The short version: iCalendar and // vCard are easy, but `sync-collection` REPORT, RRULE expansion, VTIMEZONE and ctag/etag semantics are // not — and when they are subtly wrong a phone does not error, it silently stops syncing or silently // duplicates every event. Radicale has been getting that right since 2008. // // AUTH. Radicale runs with `type = http_x_remote_user`, meaning it trusts an `X-Remote-User` header // completely and does no authentication of its own. That is safe here for exactly the reason every // other sidecar's `X-Officer-User` is safe: it binds LOOPBACK ONLY, so nothing but this sidecar can // reach it, and this sidecar is only reachable through the platform, which is the thing that // authenticates. If Radicale is ever bound to a non-loopback interface this becomes an open door — // hence the explicit hostname below rather than a default. export type Launcher = { command: string; args: string[] }; export type RadicaleHandle = { port: number; url: string; stop: () => void; isRunning: () => boolean; /** Why it never started, when it never started. Null once it has launched at least once. */ startupError: string | null; }; // ── Finding radicale ── // // `spawn('radicale')` resolves against the PATH THIS PROCESS STARTED WITH, and under PM2 that is a // login-less minimum. The same trap has now bitten `tea`, the plugin dependency probe and this — so the // search path is explicit rather than inherited. // // `/usr/bin` is listed even though it is almost always in PATH already: this is the apt package's // location, it is the supported install, and a PATH mangled by a shell profile should not be able to // break the normal case. const searchPath = (): string => [join(homedir(), '.local', 'bin'), '/usr/local/bin', '/usr/bin', '/bin', process.env.PATH ?? ''] .filter(Boolean) .join(':'); /** * How to start radicale here, or null if it is not installed at all. * * Falls back to `python3 -m radicale` because radicale is a python package: a `pip install --user` in an * environment without `~/.local/bin` on PATH, or a distribution that ships the module without a wrapper * script, both leave it importable and unfindable. The module is what actually matters. */ export function resolveRadicale(pathOverride?: string): Launcher | null { const PATH = pathOverride ?? searchPath(); const direct = Bun.which('radicale', { PATH }); if (direct) return { command: direct, args: [] }; const python = Bun.which('python3', { PATH }); if (python) { const probe = spawnSync(python, ['-c', 'import radicale'], { stdio: 'ignore', env: { PATH } }); if (probe.status === 0) return { command: python, args: ['-m', 'radicale'] }; } return null; } type StartParams = { /** Where collections live. Created if absent. */ storagePath: string; /** Where the generated config is written. Never hand-edit it; it is rewritten on every boot. */ configPath: string; port: number; }; // Radicale's own config format. Regenerated at every boot on purpose: the config is derived state, and // a hand-edit that silently disagreed with what this file thinks is true would be very hard to debug. const configFor = (storagePath: string, port: number) => `# GENERATED by officer-caldav on every boot. # Hand edits are lost. See src/servers/sidecar/caldav/radicale.ts. [server] hosts = 127.0.0.1:${port} # The platform terminates TLS; this hop is loopback. ssl = False [auth] # Trusts X-Remote-User outright. Safe ONLY because of the loopback bind above — see the note in # radicale.ts before changing either. type = http_x_remote_user [storage] type = multifilesystem filesystem_folder = ${storagePath} [rights] # The owner is the only principal that exists; single-user is a platform invariant. type = owner_only [logging] level = warning `; export function startRadicale({ storagePath, configPath, port }: StartParams): RadicaleHandle { mkdirSync(storagePath, { recursive: true }); writeFileSync(configPath, configFor(storagePath, port)); let child: ChildProcess | null = null; let stopped = false; let restarts = 0; // Resolved ONCE, before anything can throw. This used to be a bare `spawn('radicale')` at module top // level, so on a machine without it the ENOENT killed the sidecar and PM2 crash-looped it — which is // the opposite of what the backoff below is for, and it took `/_health` down with it. A missing // dependency should be a sidecar that answers "radicale is not installed", not a restart loop whose // logs say ENOENT and nothing about the fix. const launcher = resolveRadicale(); if (!launcher) { const message = 'radicale is not installed. It ships with machine-setup; install it with ' + '`sudo apt install radicale` (Debian/Ubuntu), `brew install radicale` (macOS), ' + 'or `pip install --user radicale`. Calendar and contacts are unavailable until then.'; console.error(`[caldav] ${message}`); return { port, url: `http://127.0.0.1:${port}`, isRunning: () => false, startupError: message, stop: () => {}, }; } const launch = () => { if (stopped) return; child = spawn(launcher.command, [...launcher.args, '--config', configPath], { stdio: ['ignore', 'pipe', 'pipe'], // Radicale must not inherit the platform's environment wholesale — Bun auto-loads .env into every // process started in the platform directory, and there is no reason for a calendar server to hold // an Anthropic key or a database URL. // The augmented PATH, not the inherited one — `python3 -m radicale` needs to find its own // interpreter's neighbours, and a wrapper script may shell out. env: { PATH: searchPath(), HOME: process.env.HOME ?? '' }, }); child.stdout?.on('data', (buf: Buffer) => process.stdout.write(`[radicale] ${buf}`)); child.stderr?.on('data', (buf: Buffer) => process.stderr.write(`[radicale] ${buf}`)); child.on('exit', (code, signal) => { child = null; if (stopped) return; // Backoff, capped. A crash loop should be visible in the logs rather than a busy spin, and the // sidecar itself stays up so /_health can keep reporting the truth. restarts += 1; const delay = Math.min(30_000, 500 * 2 ** Math.min(restarts, 6)); console.error(`[caldav] radicale exited (code=${code} signal=${signal}); restarting in ${delay}ms`); setTimeout(launch, delay); }); }; launch(); return { port, url: `http://127.0.0.1:${port}`, isRunning: () => child !== null, startupError: null, stop: () => { stopped = true; child?.kill('SIGTERM'); child = null; }, }; } /** Resolve the paths this sidecar owns under DATA_PATH. The platform never opens any of them. */ export function davPaths(dataPath: string) { const root = join(dataPath, 'dav'); return { root, storagePath: join(root, 'collections'), configPath: join(root, 'radicale.conf') }; }