From ccd104a28bd80a62c89fffe06bf74e276ab96636 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 4 Aug 2026 03:16:17 +0000 Subject: [PATCH] caldav/carddav: officer-caldav sidecar and the /dav door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/nextcloud-replacement.md | 244 +++++++++++++++++++ ecosystem.config.cjs | 10 + src/databases/officer_db/src/index.ts | 8 + src/databases/officer_db/src/queries/dav.ts | 113 +++++++++ src/databases/officer_db/src/schema/dav.ts | 46 ++++ src/databases/officer_db/src/schema/index.ts | 1 + src/server.tsx | 8 + src/servers/api/dav/router.ts | 45 ++++ src/servers/api/dav/sidecar-server.ts | 17 ++ src/servers/api/dav/sync-router.ts | 125 ++++++++++ src/servers/hono.ts | 19 ++ src/servers/sidecar/caldav/index.ts | 204 ++++++++++++++++ src/servers/sidecar/caldav/radicale.ts | 112 +++++++++ src/servers/sidecar/protocol.ts | 3 + 14 files changed, 955 insertions(+) create mode 100644 docs/nextcloud-replacement.md create mode 100644 src/databases/officer_db/src/queries/dav.ts create mode 100644 src/databases/officer_db/src/schema/dav.ts create mode 100644 src/servers/api/dav/router.ts create mode 100644 src/servers/api/dav/sidecar-server.ts create mode 100644 src/servers/api/dav/sync-router.ts create mode 100644 src/servers/sidecar/caldav/index.ts create mode 100644 src/servers/sidecar/caldav/radicale.ts diff --git a/docs/nextcloud-replacement.md b/docs/nextcloud-replacement.md new file mode 100644 index 00000000..9aa5d96c --- /dev/null +++ b/docs/nextcloud-replacement.md @@ -0,0 +1,244 @@ +# Replacing NextCloud + +Written 2026-08-04. Decision document for the three things NextCloud is actually used for here: +**calendar, contacts, and file sync.** Everything else NextCloud ships is out of scope and stays out. + +`nextcloud:33` is currently running as a container on this machine (`172.20.0.24:80`, behind NPM). The +goal is to retire it, not to sit beside it. + +--- + +## The rule this document is written against + +The owner's ask: _"as long as the experience for the user, be it on the platform web UI or in the +phone, be as easy and transparent as NextCloud provides."_ + +That is the acceptance test, and it has a specific consequence that drives almost every decision +below: **the phone is the hard part, and the phone does not speak Officer.** DAVx5, iOS Calendar and +Thunderbird speak CalDAV/CardDAV over HTTPS with HTTP Basic auth and `.well-known` autodiscovery. +They cannot log into Officer, cannot hold a 30-day JWT, and cannot be persuaded to. Any design that +does not produce a plain, standards-compliant DAV endpoint fails the acceptance test no matter how +good the web UI is. + +--- + +## Decision 1 — do not implement CalDAV/CardDAV + +**NextCloud does not implement CalDAV either.** It vendors `sabre/dav` and writes storage backends +and a UI on top. "Reimplementing NextCloud" honestly described is "adopting a DAV library and owning +the storage and the UI" — which is exactly the sidecar shape this platform already has eight +instances of. + +The formats are the easy half and are genuinely a weekend: + +| Thing | Difficulty | Why | +| ------------------------------- | ---------- | -------------------------------------------------------------- | +| Parse/emit iCalendar (RFC 5545) | Easy | Mature libraries; the grammar is small | +| Parse/emit vCard (RFC 6350) | Easy | Same | +| `PROPFIND` / `REPORT` semantics | Hard | Depth handling, property discovery, partial responses | +| `RRULE` expansion | Hard | Leap years, DST, `BYSETPOS`, `EXDATE`, infinite series | +| `VTIMEZONE` | Hard | Clients ship conflicting tz databases | +| `sync-collection` REPORT | Hard | Sync tokens, tombstones, truncation | +| ctag / etag / `If-Match` | Hard | Get it wrong and clients silently duplicate or drop events | +| Per-client quirks | Hardest | iOS, DAVx5 and Thunderbird each want slightly different things | + +The failure mode of getting these wrong is not a crash. It is a phone that **silently stops syncing**, +or worse, silently duplicates every event. You find out weeks later, from your calendar being wrong. +That is a bad thing to own for a feature whose entire value is that you can trust it. + +**Decision: run a proven DAV server as a sidecar, supervised and proxied, exactly like `officer-slskd` +and `officer-invoiceshelf` already are. Own the storage location, the auth, and the UI. Do not own +the protocol.** + +### Which server + +| Option | Language | Verdict | +| ------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Radicale** | Python | **Chosen.** Small, file-backed, no database, 15+ years old, the standard recommendation for exactly this job. Critically it has an auth mode that trusts a header (`http_x_remote_user`), which is precisely how every other sidecar here is designed to be fronted. | +| Baïkal | PHP | `sabre/dav` behind a web UI. Means adding PHP-FPM to the machine for one service. Heavier for no benefit — we are not using its UI. | +| Xandikos | Python | Git-backed, elegant, but a smaller install base and less battle-tested against iOS specifically. Reconsider only if Radicale disappoints. | +| Write our own | — | Rejected above. | + +Radicale is a good citizen of this architecture _because_ of the header auth: it lets the sidecar be +the only thing that decides who the user is, which is the same contract `X-Officer-User` already +encodes everywhere else. + +--- + +## Decision 2 — two front doors, not one + +This is the part that is easy to get wrong, so it is stated explicitly. + +``` + phone / Thunderbird Officer web UI + (DAVx5, iOS, macOS) (React panel apps) + │ │ + │ CalDAV over HTTPS │ JSON over HTTPS + │ HTTP Basic (app password) │ platform JWT + ▼ ▼ + ┌───────────────────────────┐ ┌──────────────────────────┐ + │ officer: /dav/* │ │ officer: /api/caldav/* │ + │ mounted TOP-LEVEL, │ │ protectedRouter, │ + │ Basic-auth gate, │ │ createSidecarProxy │ + │ forwards verbatim │ │ (X-Officer-User) │ + └───────────┬───────────────┘ └────────────┬─────────────┘ + │ │ + └──────────────► officer-caldav ◄───────────┘ + (Bun sidecar) + │ + supervises + fronts Radicale + │ + DATA_PATH/dav/collections/… +``` + +**Why not one door.** The browser should not speak DAV. `PROPFIND` and multistatus XML in React is a +lot of machinery to render a month view, and it would make the web UI as fragile as the protocol. The +sidecar already has the collections on local disk; it can serve the UI a small JSON API over the same +data and keep DAV as the machine-facing interface. This is the same split `officer-email` uses — the +sidecar owns the store and serves both a sync path and a UI path. + +**Why the DAV door is mounted top-level.** There is precedent in this codebase and it is exactly +analogous: `/api/vault` is mounted outside `protectedRouter` because "the Bitwarden client carries its +own bearer token, not a platform session JWT, so userMiddleware would 401 it" (`hono.ts:80-84`). DAV +clients are the same situation with a different credential type. + +**Why `createSidecarProxy` cannot carry the DAV door.** Three concrete blockers, all in +`src/servers/sidecar/create-proxy.ts`: + +1. It forwards only `content-type`, `range` and `if-none-match` (line 88). DAV needs `Depth`, + `If-Match`, `Destination`, `Overwrite`, `Lock-Token` and `Prefer` — dropping `Depth` alone breaks + `PROPFIND` completely. +2. It sets `X-Officer-User` from `ctx.get('user')` (line 94), which requires `userMiddleware`, which + requires a JWT. +3. `router.all()` needs to route `PROPFIND`, `REPORT`, `MKCOL`, `MKCALENDAR`, `COPY`, `MOVE`, `LOCK` + and `UNLOCK`. **Verified 2026-08-04: Hono's `all()` matches non-standard verbs**, and `.on([...])` + works too if an explicit list is ever wanted. This was the riskiest assumption in the design, so it + was tested against a throwaway Bun server before anything was built on it. + +The proxy factory's own comment says it "must never grow per-app logic". Respect that: the DAV door is +its own small file, not a branch inside the shared factory. + +--- + +## Decision 3 — app passwords, because phones cannot do JWT + +A new credential type is unavoidable. NextCloud solved this the same way and for the same reason. + +- New table `dav_app_passwords`: `id`, `user_id`, `label`, `password_hash`, `last_used_at`, + `created_at`, `revoked_at`. +- Generated server-side, high entropy, **shown exactly once**, stored only as a hash. +- Scoped to DAV. It is not a general Officer credential and must never be accepted by + `userMiddleware`. +- Revocable individually from the UI, with `last_used_at` shown so a stale device is visible. + +Rationale for not reusing the account password: it ends up typed into a phone, stored in that phone's +account manager in recoverable form, and synced to whatever backs that phone up. One password per +device, revocable per device, is the whole point. + +The single-user invariant holds — every app password belongs to the owner. `user_id` is there for +referential integrity, not multi-tenancy. + +--- + +## Decision 4 — autodiscovery is not optional + +This is most of the "just works" the acceptance test is about. Typing a bare domain and having the +phone find the calendars is the difference between transparent and fiddly. + +- `GET /.well-known/caldav` → `301` to the DAV root +- `GET /.well-known/carddav` → `301` to the DAV root +- Both mounted at the very top of the app, above every auth gate — clients probe them unauthenticated + first. +- The DAV root must answer `PROPFIND` with `current-user-principal` so the client can walk to the + principal, then to the home sets, then to the collections. Radicale does this correctly; the + platform's job is only to not break it in transit. + +If `.well-known` is missing, iOS in particular degrades to demanding a full path, which is exactly the +kind of thing that makes a self-hosted setup feel worse than the commercial one. + +--- + +## Decision 5 — storage layout + +``` +DATA_PATH/dav/ +├── collections/ # Radicale's collection root (its own on-disk format — do not hand-edit) +│ └── / +│ ├── / +│ └── / +└── radicale.conf # generated by the sidecar at boot, never hand-written +``` + +Under `DATA_PATH`, so it is inside the existing backup story rather than beside it. The sidecar owns +this directory entirely; the platform never opens it, the same way it never opens the email sidecar's +SQLite stores. + +--- + +## What is deliberately NOT being built + +Naming these now so they do not creep in later: + +- **iTIP/iMIP scheduling** — sending invitations and processing RSVPs by email. Genuinely complex, and + a single-user personal calendar mostly consumes invitations rather than issuing them. Revisit only + on a concrete need. +- **Sharing, ACLs, federation** — single-user is a hard invariant of this platform. There is nobody + to share with. +- **Reimplementing RRULE on the server.** The sidecar stores what the client sends. Expansion happens + where it is displayed, using a library. +- **A NextCloud-compatible API.** Nothing needs to pretend to be NextCloud. The standards are the + compatibility layer. +- **Migrating data automatically.** Export from the running NextCloud as `.ics`/`.vcf` and import + once. A one-time manual step is cheaper and safer than a migration tool used exactly once. + +--- + +## File sync — the harder half + +Separated because the answer is genuinely different, and because conflating them is how this becomes +a six-month project. + +**Sync is not a mount.** WebDAV gives a remote filesystem: nothing works offline, everything is slow +on mobile data, and a dropped connection is a failed save. What NextCloud's desktop client actually +provides is a local-first replica with change detection, conflict handling and background transfer. +That is the thing being asked for, and it is a hard, well-studied problem. + +**Recommendation: Syncthing, run as `officer-syncthing`, not a reimplementation.** + +| Option | Verdict | +| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Syncthing** | Mature, open source, peer-to-peer, has real Android and desktop clients, handles conflicts explicitly, no server-side account model to build. Officer's job becomes: supervise it, own its config, expose a UI, and point the file browser at the synced folder. | +| WebDAV from the sidecar | Solves "see my files remotely", does not solve "have my files offline". Complementary at best, not a substitute. Cheap to add later since a DAV server is already in the architecture. | +| Reimplement NextCloud's sync protocol | Chunked upload, ETag bookkeeping, a local state database, conflict resolution, and a client on three platforms. This is the single largest thing on the whole list. Not defensible when Syncthing exists. | + +**Known gap, stated honestly:** Syncthing's iOS story is weak — iOS background execution rules make a +true always-on sync client hard, which is precisely why NextCloud's own iOS app is a manual-ish +experience too. If iOS file sync is a hard requirement, the realistic answer is a WebDAV mount for +browsing plus explicit upload from the mobile app, and that is a decision for the owner rather than +something to be assumed. + +**Officer's deliverable for file sync** is therefore: run and supervise Syncthing, own its +configuration, replicate its UI (folders, devices, status, conflicts) as a panel app, and surface the +synced tree in the existing file browser. Not a new sync engine. + +--- + +## Build order + +Sequenced so that each step is independently useful and the riskiest assumption is tested first. + +1. **`officer-caldav` sidecar** — supervise Radicale on a loopback port, generate its config, + register with the platform. _Proves the sidecar shape._ +2. **`/dav/*` door + app passwords + `.well-known`** — the phone path. + **_This is the step that must be proven with a real phone._** Everything downstream is comfort; + this is the acceptance test. +3. **JSON read API on the sidecar** — list calendars/address books, list events in a range, list + contacts. Enough for a read-only UI. +4. **Calendar + contacts panel apps** — month/week/agenda, contact list and detail. Upstream parity + first. +5. **Write path from the UI** — create/edit/delete, going through the sidecar so DAV clients see the + same data. +6. **File sync** — separate effort, starting from the Syncthing decision above. + +Steps 1–2 are the whole of the "transparent on the phone" requirement. If the night runs out, it +should run out after 2, not before. diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 35f270b0..b0f7579c 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -92,6 +92,16 @@ module.exports = { args: 'run src/servers/sidecar/invoiceshelf/index.ts', watch: false, }, + // Calendar and contacts. Supervises Radicale (CalDAV/CardDAV) on a loopback port and owns the + // collections under DATA_PATH/dav. Two doors: /dav for phones (DAVx5, iOS, Thunderbird — HTTP Basic + // against a scoped app password) and /api/caldav for Officer's own UI. The protocol is Radicale's; + // the platform authenticates and forwards. See docs/nextcloud-replacement.md. + { + name: 'officer-caldav', + script: 'bun', + args: 'run src/servers/sidecar/caldav/index.ts', + watch: false, + }, // The photo library. Wraps a self-hosted Immich. The instance and its key are set by the owner from // /photos/settings and stored encrypted in `photos_config` — read here, never from the environment, // because Bun auto-loads `.env` into every process in this directory and `officer` would hold it too. diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index a4a476e3..c47f6e6d 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -163,6 +163,14 @@ export { recordPhotosProbe, } from './queries/photos'; export type { PhotosAccount, PhotosCredentials } from './queries/photos'; +export { + listDavAppPasswords, + createDavAppPassword, + revokeDavAppPassword, + deleteDavAppPassword, + verifyDavAppPassword, +} from './queries/dav'; +export type { DavAppPassword, DavAppPasswordView } from './queries/dav'; export { getServiceConnection, getServiceCredentials, diff --git a/src/databases/officer_db/src/queries/dav.ts b/src/databases/officer_db/src/queries/dav.ts new file mode 100644 index 00000000..0243c863 --- /dev/null +++ b/src/databases/officer_db/src/queries/dav.ts @@ -0,0 +1,113 @@ +import { and, desc, eq, isNull } from 'drizzle-orm'; +import argon2 from 'argon2'; +import { randomBytes } from 'node:crypto'; +import { db } from '../db'; +import { davAppPasswords } from '../schema/dav'; + +export type DavAppPassword = typeof davAppPasswords.$inferSelect; + +/** What the UI is allowed to see: everything except the hash. */ +export type DavAppPasswordView = Omit; + +const view = (row: DavAppPassword): DavAppPasswordView => { + const { passwordHash: _hash, ...rest } = row; + return rest; +}; + +// Base32-ish over an unambiguous alphabet: no 0/O/1/I/l, because this gets read off a screen and typed +// into a phone by hand. Grouped into blocks of four for the same reason. +const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'; + +function generateSecret(): string { + const bytes = randomBytes(20); + // 20 bytes over a 31-char alphabet ≈ 99 bits. Well past anything Basic auth over TLS needs, and it + // still fits in five readable blocks. + const chars = Array.from(bytes, (b) => ALPHABET[b % ALPHABET.length]).join(''); + return (chars.match(/.{1,4}/g) ?? [chars]).join('-'); +} + +export async function listDavAppPasswords(userId: number): Promise { + const rows = await db + .select() + .from(davAppPasswords) + .where(eq(davAppPasswords.userId, userId)) + .orderBy(desc(davAppPasswords.createdAt)); + return rows.map(view); +} + +/** + * Mint a credential. The plaintext is returned HERE AND NOWHERE ELSE — it is not stored, so this return + * value is the only chance the owner gets to see it. + */ +export async function createDavAppPassword( + userId: number, + label: string, +): Promise<{ entry: DavAppPasswordView; password: string }> { + const password = generateSecret(); + const [row] = await db + .insert(davAppPasswords) + .values({ + userId, + label, + passwordHash: await argon2.hash(password), + hint: password.slice(0, 8), + }) + .returning(); + if (!row) throw new Error('failed to create dav app password'); + return { entry: view(row), password }; +} + +export async function revokeDavAppPassword(userId: number, id: number): Promise { + const [row] = await db + .update(davAppPasswords) + .set({ revokedAt: new Date() }) + .where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt))) + .returning(); + return !!row; +} + +export async function deleteDavAppPassword(userId: number, id: number): Promise { + const [row] = await db + .delete(davAppPasswords) + .where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId))) + .returning(); + return !!row; +} + +/** + * Verify a Basic-auth credential, returning the owning user id. + * + * Every live credential is tried, because the username a DAV client sends is the account email, not a + * row id — there is nothing in the request that says WHICH device is calling. That means the cost is + * one argon2 verify per stored credential, which is why revoked rows are filtered in SQL and why the + * UI should encourage deleting devices that are gone rather than accumulating them. + * + * `lastUsedAt` is written on success, at most once a minute: a syncing phone hits this constantly and + * the column exists to answer "is this device still around", not to be an access log. + */ +export async function verifyDavAppPassword(userId: number, password: string): Promise { + const rows = await db + .select() + .from(davAppPasswords) + .where(and(eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt))); + + for (const row of rows) { + let ok = false; + try { + ok = await argon2.verify(row.passwordHash, password); + } catch { + ok = false; // a corrupt hash must not take the whole login path down + } + if (!ok) continue; + + const now = Date.now(); + if (!row.lastUsedAt || now - row.lastUsedAt.getTime() > 60_000) { + await db + .update(davAppPasswords) + .set({ lastUsedAt: new Date(now) }) + .where(eq(davAppPasswords.id, row.id)); + } + return row.userId; + } + return null; +} diff --git a/src/databases/officer_db/src/schema/dav.ts b/src/databases/officer_db/src/schema/dav.ts new file mode 100644 index 00000000..ed108d45 --- /dev/null +++ b/src/databases/officer_db/src/schema/dav.ts @@ -0,0 +1,46 @@ +import { pgTable, serial, integer, text, timestamp, index } from 'drizzle-orm/pg-core'; +import { users } from './auth'; + +// Per-device credentials for CalDAV / CardDAV clients — DAVx5, iOS, macOS, Thunderbird. +// +// A new credential type is unavoidable and NextCloud solved it the same way for the same reason: a DAV +// client cannot log into Officer. It speaks HTTP Basic on every request and has nowhere to put a 30-day +// JWT, no way to refresh one, and no way to answer a passkey challenge. Without this table the only +// credential a phone could carry is the account password itself — which then lives in that phone's +// account manager in recoverable form, and in whatever backs the phone up. +// +// So: one password per device, revocable per device, hashed with argon2 like the account password and +// SHOWN EXACTLY ONCE at creation. Nothing reads the plaintext back, because nothing stores it. +// +// SCOPE IS LOAD-BEARING. These are accepted ONLY by the /dav mount. `userMiddleware` must never look at +// this table — an app password is not a session, and a device that syncs a calendar has no business +// reaching the wallet or the vault. +// +// `revokedAt` rather than a delete, so a revoked credential stays visible in the UI ("this phone had +// access until Tuesday") instead of silently vanishing. `lastUsedAt` is what makes a stale device +// noticeable at all. +export const davAppPasswords = pgTable( + 'dav_app_passwords', + { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + /** What the owner calls the device — "Pixel 9", "iPad". The only way to tell two rows apart in the UI. */ + label: text('label').notNull(), + /** argon2 hash. Deliberately not unique: two devices could in principle collide and it would not matter. */ + passwordHash: text('password_hash').notNull(), + /** + * First 8 chars of the generated secret, stored in the clear on purpose. It is not enough to + * authenticate with, and it is the only way for the owner to match a row in this list against the + * password saved on a device they are looking at. + */ + hint: text('hint').notNull(), + lastUsedAt: timestamp('last_used_at', { withTimezone: true }), + revokedAt: timestamp('revoked_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + // Every DAV request authenticates, and a phone polls often. This index is the difference between that + // being free and it being a sequential scan on every PROPFIND. + (t) => [index('idx_dav_app_passwords_user').on(t.userId)], +); diff --git a/src/databases/officer_db/src/schema/index.ts b/src/databases/officer_db/src/schema/index.ts index d6606be2..113dd238 100644 --- a/src/databases/officer_db/src/schema/index.ts +++ b/src/databases/officer_db/src/schema/index.ts @@ -1,6 +1,7 @@ export * from './auth'; export * from './chat-events'; export * from './dashboards'; +export * from './dav'; export * from './email'; export * from './headscale'; export * from './invoiceshelf'; diff --git a/src/server.tsx b/src/server.tsx index b7c36825..fd6b0916 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -212,6 +212,14 @@ const server = serve({ '/api/cliamp/ws': (req, server) => upgradeWs(req, server, 'cliamp'), '/api/cliamp/audio/ws': (req, server) => upgradeWs(req, server, 'cliamp-audio'), '/api/desktop/ws': (req, server) => upgradeWs(req, server, 'desktop'), + // CalDAV/CardDAV. These live OUTSIDE /api because DAV clients are given a bare domain and probe + // fixed, spec-defined paths — `/.well-known/caldav` unauthenticated, before they hold any + // credential at all. They need naming explicitly here or the `/*` SPA fallback below swallows them + // and the phone gets an HTML page where it expected a redirect. + '/.well-known/caldav': honoServer.fetch, + '/.well-known/carddav': honoServer.fetch, + '/dav': honoServer.fetch, + '/dav/*': honoServer.fetch, '/': officerWeb, '/*': officerWeb, '/api': honoServer.fetch, diff --git a/src/servers/api/dav/router.ts b/src/servers/api/dav/router.ts new file mode 100644 index 00000000..651eeaed --- /dev/null +++ b/src/servers/api/dav/router.ts @@ -0,0 +1,45 @@ +import { createRouter } from '../../create-router'; +import { listDavAppPasswords, createDavAppPassword, revokeDavAppPassword, deleteDavAppPassword } from 'officerdb'; +import * as errors from '../../custom-errors'; + +// Management of DAV app passwords, for Officer's own UI. Behind userMiddleware like everything else +// under /api — this is the owner administering their devices from a logged-in browser, which is a +// completely different act from a phone syncing (that is /dav, see sync-router.ts). +// +// The plaintext credential exists for exactly one response and is never stored, so POST is the only +// place it appears. There is deliberately no "show me it again" endpoint: if it is lost, revoke the +// row and mint another. That is cheaper than any design where the secret can be read back. + +export const davRouter = createRouter(); + +davRouter.get('/passwords', async (ctx) => { + const user = ctx.get('user'); + return ctx.json({ passwords: await listDavAppPasswords(user.id) }); +}); + +davRouter.post('/passwords', async (ctx) => { + const user = ctx.get('user'); + const body = ctx.get('body') as { label?: string } | undefined; + const label = body?.label?.trim(); + if (!label) throw errors.BAD_REQUEST('label is required'); + + const { entry, password } = await createDavAppPassword(user.id, label); + // `password` is returned once, here. Nothing else in the system can produce it again. + return ctx.json({ entry, password, username: user.email }); +}); + +davRouter.post('/passwords/:id/revoke', async (ctx) => { + const user = ctx.get('user'); + const id = Number(ctx.req.param('id')); + if (!Number.isInteger(id)) throw errors.BAD_REQUEST('invalid id'); + if (!(await revokeDavAppPassword(user.id, id))) throw errors.NOT_FOUND('no such app password'); + return ctx.json({ ok: true }); +}); + +davRouter.delete('/passwords/:id', async (ctx) => { + const user = ctx.get('user'); + const id = Number(ctx.req.param('id')); + if (!Number.isInteger(id)) throw errors.BAD_REQUEST('invalid id'); + if (!(await deleteDavAppPassword(user.id, id))) throw errors.NOT_FOUND('no such app password'); + return ctx.json({ ok: true }); +}); diff --git a/src/servers/api/dav/sidecar-server.ts b/src/servers/api/dav/sidecar-server.ts new file mode 100644 index 00000000..d093d33d --- /dev/null +++ b/src/servers/api/dav/sidecar-server.ts @@ -0,0 +1,17 @@ +import { createSidecarProxy } from '../../sidecar/create-proxy'; + +// The officer-caldav sidecar has TWO consumers in the platform and they need different things, so the +// port capture lives here and both import it. +// +// • `caldavRouter` — the ordinary JSON door for Officer's own UI. Behind userMiddleware, forwarded by +// the shared factory with `X-Officer-User`, exactly like every other sidecar. +// • `davSyncRouter` — the DAV door for phones. Cannot use the factory: it forwards only three headers +// (`create-proxy.ts:88`) and DAV dies without `Depth`, and it derives the user from a platform JWT +// that a DAV client has no way to hold. +// +// Hence one registration, two forwarders. The factory's own comment says it must never grow per-app +// logic, so the DAV-specific half is a separate file rather than a branch inside it. +const proxy = createSidecarProxy({ name: 'caldav', prefix: '/api/caldav' }); + +export const caldavRouter = proxy.router; +export const getCaldavUrl = proxy.getHttpUrl; diff --git a/src/servers/api/dav/sync-router.ts b/src/servers/api/dav/sync-router.ts new file mode 100644 index 00000000..3f7f6b8f --- /dev/null +++ b/src/servers/api/dav/sync-router.ts @@ -0,0 +1,125 @@ +import { createRouter } from '../../create-router'; +import { getUserByEmail, verifyDavAppPassword } from 'officerdb'; +import { getCaldavUrl } from './sidecar-server'; + +// The DAV door: `/dav/*`, mounted TOP-LEVEL, outside protectedRouter. +// +// WHY IT IS NOT UNDER /api AND NOT BEHIND userMiddleware. DAVx5, iOS Calendar, macOS and Thunderbird +// speak CalDAV over HTTPS with HTTP Basic auth on every single request. They cannot log into Officer, +// cannot hold a 30-day JWT, cannot refresh one and cannot answer a passkey challenge. This is the same +// situation `/api/vault` is in — "the Bitwarden client carries its own bearer token, not a platform +// session JWT, so userMiddleware would 401 it" (hono.ts) — with a different credential type. +// +// The credential is a DAV app password (`dav_app_passwords`), scoped to this mount and nothing else. +// An app password is NOT a session: userMiddleware must never learn to accept one, or a device that +// syncs a calendar would also reach the wallet. +// +// Everything is forwarded verbatim to the caldav sidecar, which forwards it to Radicale. The platform +// authenticates and moves bytes; it does not parse a single line of iCalendar. That restraint is the +// same one create-proxy.ts documents, and for the same reason. + +export const davSyncRouter = createRouter(); + +const unauthorized = () => + new Response('Unauthorized', { + status: 401, + // Without this header a DAV client will not prompt for credentials at all — it just fails. The + // realm string is what the phone shows in its password dialog. + headers: { 'WWW-Authenticate': 'Basic realm="Officer DAV", charset="UTF-8"' }, + }); + +type BasicCreds = { username: string; password: string }; + +function parseBasic(header: string | undefined): BasicCreds | null { + if (!header?.startsWith('Basic ')) return null; + let decoded: string; + try { + decoded = Buffer.from(header.slice(6), 'base64').toString('utf8'); + } catch { + return null; + } + // Split on the FIRST colon only — a generated password never contains one, but an email local-part + // legally can, and splitting greedily would break those accounts in a way that looks like a wrong + // password. + const at = decoded.indexOf(':'); + if (at < 0) return null; + return { username: decoded.slice(0, at), password: decoded.slice(at + 1) }; +} + +// Same list the sidecar forwards, and for the same reason: `Depth` decides whether PROPFIND returns a +// collection, its children or the whole tree, so dropping it makes every calendar look empty. +const REQUEST_HEADERS = [ + 'depth', + 'content-type', + 'content-length', + 'if', + 'if-match', + 'if-none-match', + 'destination', + 'overwrite', + 'lock-token', + 'timeout', + 'prefer', + 'user-agent', +] as const; + +const RESPONSE_HEADERS = [ + 'content-type', + 'dav', + 'allow', + 'etag', + 'last-modified', + 'location', + 'lock-token', + 'preference-applied', + 'vary', +] as const; + +davSyncRouter.all('/*', async (ctx) => { + const creds = parseBasic(ctx.req.header('authorization')); + if (!creds) return unauthorized(); + + const user = await getUserByEmail(creds.username); + if (!user) return unauthorized(); + + const userId = await verifyDavAppPassword(user.id, creds.password); + if (!userId) return unauthorized(); + + const base = getCaldavUrl(); + if (!base) return new Response('caldav sidecar not available', { status: 503 }); + + const url = new URL(ctx.req.url); + // The sidecar mounts DAV at /dav too, so the prefix is preserved rather than stripped — Radicale + // generates absolute hrefs in its multistatus responses, and if the path the client sees differs + // from the path the server thinks it is at, every href points somewhere that 404s. + const target = `${base}${url.pathname}${url.search}`; + + const headers: Record = {}; + for (const name of REQUEST_HEADERS) { + const value = ctx.req.header(name); + if (value) headers[name] = value; + } + headers['X-Officer-User'] = String(userId); + + const method = ctx.req.method; + const hasBody = method !== 'GET' && method !== 'HEAD'; + + let upstream: Response; + try { + upstream = await fetch(target, { + method, + headers, + body: hasBody ? await ctx.req.arrayBuffer() : undefined, + }); + } catch (err) { + console.error('[dav] proxy fetch failed', { path: url.pathname, error: String(err) }); + return new Response('caldav sidecar unreachable', { status: 502 }); + } + + const out = new Headers(); + for (const name of RESPONSE_HEADERS) { + const value = upstream.headers.get(name); + if (value) out.set(name, value); + } + return new Response(upstream.body, { status: upstream.status, headers: out }); +}); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index a5e2b4a0..3a6f3767 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -30,6 +30,9 @@ import { photosRouter } from './api/photos/router'; import { walletRouter } from './api/wallet/router'; import { vpnRouter } from './api/vpn/router'; import { terminalRouter } from './api/terminal/sidecar-server'; +import { caldavRouter } from './api/dav/sidecar-server'; +import { davSyncRouter } from './api/dav/sync-router'; +import { davRouter } from './api/dav/router'; import { notifyRouter } from './api/notify/router'; import { systemMonitorRouter } from './api/system-monitor/system-monitor'; import { activityRouter } from './api/activity/router'; @@ -84,6 +87,20 @@ honoServer.route('/api/waitlist', waitlistRouter); honoServer.route('/api/vault', vaultRouter); honoServer.get('/api/integrations/google/callback', googleCallbackHandler); +// CalDAV/CardDAV for phones and desktop clients — mounted TOP-LEVEL for the same reason the vault is: +// DAVx5, iOS and Thunderbird authenticate with HTTP Basic on every request and have nowhere to put a +// platform JWT, so userMiddleware would 401 them. The credential is a scoped DAV app password; see +// api/dav/sync-router.ts. +honoServer.route('/dav', davSyncRouter); + +// Autodiscovery. This is most of what makes adding an account on a phone feel transparent instead of +// fiddly: the client is given a bare domain and probes these paths UNAUTHENTICATED before it has any +// credential, so they must sit above every auth gate. Without them iOS in particular degrades to +// demanding a full collection URL, which is exactly the sort of thing that makes self-hosting feel +// worse than the commercial product it is replacing. +honoServer.get('/.well-known/caldav', (ctx) => ctx.redirect('/dav/', 301)); +honoServer.get('/.well-known/carddav', (ctx) => ctx.redirect('/dav/', 301)); + const protectedRouter = createRouter(); protectedRouter.use(bodyParser()); protectedRouter.use(userMiddleware); @@ -106,6 +123,8 @@ protectedRouter.route('/file-browser', fileBrowserRouter); protectedRouter.route('/music', musicRouter); protectedRouter.route('/slskd', slskdRouter); protectedRouter.route('/terminal', terminalRouter); +protectedRouter.route('/caldav', caldavRouter); // the JSON door for Officer's own calendar/contacts UI +protectedRouter.route('/dav', davRouter); // app-password management (the sync door is /dav, top-level) protectedRouter.route('/notify', notifyRouter); protectedRouter.route('/headscale', headscaleRouter); protectedRouter.route('/transmission', transmissionRouter); diff --git a/src/servers/sidecar/caldav/index.ts b/src/servers/sidecar/caldav/index.ts new file mode 100644 index 00000000..fb8904ba --- /dev/null +++ b/src/servers/sidecar/caldav/index.ts @@ -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 { + const url = new URL(req.url); + const target = `${radicale.url}${subpath}${url.search}`; + + const headers: Record = {}; + 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 + // `/1/`, 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).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')); diff --git a/src/servers/sidecar/caldav/radicale.ts b/src/servers/sidecar/caldav/radicale.ts new file mode 100644 index 00000000..20af50b6 --- /dev/null +++ b/src/servers/sidecar/caldav/radicale.ts @@ -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') }; +} diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index e56b0dd4..964b73ef 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -75,6 +75,9 @@ export type SidecarEvent = | { type: 'invoiceshelf:server'; port: number } // Photos (Immich) — the sidecar reports where its HTTP server is listening (random port) on connect | { type: 'photos:server'; port: number } + // CalDAV/CardDAV — the sidecar reports where its HTTP server is listening (random port) on connect. + // One port serves both doors: /dav (forwarded to Radicale) and /_officer (JSON for Officer's UI). + | { type: 'caldav:server'; port: number } // Wallet — the sidecar reports where its HTTP server is listening (random port) on connect | { type: 'wallet:server'; port: number } // PTY — the sidecar reports where its terminal HTTP/WS server is listening (random port) on connect