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-principalonly.OPTIONSunder/dav/:DAV: addressbook— the class 1, 3, and access-control claims are unnecessary — plus theAllowlist.PROPFINDon the principal:addressbook-home-setonly.PROPFINDon the address book collection:resourcetype(collection + addressbook),supported-report-setadvertisingsync-collection,getctag, andsync-token. AtDepth: 1, membergetetagentries — the collection itself needs no self-entry.REPORT addressbook-multiget:getetagplusaddress-datafor each requested href.REPORT sync-collection:getetagonly — the client refetches changed cards through multiget orGETon its own.GETa card: the vCard body plus anETagheader.
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:
| RFC | Title | Why it matters |
|---|---|---|
| 4918 | HTTP Extensions for WebDAV | PROPFIND, Depth, 207 Multi-Status, ETags |
| 3253 | Versioning Extensions to WebDAV | Defines REPORT and supported-report-set |
| 5397 | WebDAV Current Principal Extension | current-user-principal, the entry point to discovery |
| 6352 | CardDAV | Address book collections, addressbook-multiget, addressbook-query |
| 6578 | Collection Synchronization for WebDAV | sync-collection REPORT and sync tokens |
| 6764 | Locating Services for CalDAV and CardDAV | /.well-known/carddav and SRV-based discovery |
| 2426 | vCard 3.0 | The version Apple clients actually speak |
| 6350 | vCard 4.0 | The 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:
getctagin thehttp://calendarserver.org/ns/namespace, a collection-wide change tag. Without it, macOS never requests any vCards.push-transportsandpushkey, for server-initiated refresh. Not needed, but macOS asks for them on every collectionPROPFIND.
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.