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,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)
|
||||
│ └── <user>/
|
||||
│ ├── <calendar-uuid>/
|
||||
│ └── <addressbook-uuid>/
|
||||
└── 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DavAppPassword, 'passwordHash'>;
|
||||
|
||||
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<DavAppPasswordView[]> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<number | null> {
|
||||
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;
|
||||
}
|
||||
@@ -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)],
|
||||
);
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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<string, string> = {};
|
||||
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 });
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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') };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user