Developer Documentation for Netatalk Client
AFP (Apple Filing Protocol) originated on the Mac as Apple’s network filesystem protocol. Today it is a cross-platform protocol with multiple independent client and server implementations, including Netatalk on the server side and this Netatalk Client project on the client side. A client has to establish a network connection to an AFP server, attach to a volume, and keep that session state alive while file operations are in progress.
Netatalk Client is split into a stateful protocol library, a stateless
adapter layer, and two different front-ends. libafpclient is the core
library that knows how to speak DSI and AFP, but it expects a long-lived
process that can live with its event loop, threads, signals, and connection
state.
libafpsl is the stateless API in afpsl.h. It lets short-lived tools and
other applications issue one operation at a time without embedding
libafpclient’s event loop. Under the hood, libafpsl talks over a Unix
socket to afpsld, the stateless daemon implemented in daemon/. afpsld
owns the persistent AFP server and volume state, calls the libafpclient
midlevel API, and returns one response to each stateless request. This is
the layer used by afpcmd, and it can be built without FUSE.
The user-facing clients then choose how to present AFP files to callers:
- The FUSE client in
fuse/mounts an AFP volume as a local filesystem. Applications operate on files under the mountpoint, and filesystem calls are translated into AFP operations. The AFP server decides how to store file metadata for this path;afpcmdmetadata mode is not involved. afpcmdis an ftp-style file-transfer client built fromcmdline/on top oflibafpslandafpsld. It copies data between the AFP server and a path on the ordinary local filesystem, not a FUSE mount. Its-M/--metadataoption only selects the local on-disk representation for metadata preserved during these transfers.
Both front-ends ultimately use the same AFP protocol machinery, but they
answer different questions. FUSE asks “how do I make this remote volume look
like a local filesystem?” afpcmd asks “how do I copy this remote object to
or from a regular local path?”
Architectural Diagram
┌──────────────────────────────────────────────────────────────┐
│ Client Applications │
├─────────────────────────────┬────────────────────────────────┤
│ afpcmd, GUI clients │ afpc fs, mount_afpfs │
└───────┬─────────────────────┴─────────┬──────────────────────┘
│ │
│ libafpsl.so (afpsl.h) │ (direct spawn/mount)
│ Stateless API │
↓ ↓
┌──────────────────┐ ┌─────────────────────┐
│ afpsld │ │ afpfsd │
│ (Stateless) │ │ (FUSE) │
├──────────────────┤ ├─────────────────────┤
│ • CONNECT │ │ • MOUNT/UNMOUNT │
│ • File I/O │ │ • FUSE operations │
│ • Dir ops │ │ • xattr callbacks │
│ • Metadata copy │ │ • Multi-mount mgmt │
│ modes: FI, RF, │ │ │
│ xattrs │ │ │
│ │ │ Requires libfuse │
└───────┬──────────┘ └───────┬─────────────┘
│ │
│ Calls midlevel API │ Calls midlevel API
└─────────────┬───────────────┘
↓
┌──────────────────────────────────┐
│ libafpclient.so │
├──────────────────────────────────┤
│ • midlevel (afp.h, midlevel.h) │
│ • metadata ops │
│ • lowlevel │
│ • proto_* (AFP protocol) │
│ • Engine (DSI, event loop) │
└──────────────┬───────────────────┘
↓
┌───────────────┐
│ AFP Server │
└───────────────┘
libafpclient
This shared library (libafpclient.so) implements the DSI and AFP communication
engine used by the bundled daemons and tools. Stateful consumers use its opaque
transport interface from <netatalk-client/transport.h>; concrete headers
under lib/ remain implementation details.
Applications that prefer daemon-managed connections should use the stateless
libafpsl interface from <netatalk-client/afpsl.h>.
A key point to know when building libafpclients is that libafpclient will spawn threads and override signals. Asynchronous events need to be hooked into a loop provided by libafpclient. You cannot write your own select() loop!
The major subcomponents of libafpclient are all in the lib/ directory.
They are:
Logging
log_for_client() is the private shared logging entry point. Callers pass it a complete message string; it trims
trailing newlines, escapes control characters, and forwards the sanitized text to the registered client logger.
If the message pointer is null, it is logged as (null).
Inside the netatalk-client build, log_for_client() is also available as a checked printf-style convenience macro.
Internal callers may use literal format strings:
log_for_client(priv, AFPFSD, LOG_WARNING, "Could not open %s", path);
Do not pass runtime or remote-controlled text as the format string. If a message is already composed, log it through a
literal %s format:
log_for_client(priv, AFPFSD, LOG_ERR, "%s", message);
The underlying function remains the plain-message API. Internal code that deliberately needs
to call that function directly can use the parenthesized form (log_for_client)(priv, AFPFSD, level, message) to bypass
the internal macro, but that should stay rare and explicit.
Midlevel
This is an internal API that simplifies the AFP functions and does some simplification of the protocol, such as calling multiple AFP functions to perform a basic task. It is not an application-facing interface.
Typically, a midlevel function will:
- translate filenames for you
- handle metainformation (resource forks, special files)
- call the lowlevel function
Lowlevel
This is an API that handles many AFP functions, while taking care of some AFP details, such as behaviour differences between AFP versions and situations where servers don’t adhere to the exact protocol.
An example of this is when listing a directory; ll_readdir() will figure out what AFP version is being used, and either call protocols afp_enumerateext() for AFP 2.x or afp_enumerateext2 for 3.x (which can handle larger file lists).
These are implemented in lib/midlevel.c. The API is exposed in midlevel.h.
You should generally not use these functions.
Protocol
This is the raw API that exposes individual AFP functions, this includes things like afp_listextattr().
These are implemented in lib/proto_* files and exposed in afp.h.
You should almost never use this set of functions.
Other topics
- startup
- metainformation
- scheduling
AFP Protocol Compliance
AFP 3.3 (OS X 10.6)
Replay Cache Support
AFP 3.3 mandates support for the AFP Replay Cache mechanism, which ensures reliable operation across network interruptions and reconnections.
-
Persistent Request IDs: Request IDs are no longer reset to 0 on reconnection when the server supports replay cache. They wrap around from 65535 to 1 (avoiding 0).
-
Server Capability Detection: During
DSIOpenSession, the client now parses thekServerReplayCacheSizeoption from the server’s reply to detect replay cache support. -
Dynamic Behavior:
- If server advertises replay cache support → persistent request IDs enabled
- If server doesn’t support replay cache → legacy behavior (reset to 0 on reconnect)
Two-Daemon Architecture
The project uses two separate daemon binaries with distinct purposes:
afpsld - AFP Stateless Daemon
Location: daemon/
Purpose: Handles remote AFP file operations via the stateless API
Dependencies: libafpclient.so only (NO FUSE required)
Socket: /tmp/afp_sl-<uid>
Operations:
- Connection management (CONNECT, ATTACH, DETACH)
- File operations (OPEN, READ, WRITE, CLOSE, STAT)
- Directory operations (READDIR, MKDIR, RMDIR)
- File manipulation (CREAT, UNLINK, RENAME, CHMOD, TRUNCATE)
- Volume information (STATFS)
Architecture:
- Listens on a user-specific Unix domain socket
- Spawns a thread for each incoming request
- Maintains global state for all connected servers and attached volumes
- Threads are detached and clean up automatically after responding
afpfsd - AFP FUSE Daemon
Location: fuse/
Purpose: Mounts AFP volumes as local filesystems using FUSE
Dependencies: libafpclient.so + libfuse
Socket: /tmp/afp_server-<uid>-<mountpoint-hash>
Operations:
- FUSE mount management (MOUNT, UNMOUNT, STATUS)
- Local FUSE filesystem operations
- Manager daemon coordinates multiple mounts
This daemon uses a multi-daemon model where a manager process spawns individual mount-specific daemon processes for fault isolation (see Multi-Mount Architecture section).
Multi-Mount Architecture for FUSE
Design Choice: The Netatalk Client FUSE client uses a manager daemon architecture where each mount gets its own isolated daemon process, providing fault isolation and simpler state management for multiple mounts.
Compared to a single shared daemon with per-mount multi threading or multiplexing, this design offers:
Benefits:
- Fault Isolation: One mount crashing doesn’t affect others
- Resource Isolation: Independent memory spaces and CPU usage
- Security Compartmentalization: Credential isolation per mount
Trade-offs:
- ~2-3 MB memory overhead per additional mount
- Slightly slower multi-mount startup (each daemon forks independently)
- More socket files in /tmp
Multi-Daemon Model Overview
┌─────────────────────────────────────────────────────────────┐
│ mount_afpfs afp://server/vol1 /mnt/vol1 │
│ mount_afpfs afp://server/vol2 /mnt/vol2 │
│ mount_afpfs afp://server/vol3 /mnt/vol3 │
└─────────────────────────────────────────────────────────────┘
↓ All requests go through manager
┌─────────────────────────────────────────────────────────────┐
│ afpfsd --manager (PID: 1000) [socket: afpfsd-501] │
│ ├── Tracks child PIDs: [2001, 2002, 2003] │
│ ├── Spawns mount-specific daemons on demand │
│ └── Handles coordinated shutdown (exit command) │
└─────────────────────────────────────────────────────────────┘
↓ Spawns independent mount daemons
┌──────────────────────────────┬──────────────────────────────┬──────────────────────────────┐
│ afpfsd --socket-id ... (2001)│ afpfsd --socket-id ... (2002)│ afpfsd --socket-id ... (2003)│
│ [socket: afpfsd-501-bdb4...] │ [socket: afpfsd-501-8f3e...] │ [socket: afpfsd-501-c7d2...] │
│ /mnt/vol1 FUSE mount │ /mnt/vol2 FUSE mount │ /mnt/vol3 FUSE mount │
└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘
Mount Flow
mount_afpfs afp://server/vol1 /mnt/vol1- Client computes mount socket ID via hash of
/mnt/vol1→afpfsd-501-bdb4a5c2 - Client tries to connect to mount socket → doesn’t exist
- Client connects to manager socket
afpfsd-501 - If manager doesn’t exist, client spawns it:
afpfsd --manager - Client sends
AFP_SERVER_COMMAND_SPAWN_MOUNTwith socket ID and mountpoint - Manager forks child process:
afpfsd --socket-id afpfsd-501-bdb4a5c2 - Mount daemon listens on its unique socket and performs FUSE mount
- Client receives success, sends actual mount request to mount daemon socket
Coordinated Shutdown
afpc fs exit
- Client connects to manager socket
afpfsd-501(NULL mountpoint) - Sends
AFP_SERVER_COMMAND_EXIT - Manager daemon:
- Sends SIGTERM to all tracked child PIDs
- Waits 1 second for graceful shutdown
- Sends SIGKILL to any remaining children
- Waits for all children with
waitpid() - Exits manager daemon
Result: All mounts unmounted cleanly, no orphaned processes
Key Functions:
get_daemon_filename(char *name, size_t size, const char *mountpoint)infuse/client.c- mountpoint != NULL: hashes path → unique socket per mount (e.g.,
afpfsd-501-bdb4a5c2) - mountpoint == NULL: returns manager socket (e.g.,
afpfsd-501) -
Used on all platforms (no
#ifdefbranching) -
start_manager_daemon()infuse/client.c - Forks and execs:
afpfsd --manager -
Only runs if manager socket doesn’t exist
-
start_afpfsd(const char *mountpoint)infuse/client.c - Connects to manager socket (starts manager if needed)
- Sends
AFP_SERVER_COMMAND_SPAWN_MOUNTrequest -
Manager spawns mount daemon with unique socket ID
-
run_manager_daemon()infuse/daemon.c - Listens on shared socket (e.g.,
afpfsd-501) - Handles
SPAWN_MOUNT,EXIT,PINGcommands - Tracks child daemon PIDs in linked list
-
Reaps dead children periodically via
waitpid(..., WNOHANG) -
main()infuse/daemon.c - Detects
--managerflag → callsrun_manager_daemon() - Detects
--socket-idflag → runs mount daemon on that socket - No flag: backward compatible mode (shared socket, deprecated)
Socket Naming
All socket files are created in /tmp/:
- Manager socket:
/tmp/afp_server-<uid>(e.g.,/tmp/afp_server-501) - Mount sockets:
/tmp/afp_server-<uid>-<hash>(e.g.,/tmp/afp_server-501-bdb4a5c2) - Hash is computed via djb2 algorithm on mountpoint absolute path
- Ensures unique socket per mountpoint
Management Commands (status, unmount, exit)
Use NULL mountpoint in daemon_connect(), which causes:
get_daemon_filename(NULL)→ returns manager socket name (e.g.,afpfsd-501)- Commands connect to manager daemon
Stateless Client Library and Daemon Architecture
Stateless Client Library (libafpsl)
Netatalk Client provides a stateless client library (libafpsl.so) for applications that need to perform AFP operations
without managing persistent connections or event loops.
Unlike libafpclient which requires a long-lived stateful process with its own event loop,
the stateless library delegates connection management to a daemon process.
Key characteristics:
- No event loops: Applications don’t need to integrate with libafpclient’s signal handlers or select() loops
- Daemon-managed state: Server and volume connections persist in the daemon, not the client process
- One-shot operations: Each API call sends a request to the daemon via Unix socket and receives a response
- Process isolation: Client process crashes don’t affect active AFP connections managed by the daemon
- Consumer-owned logging: Applications can route library and daemon messages into their own logging framework
Register a logger before making stateless calls:
static void app_log(void *context, int level, const char *message)
{
/* level is one of the syslog LOG_* values. */
}
afp_sl_set_log_callback(app_log, application_context);
The callback runs synchronously on the calling thread. Passing a null callback disables log delivery. Registration is process-global, matching the stateless library’s process-global connection state.
The library also provides metadata-only replacement helpers for local-to-AFP, AFP-to-local, and AFP-to-AFP copies.
Callers select the local on-disk representation on each operation with enum afp_metadata_mode;
this is the implementation behind afpcmd -M / --metadata for file transfers involving an ordinary local path.
It does not control FUSE mounts or the AFP server’s own metadata storage.
Supported modes are auto, Netatalk AppleDouble, filesystem xattrs, macOS AppleDouble, and none.
The destination must already exist.
These helpers clear represented destination metadata before copying FinderInfo, ResourceFork, and eligible generic
xattrs. They deliberately do not copy the data fork, POSIX mode, or timestamps.
In auto mode, generic xattrs use filesystem xattrs when available and Netatalk AppleDouble EA sidecars otherwise.
FinderInfo and ResourceFork use native filesystem xattrs on macOS, and macOS AppleDouble sidecars on other systems
unless Netatalk mode is selected explicitly.
The stateless API returns zero on success and negative errno values on failure. Metadata read and list calls instead
return a nonnegative byte count on success. Generic xattr values are limited to 4096 bytes,
xattr name lists to AFP_SL_XATTR_LIST_MAX, and resource forks to INT_MAX. Resource fork data is read and written in
4096-byte chunks. Positioned writes do not shorten an existing fork, except that a zero-length write at offset
zero clears it; call afp_sl_truncateresourcefork() to set its final length explicitly. afp_sl_setxattr() accepts
AFP_SL_XATTR_CREATE or AFP_SL_XATTR_REPLACE (the portable equivalents of
the system XATTR_CREATE and XATTR_REPLACE flags), but not both. afp_sl_attach() reports a volume-password challenge
through its optional status output while returning -EACCES. Consumers can classify session recovery with
afp_sl_recovery_for_error(). afp_sl_changepw() likewise uses a typed status output for password-policy details while
keeping its return value in the errno domain.
Metadata replacement is not atomic. A failure can leave partially copied destination metadata. Unsupported metadata and
values or lists above current protocol limits are reported through the optional enum afp_metadata_warning bitmask so
non-interactive consumers can apply their own policy without parsing library output.
Use cases:
- Command-line utilities (afpcmd)
- GUI applications that need asynchronous AFP operations
- Scripts and automation tools
- Applications on systems without FUSE support
Stateless Protocol Communication
The stateless library communicates with afpsld using a request/response protocol over Unix sockets:
Every daemon response ends with a structured log trailer. Each record preserves its syslog severity, and libafpsl delivers the records through the registered callback after validating the complete response. This applies to fixed and streaming operations, including reads, writes, metadata calls, and directory listings.
Connection model:
- Short-lived Unix socket connections: Most operations open a new connection to afpsld, send one request, receive one response, and close
- Persistent server/volume state: Even though socket connections are ephemeral, the server and volume state persists in afpsld’s memory
- Server ID handles: When a server is connected, afpsld returns an opaque
afpc_server_tthat identifies the authenticated AFP session. Follow-up attach requests must present this handle; afpsld does not implicitly rediscover authenticated sessions by server name. - Volume ID handles: When a volume is attached, afpsld returns an opaque
afpc_volume_tthat remains valid across separate socket connections as long as the daemon runs - Explicit session resume:
afp_sl_resume()can return an existing connectedafpc_server_twithout reauthentication, but only when the caller supplies no password and the daemon can identify one matching session. Resume authenticates the caller’s access to the per-user daemon’s existing session state, not the AFP user identity. Normalafp_sl_connect()always performs AFP authentication. - Connection reuse for CONNECT/ATTACH: The CONNECT operation keeps the socket open (
close=0flag) to allow the subsequent ATTACH to use the same connection
Request flow:
Client Process afpsld Daemon AFP Server
| | |
|--CONNECT (close=0)---->| |
| |----TCP connect---------->|
|<---server_id-----------| |
| | |
|--ATTACH server_id----->| |
| |----FPOpenVol------------>|
|<---volumeid------------| |
| [connection closes] | |
| | |
|--READDIR (close=1)---->| |
| (new socket) |----FPEnumerate---------->|
|<---file list-----------| |
| [connection closes] | |
Threading model:
- afpsld accepts connections on the main thread
- Each accepted connection spawns a new detached thread
- Thread processes one command and sends response
- Thread exits and cleans up automatically
- Server/volume state persists in global data structures protected by locks
Benefits of the Two-Daemon Approach
Separation of concerns:
- Stateless operations (afpsld) completely independent of FUSE
- FUSE mounting (afpfsd) can evolve independently
- Each daemon optimized for its specific use case
Build flexibility:
- Can build afpsld + libafpsl without FUSE dependency
- Systems without FUSE can still use AFP file operations
- Minimal dependencies for embedded or constrained environments
Process isolation:
- Stateless operations don’t affect FUSE mounts
- FUSE mount crashes don’t affect stateless operations
- Each daemon can be restarted independently
Clear naming:
- afpsld = AFP stateless daemon (file operations)
- afpfsd = AFP FUSE daemon (filesystem mounting)
- No conflict with Netatalk’s
afpdserver
Zeroconf Discovery Architecture
Zeroconf browsing is a frontend concern implemented by two layered internal
static libraries in discovery/. libafpc-discovery normalizes the provider
API and is used by every discovery frontend. libafpc-client-discovery builds
the bounded discovery command and exact service resolver once from
discovery/client/discover.c. Discovery happens before an AFP session
or mount request exists.
afpcmd afpc / mount_afpfs
| |
+---------- afpc-discovery ------------+
|
+-------+---------+
| normalized core |
+-------+---------+
Avahi or DNS-SD
|
selected endpoint
|
existing afpsld / afpfsd
|
authenticate -> volumes
The public-to-the-tree API in discovery/discovery.h normalizes service add,
update, remove, resolve, and snapshot operations. Native providers live behind
discovery/backend.h: DNS-SD is preferred on macOS, Avahi is preferred on
other supported Unix systems, and a stub preserves normal builds when neither
provider is available. Service identity is the instance, registration type,
domain, and interface tuple. Resolved targets, advertised ports, address
families, IPv6 scope, and raw TXT data remain separate endpoint data.
The default provider session listens for both _afpovertcp._tcp and
_device-info._tcp; the bounded discovery frontend correlates matching
instance/domain/interface tuples and extracts the companion model TXT value.
The frontends deliberately have different UX responsibilities:
afpcmd --browsemaintains the live interactive service list and consolidates advertisements sharing an instance, type, and domain. Selection resolves every active interface member and prefers a non-loopback endpoint before using the existing stateless connection flow.afpc discoverprovides bounded human, verbose, and JSON snapshots from the client-discovery archive. The normal table provides human-readable information, while verbose and JSON output retain per-interface diagnostics. Verbose output also retains every device-info advertisement as an independent diagnostic entry.
Fake providers in test/ make event ordering, output, interface selection, and
ambiguity handling deterministic without requiring multicast networking in
the unit test suite.
afpcmd Implementation Using Stateless Library
Overview
The afpcmd command-line utility has been refactored to use the stateless client library (libafpsl) instead of directly calling the midlevel API. This change provides several benefits:
- No event loop management: afpcmd no longer needs to integrate with libafpclient’s event loop
- Simpler connection handling: Connection state managed by afpsld daemon
- Daemon-based architecture: Aligns with modern client design patterns
- Better fault isolation: AFP connections survive afpcmd crashes
Architecture Transition
Before (stateful direct API):
afpcmd process
├── Calls midlevel API (ml_*) directly
├── Manages struct afp_server and afp_volume locally
├── Runs afp_main_loop() in background thread
├── Integrates with signal handlers
└── Must live for duration of all operations
After (stateless daemon API):
afpcmd process afpsld daemon
├── Calls stateless API ├── Manages global server/volume state
│ (afp_sl_*) ├── Runs afp_main_loop()
├── Opens/closes Unix socket ├── Calls midlevel API (ml_*)
├── No event loop └── Spawns threads per request
├── No signal handlers
└── Can exit/restart freely
Connection Management
afpcmd maintains minimal connection state:
afpc_server_t server_id- Opaque handle returned by afpsld after CONNECTafpc_volume_t vol_id- Opaque handle returned by afpsld after ATTACHint connected- Boolean flag indicating whether a volume is attachedchar curdir[]- Current working directory path (client-side tracking)
Connection flow:
- User runs:
afpcmd afp://user:pass@server/volume - afpcmd calls
afp_sl_connect()→ afpsld connects to AFP server - afpcmd calls
afp_sl_attach(server_id, ...)→ afpsld opens volume on that authenticated session - afpcmd receives
afpc_volume_thandle and setsconnected = 1 - All subsequent commands pass this volume handle to
afp_sl_*functions - afpsld uses the volume handle to look up the volume in its global state
Volume listing and volume attachment are bound to server_id; callers should not rely on URL-only lookup to rediscover
an authenticated session.
Long-lived clients that need cross-process reuse, such as KIO workers, should first call afp_sl_resume() with no
password. This resumes the per-user daemon’s existing host session rather than reauthenticating AFP user identity. If no
matching daemon session exists, they should retrieve credentials from their own credential cache or prompt the user, then
call afp_sl_connect() to authenticate.
Disconnect flow:
- User runs:
disconnectorquit - afpcmd calls
afp_sl_detach()→ afpsld closes volume - afpcmd sets
vol_id = NULLandconnected = 0
Command Implementation
afpcmd commands map to stateless library operations:
| Command | Stateless API | Description |
|---|---|---|
| connect | afp_sl_connect() + afp_sl_attach() | Authenticate and attach to volume |
| disconnect | afp_sl_detach() | Detach from volume |
| ls/dir | afp_sl_readdir() | List directory contents |
| get | afp_sl_stat() + afp_sl_open() + afp_sl_read() + afp_sl_close() | Download files |
| put | afp_sl_creat() + afp_sl_open() + afp_sl_write() + afp_sl_close() | Upload files |
| rm | afp_sl_unlink() | Delete files |
| mkdir | afp_sl_mkdir() | Create directories |
| rmdir | afp_sl_rmdir() | Remove directories |
| mv | afp_sl_rename() | Rename/move files |
| chmod | afp_sl_chmod() | Change permissions |
| stat | afp_sl_stat() | Get file attributes |
| df | afp_sl_statfs() | Volume statistics |
Example: File Download (get command)
High-level flow:
- User runs:
get remote_file.txt local_file.txt - afpcmd calls
afp_sl_stat(vol_id, path, basename, &stat)to get file size - afpcmd calls
afp_sl_open(vol_id, path, basename, &fileid, O_RDONLY) - afpcmd loops calling
afp_sl_read(vol_id, fileid, fork=0, offset, size, &received, &eof, buffer) - Each chunk is written to local file
- afpcmd calls
afp_sl_close(vol_id, fileid)when complete
What happens in afpsld:
- Each afp_sl_* call creates a new Unix socket connection to afpsld
- afpsld finds the volume using volumeid (pointer lookup)
- afpsld calls corresponding midlevel API (ml_stat, ml_open, ml_read, ml_close)
- Response is sent back to afpcmd
- Connection closes (except for CONNECT which stays open for ATTACH)