NextBSD · Userland daemon · Plan

A native AFP server — afpd

A clean-room Apple Filing Protocol server so NextBSD can serve files to Macs — the "constant" that works over either UFS or HFS+ underneath, because the fork-storage layer is abstracted. The authentic file-sharing half of the old-Mac stack.

Scoped 2026-07-16 from a source-grounded agent (AFP/DSI protocol + nextbsd-userland patterns). Half of the pragmatic "UFS + AFP" path (companion: UFS case-insensitivity patch). Planning only — no code written.

Verdict

Feasible and clean, but a real build — the biggest write-new component in either filesystem track, because there's no permissive code to wrap (netatalk is GPL, Apple's server is closed), so the protocol engine is fully original. It's the wland precedent taken further: wland shelled a stock supplicant; AFP has nothing to shell.

Critical strategic bound — AFP serves old Macs, not new ones Apple is removing the AFP client: deprecated in macOS 15.5, warned to end in macOS 27, and the macOS 27 "Golden Gate" dev beta reportedly ships with no AFP client at all. Server-side is unaffected — but it means this server is a perfect fit for System 7 → macOS 26 (exactly NextBSD's revival mission) and modern macOS 27+ will not mount it. For current-Mac interop you'd need an SMB3 path separately. Scope AFP as the old-Mac constant, not the modern-Mac story.

1Protocol surface

Transport: DSI over TCP 548. A fixed 16-byte DSI header frames every message; small command set, all MVP: OpenSession/CloseSession, Command (carries AFP requests), Write, GetStatus (pre-login server info), Tickle (keepalive), Attention (server→client). Skip AppleTalk entirely — DSI-over-TCP covers AFP 2.2 onward.

Versions: target AFP 3.x primary (advertise AFP3.1AFP3.4) — TCP-only, UTF-8 filenames, POSIX permissions, 64-bit forks; AFP 3.4 is a minor error-code tweak. Add AFP 2.2 (AFPX03) secondary for System 7 → OS 9 (31-byte Mac-Roman names). [U] the earliest classic Mac OS that speaks AFP-over-TCP vs AppleTalk-only — very early System 7 may be AppleTalk-only, which this server won't serve.

Minimal viable AFP command set

GroupCommands
Session / authFPGetSrvrInfo, FPLogin/FPLoginExt, FPLoginCont, FPLogout, FPGetSrvrParms, FPMapID/FPMapName
VolumeFPOpenVol, FPCloseVol, FPGetVolParms
Catalog / dirFPGetFileDirParms, FPSetFileDirParms, FPEnumerateExt2, FPCreateDir/FPCreateFile, FPDelete, FPRename/FPMoveAndRename, FPResolveID/FPCreateID (CNID)
ForksFPOpenFork (data or resource), FPRead(Ext), FPWrite(Ext), FPFlushFork, FPCloseFork, FPSetForkParms (EOF)
LockingFPByteRangeLock(Ext)mandatory, Mac clients rely on it
"Looks optional, Finder breaks without" → MVPFPExchangeFiles (safe-save atomic swap), FPByteRangeLockExt
LaterFPCatSearch, FPGetACL/FPSetACL, FPGetExtAttr/FPSetExtAttr (AFP 3.2+)

2Authentication (UAMs) — arm's-length via PAM

AFP auth is a pluggable UAM challenge/response in FPLogin+FPLoginCont. Implementation order:

  1. DHX2 ("DHX2") — the modern default: 1024-bit Diffie-Hellman, session key MD5(K), CAST5-CBC, fixed IVs, a 16-byte nonce exchange for mutual auth. Crypto via LibreSSL/OpenSSL BN+EVP (BSD-friendly), not libgcrypt (LGPL — netatalk's choice; pick differently to stay clean-room).
  2. Cleartext ("Cleartxt Passwrd") — 8-char, for System 7 → OS 9 that can't do DHX2. Gate behind opt-in.
  3. DHX ("DHCAST128") — bridges OS 9 / early OS X. Optional if DHX2+cleartext cover the matrix.
  4. Kerberos v5 / GSSAPI — v0.2, needs a KDC.
The server never touches the credential store Add an /etc/pam.d/afpd stack mirroring login (auth optional pam_directoryservices.soauth required pam_unix.so). Cleartext hands the password straight to pam_authenticate. DHX/DHX2/Kerberos recover the plaintext inside the UAM after decryption, then hand that to pam_authenticate — so the AFP server never reads master.passwd or the directory schema directly. User→uid/gid resolution goes through NSS (nss_directoryservices + getpwnam). Same arm's-length principle as the whole directory design. UAMs are internal statically-linked modules (a struct afp_uam table), not dlopen'd plugins — tighter trust boundary.

3Fork storage — the crux (UFS-EA · AppleDouble · HFS+-native)

Every AFP object has a data fork, a resource fork, and Finder info (+ a stable CNID) that the server must serve regardless of backing FS. One interface, swappable backends — this is the whole point: the DSI/AFP engine never knows which is underneath.

struct fork_backend {
    ssize_t (*rsrc_read/write)(vol, path, off, buf, len);
    int     (*finderinfo_get/set)(vol, path, uint8_t fi[32]);
    int     (*meta_get/set)(vol, path, struct afp_meta*);   /* AFP flags, comment, CNID */
};
BackendHow
UFS2 EA (ea=sys, default)Resource fork + metadata in UFS2 extended attributes (extattr_get/set_file, USER namespace). Adopt netatalk's documented on-disk EA/AppleDouble-v2 layout (a data format, not GPL code) for migration + dual-server interop. [U] verify UFS2 per-EA size limit — if a resource fork can exceed it, fall back to an AppleDouble sidecar for that object.
AppleDouble sidecar (ea=ad).AppleDouble/ + ._name files (AppleDouble v2). For exports/filesystems without usable EAs (NFS-backed shares). This is exactly how Apple's own UFS stored forks — direct Apple precedent.
HFS+ nativeResource fork via ..namedfork/rsrc / the HFS attr file; FinderInfo native. Selected by the vtable when a real HFS+ mount is present — the AFP command layer is unchanged.

CNID (Catalog Node ID) — AFP needs a stable 32-bit file ID surviving renames/moves. UFS inodes are stable but 64-bit + reused, so a persistent map is required: SQLite (public domain) per-volume .cnid.db. A real subsystem (allocation, rename tracking, orphan reaping), not a stub. ACLs — AFP's NFSv4-style ACEs map almost directly to FreeBSD NFSv4 ACLs on UFS2 (acl_*_file, ACL_TYPE_NFS4). Case — the server is case-policy-neutral; it advertises case-insensitivity in FPGetVolParms only when the backing mount (the UFS casefold patch or HFS+) actually provides it.


4Architecture & placement

launchd (socket-activated, TCP 548)  ─┐   Bonjour: _afpovertcp._tcp via mDNSResponder
                                      ▼
  afpd parent ── accept() ── fork() per connection ── child drops priv to authed user
        │
        ├─ ① DSI transport      (framing, tickle, request/reply demux)          write-new
        ├─ ② AFP command dispatch (FPxxx jump table, per-session state)          write-new
        ├─ ③ UAM engine         (cleartext / DHX2 / DHX) ── PAM ── directoryservicesd
        ├─ ④ VFS / volume layer (path resolve, FPEnumerate, perms)               write-new
        ├─ ⑤ fork_backend vtable (EA · AppleDouble · HFS+-native)                write-new
        └─ ⑥ CNID store         (SQLite per volume)                             reuse+glue

Reuse vs write-new: reuse — mDNSResponder, OpenPAM + pam_directoryservices + NSS, launchd, LibreSSL (DHX2), SQLite (CNID), FreeBSD extattr/acl/sendfile/flock. Write-new (clean-room, netatalk as design reference only — zero GPL code): the DSI+AFP engine, UAM handshakes, fork abstraction, CNID allocator, config parser.


5Effort & risks

MilestoneEffort
DSI + fork model + launchd/mDNS + login-cleartext (first read-only mount)~1–1.5 mo
Full MVP catalog+fork+write+locking, AFP 3.x, DHX2, EA backend + CNID, homes+3–5 mo
AppleDouble fallback, AFP 2.2 (classic Macs), ACL mapping, FPExchangeFiles, hardening+2–3 mo
MVP → trustworthy daily driver~6–9 mo
Kerberos UAM, HFS+-native backend, full interop matrixv0.2+
Risk
Clean-room correctness across Mac versions (highest)AFP is under-documented (Apple's specs are archived/incomplete). FPEnumerate flags, error-code mapping, Finder's exact FPExchangeFiles/lock expectations are empirical — a long tail of "Finder does something weird," found only by testing against real System 7 / OS 9 / OS X / macOS 11–26 clients. A physical/VM Mac test matrix is mandatory.
DHX2 crypto correctnessFixed IVs, MD5(K), CAST5-CBC, nonce byte-order must match bit-for-bit or auth silently fails. [U] confirm CAST5-CBC is enabled in NextBSD's LibreSSL (legacy cipher, may be in the legacy provider).
Fork-storage edge casesUFS2 per-EA size vs large resource forks; atomicity of data-fork + metadata-EA writes (crash between = orphaned metadata); ._ files leaking to non-Mac clients; CNID stability across mv/restore/FPExchangeFiles (CNID-DB corruption = Finder aliases break).
Byte-range lockingAFP mandatory-ish locks vs FreeBSD advisory locks — impedance mismatch; old multi-client apps (FileMaker, Quark) are the stress case.
Case dependencyCorrectness on mixed-case names needs the UFS casefold patch (or HFS+).
AFP deprecation (strategic)Server unaffected; Apple drops the client (macOS 15.5 deprecated → macOS 27 removes). Serves old Macs beautifully; modern macOS 27+ can't mount it. [U] the "macOS 27 ships with no AFP client" claim is from a single dev-beta report — high-confidence-but-unverified until GA.
Bottom line A clean-room AFP server is the largest single write-new piece in the filesystem tracks (~6–9 months), precisely because there's no permissive code to shell around. But it's the constant that makes the FS choice not matter — the same daemon serves UFS (EA/AppleDouble forks, exactly how Apple's own UFS did it) or HFS+ (native). Pair it with the UFS casefold patch and you can serve old Macs case-insensitively over AFP at a fraction of the HFS+-port cost — with eyes open that AFP is the old-Mac story (System 7 → macOS 26), and current-Mac interop is a separate SMB3 question.