caldav/carddav: officer-caldav sidecar and the /dav door
first two steps of docs/nextcloud-replacement.md — the half that has to work on
a phone, because that is the half that cannot be faked.
radicale is supervised by the sidecar rather than reimplemented. nextcloud does
not implement caldav either; it vendors sabre/dav. icalendar and vcard are a
weekend, but sync-collection, rrule expansion, vtimezone and ctag/etag are not,
and when they are subtly wrong a phone does not error — it silently stops
syncing, or silently duplicates every event.
two doors, because a browser should not speak dav:
/dav/* top-level, http basic against a scoped app password, every
verb and every dav header forwarded verbatim. this is what
davx5 and ios talk to. same reasoning as /api/vault being
mounted outside protectedRouter.
/api/caldav/* the ordinary sidecar proxy, for officer's own ui. json.
the shared proxy factory could not carry the dav door: it forwards three
headers and dav dies without Depth, and it derives the user from a jwt a phone
cannot hold. so it is a separate file, per that factory's own instruction never
to grow per-app logic.
new `dav_app_passwords` — a phone cannot do jwt, and the alternative is the
account password living in a phone's account manager. argon2, shown once,
revocable per device, and accepted ONLY by /dav.
.well-known/caldav and carddav redirect to the dav root. they are most of what
makes adding an account feel transparent, and they need naming explicitly in
server.tsx or the SPA `/*` fallback answers the phone with html.
verified end to end against the running stack: 401 + WWW-Authenticate
unauthenticated; 207 with calendar-access and addressbook advertised; MKCALENDAR,
PUT and GET of a real VEVENT; calendar-query and sync-collection REPORTs; MKCOL,
PUT and GET of a real vCard. X-Script-Name is set because radicale otherwise
generates hrefs at / and the client follows them into the SPA.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { startRadicale, davPaths } from './radicale';
|
||||
|
||||
// The officer-caldav sidecar. Owns the whole CalDAV/CardDAV contract: it supervises Radicale, owns the
|
||||
// collection storage under DATA_PATH/dav, and exposes two very different doors.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// HTTP CONTRACT — the platform strips its mount prefix before forwarding.
|
||||
//
|
||||
// ANY /dav/* the DAV door. Forwarded verbatim to Radicale, including PROPFIND, REPORT,
|
||||
// MKCOL, MKCALENDAR, COPY, MOVE, LOCK and UNLOCK, and every DAV header.
|
||||
// Reached from the platform's TOP-LEVEL /dav mount, which authenticates a DAV
|
||||
// app password over HTTP Basic. This is the door phones use.
|
||||
// GET /_health is Radicale up, and does it answer.
|
||||
// GET /_officer/* the JSON door for Officer's own web UI (see collections.ts).
|
||||
//
|
||||
// WHY TWO DOORS. A browser should not speak DAV — rendering a month view out of multistatus XML is a
|
||||
// lot of machinery, and it would make the web UI as fragile as the protocol. The collections are on
|
||||
// local disk here, so this sidecar can serve the UI plain JSON over the same data while DAV stays the
|
||||
// machine-facing interface. Same split officer-email already uses.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
const DATA_PATH = process.env.DATA_PATH ?? `${process.cwd()}/data`;
|
||||
|
||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||
function getFreePort(): number {
|
||||
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||
const p = probeServer.port;
|
||||
probeServer.stop(true);
|
||||
if (p == null) throw new Error('failed to acquire a free port');
|
||||
return p;
|
||||
}
|
||||
|
||||
// Where the platform exposes the DAV door publicly. It is a fixed contract between hono.ts's top-level
|
||||
// mount and this sidecar, not configuration — Radicale has to be told it so the hrefs it generates are
|
||||
// the ones the client can actually fetch.
|
||||
const DAV_MOUNT = '/dav';
|
||||
|
||||
const paths = davPaths(DATA_PATH);
|
||||
const radicale = startRadicale({ ...paths, port: getFreePort() });
|
||||
const port = getFreePort();
|
||||
|
||||
// Headers a DAV exchange cannot survive without. `Depth` alone decides whether a PROPFIND returns the
|
||||
// collection, its children, or the whole tree — drop it and every client sees an empty calendar. The
|
||||
// rest carry conditional writes (If-Match is how a client avoids clobbering a concurrent edit),
|
||||
// COPY/MOVE targets, and lock tokens.
|
||||
const DAV_REQUEST_HEADERS = [
|
||||
'depth',
|
||||
'content-type',
|
||||
'content-length',
|
||||
'if',
|
||||
'if-match',
|
||||
'if-none-match',
|
||||
'destination',
|
||||
'overwrite',
|
||||
'lock-token',
|
||||
'timeout',
|
||||
'prefer',
|
||||
'user-agent',
|
||||
] as const;
|
||||
|
||||
// Sent back untouched. `DAV` and `Allow` are how a client discovers what the server supports; getting
|
||||
// them wrong makes iOS decide the account is not a calendar at all.
|
||||
const DAV_RESPONSE_HEADERS = [
|
||||
'content-type',
|
||||
'dav',
|
||||
'allow',
|
||||
'etag',
|
||||
'last-modified',
|
||||
'location',
|
||||
'lock-token',
|
||||
'preference-applied',
|
||||
'vary',
|
||||
] as const;
|
||||
|
||||
async function forwardToRadicale(req: Request, subpath: string, userId: string): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const target = `${radicale.url}${subpath}${url.search}`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
for (const name of DAV_REQUEST_HEADERS) {
|
||||
const value = req.headers.get(name);
|
||||
if (value) headers[name] = value;
|
||||
}
|
||||
// Radicale is configured with `type = http_x_remote_user` and does no authentication of its own. It
|
||||
// binds loopback, so this header is only settable from inside this process.
|
||||
headers['X-Remote-User'] = userId;
|
||||
// Radicale generates ABSOLUTE hrefs in its multistatus bodies, and it builds them from where it
|
||||
// thinks it is mounted. Without this it answers a PROPFIND on /dav/ with
|
||||
// `<current-user-principal><href>/1/</href>`, the client dutifully requests /1/, and the platform
|
||||
// serves it the SPA — an HTML page where a calendar was expected. The client does not report a
|
||||
// useful error for that; the account just appears to have no calendars in it.
|
||||
headers['X-Script-Name'] = DAV_MOUNT;
|
||||
|
||||
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(target, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: hasBody ? await req.arrayBuffer() : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
// Path only — a DAV body is the owner's calendar and address book content.
|
||||
console.error('[caldav] radicale unreachable', { path: subpath, error: String(err) });
|
||||
return new Response('caldav upstream unreachable', { status: 502 });
|
||||
}
|
||||
|
||||
const out = new Headers();
|
||||
for (const name of DAV_RESPONSE_HEADERS) {
|
||||
const value = upstream.headers.get(name);
|
||||
if (value) out.set(name, value);
|
||||
}
|
||||
return new Response(upstream.body, { status: upstream.status, headers: out });
|
||||
}
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname: '127.0.0.1',
|
||||
// A contact photo or a calendar with years of history arrives as one PUT.
|
||||
maxRequestBodySize: 32 * 1024 * 1024,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
const officerUser = req.headers.get('X-Officer-User');
|
||||
const userId = Number(officerUser);
|
||||
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
|
||||
return new Response('missing or invalid X-Officer-User', { status: 401 });
|
||||
}
|
||||
|
||||
if (url.pathname === '/_health') {
|
||||
if (!radicale.isRunning()) {
|
||||
return Response.json({ ok: false, running: false, error: 'radicale not running' }, { status: 503 });
|
||||
}
|
||||
try {
|
||||
const probe = await fetch(`${radicale.url}/`, {
|
||||
method: 'PROPFIND',
|
||||
headers: { 'X-Remote-User': String(userId), Depth: '0' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
// Anything that answers the DAV handshake is healthy; 207 Multi-Status is the expected reply.
|
||||
return Response.json({ ok: probe.status < 500, running: true, status: probe.status });
|
||||
} catch (err) {
|
||||
return Response.json({ ok: false, running: true, error: String(err) }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === '/dav' || url.pathname.startsWith('/dav/')) {
|
||||
const subpath = url.pathname.slice('/dav'.length) || '/';
|
||||
return forwardToRadicale(req, subpath, String(userId));
|
||||
}
|
||||
|
||||
return new Response('not found', { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[caldav] listening on 127.0.0.1:${port}; radicale on ${radicale.url}; storage ${paths.storagePath}`);
|
||||
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
|
||||
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
switch (cmd.type) {
|
||||
case 'ping':
|
||||
reply({ type: 'pong', id: cmd.id });
|
||||
break;
|
||||
default:
|
||||
reply({
|
||||
type: 'error',
|
||||
id: (cmd as SidecarCommand).id,
|
||||
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: 'caldav',
|
||||
capabilities: ['caldav'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||
},
|
||||
onConnected() {
|
||||
connection.send({ type: 'caldav:server', port });
|
||||
console.log(`[caldav] reported server port ${port} to API`);
|
||||
},
|
||||
});
|
||||
|
||||
function shutdown(signal: string) {
|
||||
console.log(`[caldav] ${signal} received, shutting down...`);
|
||||
radicale.stop();
|
||||
try {
|
||||
server.stop(true);
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
connection.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -0,0 +1,112 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
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 RadicaleHandle = {
|
||||
port: number;
|
||||
url: string;
|
||||
stop: () => void;
|
||||
isRunning: () => boolean;
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
const launch = () => {
|
||||
if (stopped) return;
|
||||
child = spawn('radicale', ['--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.
|
||||
env: { PATH: process.env.PATH ?? '', 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,
|
||||
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') };
|
||||
}
|
||||
Reference in New Issue
Block a user