one sidecar, one permission, one install — and two screens, because Calendar and Contacts are two places you go rather than two sections of one page. the first plugin to declare `screens`, which the platform gained for this. what moved: the radicale sidecar, the four routers, the panel apps, the app-password table and the settings form. the form became a modal on the calendar screen — a plugin ships panels and cannot contribute a settings section, which is the same wall the email plugin hit and the same answer. the routes are /calendar and /contacts, not /caldav/calendar. they were core routes and keeping them is not cosmetic: a bookmark, a dock tile someone arranged, and a persisted layout all address them. only a first-party plugin may choose its own segment; a third-party one is confined to /p/<publisher>/. four paths outside /api come with it, above the account gate, because phones are not browsers: DAVx5, iOS and thunderbird send HTTP basic on every request and have nowhere to put a JWT. that is not "open" — the sync door demands a scoped app password and resolves it to one user before radicale sees anything. two things deliberately unchanged. the table is still dav_app_passwords, not caldav_app_passwords: installs hold real rows and a naming convention is not worth a data migration on a live machine. and the panel names still resolve through the platform's catalogs, so the six locales they had as core screens survive rather than dropping to english to make the refactor tidier. per-user was already true and is worth writing down because the config said otherwise: the sync door resolves an app password to an account, tells radicale that id is the principal, and owner_only does the rest. the comment claiming "the owner is the only principal that exists" had been stale since multi-user landed on 2026-08-07. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
179 lines
7.2 KiB
TypeScript
179 lines
7.2 KiB
TypeScript
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') };
|
|
}
|