pro-tacts

A CardDAV server for my family.

Simplifying assumptions

  • Small, trusted user group (2-3 people) — no permissions or access controls
  • Network access via Tailscale — simple authentication
  • Apple devices only (it might happen to work on other CardDAV clients, but only accidentally)

Features

  • Groups with attributes (e.g., an address shared by all members)
  • Selective sync (choose which contacts to sync rather than all-or-nothing)
  • Plaintext storage in git (version history, human-readable)

Status

Read-only and serving real data. Contacts live as KDL files under data/contacts (override the root with PRO_TACTS_DATA_DIR), one contact per file, the filename doubling as the contact ID and the vCard UID:

name "John Smith"
phone "+1-555-1234" type="mobile"
email "john@example.com"

macOS Contacts displays them over Tailscale serve as of 2026-08-14, so later work has a known-good baseline to change. See docs/plans/2026-08-12-one-card-on-macos.md for what that milestone established. Etags, the ctag, and the sync token are derived from file state — a contact's etag hashes its rendered vCard, and the collection tags hash the membership — so an edit on disk reaches synced clients on their next poll.

Requests are authenticated by the Tailscale-User-Login header that tailscale serve injects, which it strips from incoming requests so a client cannot forge one. A request without it gets a 403. That holds only while the app is reachable through serve alone — bind it to localhost. Tailscale documents two cases that carry no identity and so cannot get in: Funnel traffic, which is public, and traffic from tagged devices.

Requests the server cannot answer — a 404, a refused report, or a crash — are kept under log/unhandled, one directory per distinct request, in the same layout as test/fixtures/macos-exchange. A client asking for something unimplemented therefore leaves behind enough to implement it, and the capture can be promoted to a fixture by copying it and stripping the identifying headers. Sentry gets the request body too, minus any card content, which ProTacts::SentryScrubber redacts on the way out — hrefs and tailnet IPs are not secrets, but the cards themselves never leave the machine.

The minimal set macOS Contacts needs

The responses are the verified minimum for macOS 26.5.1 Contacts, found by removing properties and re-provisioning until the card stopped appearing (August 2026; per-round evidence in the task comments). This is a per-client property, not a universal spec: iOS and other macOS versions are untested and may need more — the fixture replay in test/fixtures/ is the harness to run when one of them misbehaves. What each response must carry:

  • PROPFIND / and /.well-known/carddav: current-user-principal only.
  • OPTIONS under /dav/: DAV: addressbook — the class 1, 3, and access-control claims are unnecessary — plus the Allow list.
  • PROPFIND on the principal: addressbook-home-set only.
  • PROPFIND on the address book collection: resourcetype (collection + addressbook), supported-report-set advertising sync-collection, getctag, and sync-token. At Depth: 1, member getetag entries — the collection itself needs no self-entry.
  • REPORT addressbook-multiget: getetag plus address-data for each requested href.
  • REPORT sync-collection: getetag only — the client refetches changed cards through multiget or GET on its own.
  • GET a card: the vCard body plus an ETag header.

Three properties are load-bearing in non-obvious ways, documented in docs/macos-contacts.md: the collection's resourcetype (without card:addressbook the client drops the account data), the sync-collection advertisement (without it the warm sync never runs), and getctag (without it no vCard is ever requested). Everything else the client asks for — displayname, privileges, owner, quotas, push transports, me-card, principal-URL, the multiget/query advertisements — is optional.

Protocol references

CardDAV is a stack of extensions rather than a single specification, so implementing it means reading several RFCs together:

RFCTitleWhy it matters
4918HTTP Extensions for WebDAVPROPFIND, Depth, 207 Multi-Status, ETags
3253Versioning Extensions to WebDAVDefines REPORT and supported-report-set
5397WebDAV Current Principal Extensioncurrent-user-principal, the entry point to discovery
6352CardDAVAddress book collections, addressbook-multiget, addressbook-query
6578Collection Synchronization for WebDAVsync-collection REPORT and sync tokens
6764Locating Services for CalDAV and CardDAV/.well-known/carddav and SRV-based discovery
2426vCard 3.0The version Apple clients actually speak
6350vCard 4.0The current version; Apple does not use it

RFC 6352 requires an address book collection to support vCard 3.0 and treats 4.0 as optional, which is why contacts are rendered as VERSION:3.0 even though docs/plans/2026-01-12-carddav-reference.md shows 4.0 examples.

Two properties macOS depends on are not in any RFC. They come from Apple's CalendarServer, which is archived but still the only written source:

  • getctag in the http://calendarserver.org/ns/ namespace, a collection-wide change tag. Without it, macOS never requests any vCards.
  • push-transports and pushkey, for server-initiated refresh. Not needed, but macOS asks for them on every collection PROPFIND.

What macOS Contacts needs

sabre/dav's notes on the macOS Address Book client are the best single source of client quirks, and they explain several failures that look like protocol bugs but are not. See docs/macos-contacts.md for the details worth keeping close, including how to turn on the client's own debug logging.

Reference implementations

servers/ holds compose files for CardDAV servers to compare against, each sitting behind mitmproxy because those servers cannot be made to log what we need. Point macOS Contacts at one, watch what it sends and what a working server sends back, then make pro-tacts match. pro-tacts itself is debugged through its own logs instead.

Monica is the one confirmed working with macOS Contacts here, so prefer it when a recording needs to be trustworthy. Its issue tracker is full of reports of the opposite, which is worth knowing before taking them at face value: the two recurring causes in monicahq/monica#4240 are a /.well-known/carddav redirect that downgrades HTTPS to HTTP, and Monica requiring an API token rather than a password. Neither is a CardDAV problem. The one genuinely protocol-level thread is the sabre/dav investigation that issue prompted, sabre-io/dav#1315, on macOS giving up when discovery answers at the wrong resource.

Two implementations are worth reading rather than running:

  • sabre/dav is the reference PHP implementation and what Baikal, Monica, and Nextcloud are all built on. Its client-quirk documentation is more valuable than its code.
  • Xandikos is a small Python CalDAV/CardDAV server backed by a git repository, which makes it the closest existing thing to what pro-tacts is trying to be. Its DAV compliance notes enumerate every method, header, property, and report against the RFC that defines it — a useful checklist for deciding what to skip.