A Swift command-line utility and launchd system daemon that replaces autofs
for SMB mounts on macOS. It mounts your NAS shares, probes them for health, and
force-unmounts + remounts when a mount goes stale — the failure mode where
macOS smbfs loses its connection (ENOTCONN, "socket not connected") and wedges
until automount -cuv. smbmounter removes autofs from the picture entirely and
makes mount management explicit, observable, and recoverable.
Built and tested on macOS Sequoia (15.x / x86_64), Swift 6.2 toolchain.
macOS smbfs doesn't transparently reconnect after a half-open TCP state, and
autofs caches the dead mount and serves a stale handle. The fix that worked
manually — automount -cuv — only worked because it forced a fresh mount. This
daemon does that automatically and on purpose:
-o softalways. Stuck I/O returns errors quickly instead of hanging processes forever. This is enforced — the daemon refuses to start a mount withoutsoft.- Periodic health probe. A timeout-bounded
statof the mountpoint plus a device-id check detects a mount that has dropped. (A keepalive that writes to the share to keep the session warm can't run from the daemon — see Keepalive limitation — so the probe relies on the device-id check rather than generating SMB traffic.) - Fast, targeted recovery. A mount that has clearly vanished (reverted to the
local fs, or a dead-connection error) is force-unmounted and remounted
immediately; ambiguous failures (a single slow
stat) wait for three strikes first. Recovery retries on a capped backoff. - No autofs. No
/etc/auto_master/auto_smbentries. The daemon never callsautomount. - Credentials via the System keychain. No passwords in config,
ps, or logs.
One binary, multiple subcommands. The daemon subcommand (run by launchd) owns
one MountSupervisor state machine per configured mount:
Unmounted ──mount──▶ Mounting ──ok──▶ Mounted ──probe fail (x3)──▶ Recovering
▲ │ │ │
│ └──fail──▶ Failed◀──── backoff exhausted ───────┘
│ │
└──── idle unmount ◀── Mounted └── retry timer / network-reachable / reload / `mount` ──▶ retry
Each supervisor runs a probe timer and an idle watcher on its own serial queue. A
unix-domain control socket (/var/run/smbmounter.sock) speaks JSON-line RPC to the
CLI.
The daemon mounts via the NetFS framework (NetFSMountURLSync), not the
mount_smbfs CLI. The CLI doesn't read the Keychain (its -N flag reads a
password from nsmb.conf, and it links no Security framework); the Keychain path
lives in NetFS, which is what Finder and autofs use. The daemon reads the password
from the System keychain itself (as root) and hands it to NetFS in memory, so the
password never appears in ps or on a command line.
macOS limitation — a mounted SMB share is accessible only to the user who
mounted it. This is enforced by smbfs at the session level; file-mode bits
(noowners, dir_mode=0777, …) do not override it. So:
- If you set
local_user, the daemon mounts as that user: itchowns the mountpoint to them (macOS only lets a non-root user mount on a mountpoint they own) and performs the mount via a small privilege-dropping helper, so the share is owned by and readable by that user. This is effectively required for a usable mount on a single-user Mac — set it to your login name. - Without
local_userthe mount is owned by root and only root can read it. - There is no way to make one eagerly-mounted SMB share readable by all local users. autofs only appears multi-user because it mounts per-user, on-access, behind one path — a mechanism this eager-mount daemon deliberately does not implement. If you need every user on the machine to access the share, this tool is not the right fit (keep autofs, or mount per-user).
A keepalive would periodically write a small file to the share so the SMB session
doesn't go idle and get disconnected. The daemon can't do this for a
local_user mount. smbfs binds the share to the mounting user's login
session, so even the root daemon setuid'd to that user — running in the system
session — is denied (the same wall that stops root from reading the share;
confirmed in the field: the touch succeeds from the user's session but is denied
from the daemon's). So the daemon does not try to keep the session warm;
instead it relies on the device-id probe plus fast recovery — an idle/dropped
mount is detected and remounted within seconds. On a stable network the session
rarely idles out anyway. If your server aggressively disconnects idle SMB sessions
and you want to prevent the drop, run a keepalive from a per-user LaunchAgent
(e.g. touch <mountpoint>/.smbmounter-keepalive every minute) — it executes
inside your login session and so is allowed to write.
make build # swift build -c release
make test # swift test (32 unit tests; no network/NAS needed)No external dependencies — the TOML config parser and CLI arg parsing are hand-rolled to keep a root daemon auditable and buildable offline.
Two GitHub Actions workflows (macOS-only — there is no other target platform):
- CI (
.github/workflows/ci.yml) runs on every push / PR tomainanddevelop:swift build,swift test, and a release build. - Release (
.github/workflows/release.yml) runs on a version tag push. It builds the binary, produces a.pkginstaller and a binary tarball, and attaches them to a draft GitHub Release for review.
To cut a release, bump Constants.swift's version, commit, then tag:
git tag v1.0.0
git push origin v1.0.0 # → builds, packages, opens a draft releaseThe release .pkg is signed + notarized when these repo secrets are set,
and gracefully unsigned when they are not (handy for forks):
APPLE_CERTIFICATE_P12_BASE64, APPLE_CERTIFICATE_PASSWORD,
KEYCHAIN_PASSWORD, APPLE_TEAM_ID, APPLE_API_KEY_P8_BASE64,
APPLE_API_KEY_ID, APPLE_API_ISSUER_ID. The .p12 must hold both a
Developer ID Application identity (signs the binary) and a Developer ID
Installer identity (signs the .pkg).
Build the artifacts locally (unsigned) without tagging:
make package # → dist/smbmounter-<version>.pkg + .tar.gzsudo ./install.sh # or: sudo make installThis installs:
| Path | Purpose | Mode |
|---|---|---|
/usr/local/sbin/smbmounter |
the binary | root:wheel 0755 |
/usr/local/etc/smbmounter/config.toml |
config (example, if none exists) | root:wheel 0644 |
/Library/LaunchDaemons/com.brian.smbmounter.plist |
launchd registration | root:wheel 0644 |
/etc/newsyslog.d/com.brian.smbmounter.conf |
log rotation | root:wheel 0644 |
Then:
# 0. (first time only) migrate off autofs — see MIGRATING.md
smbmounter setup --check
# 1. edit your config
sudo vi /usr/local/etc/smbmounter/config.toml
# 2. store the SMB password in the System keychain (prompts; never on argv)
sudo smbmounter setup mammoth
# 3. load the daemon
sudo launchctl bootstrap system /Library/LaunchDaemons/com.brian.smbmounter.plist
# (or: sudo make load)
# 4. verify
smbmounter statussudo ./uninstall.sh # keeps config + logs + keychain creds
sudo ./uninstall.sh --purge # also removes config and logs
# or: sudo make uninstallsmbmounter daemon [--config <path>] Run the daemon (launchd invokes this).
smbmounter status Show state of every configured mount.
smbmounter mount <name> Force-mount a configured share.
smbmounter unmount [-f] <name> Unmount a share (-f forces).
smbmounter reload Reload config in the running daemon.
smbmounter setup <name> Store the SMB credential (interactive).
smbmounter setup --check Print the autofs migration checklist.
smbmounter probe <name> Run a one-shot health probe.
smbmounter version
status, mount, unmount, reload, and probe talk to the running daemon
over the control socket. status is readable by anyone; mutating ops require
root or membership in the staff group (so you don't need sudo for everyday
use). setup runs locally and needs sudo (it writes the System keychain).
Example:
$ smbmounter status
NAME STATE MOUNTPOINT FROM RECOV SINCE
mammoth Mounted /mammoth //floof@mammoth/mammoth 0 2026-06-01 09:30:11
/usr/local/etc/smbmounter/config.toml (TOML). See config.example.toml.
| Key | Default | Meaning |
|---|---|---|
mount_options |
["soft","nodev","nosuid","noowners"] |
mapped to NetFS mount flags (nodev/nosuid/noowners/rdonly/nobrowse). soft is enforced via NetFS SoftMount + your /etc/nsmb.conf. Must include soft. |
local_user |
(none) | mount as this local user so the share is accessible to them (see "How it mounts"). Unset = mount as root (root-only access). |
probe_interval_sec |
60 |
seconds between health probes |
probe_timeout_sec |
5 |
wall-clock timeout for the probe stat |
recover_backoff_sec |
[2,5,15,30,60] |
capped retry schedule after a probe failure |
probe_failure_threshold |
3 |
consecutive ambiguous failures (e.g. a slow stat) before recovery. A definite failure — mount reverted to local fs, or a dead-connection error — recovers immediately, ignoring this count. |
failed_retry_sec |
30 |
retry a Failed mount every N seconds (transient/network failures only — never auth/config); 0 disables. Heals the cold-boot race. |
idle_unmount_min |
0 |
unmount after N minutes with no open files (0 = never) |
mount_at_startup |
true |
mount when the daemon starts |
create_keepalive |
true |
reserved — see Keepalive limitation. Currently inert: the daemon can't keep a session-bound mount warm. |
keepalive_filename |
.smbmounter-keepalive |
name of that file |
log_level |
info |
debug | info | warn | error |
| Key | Required | Meaning |
|---|---|---|
name |
yes | unique id, [A-Za-z0-9_-]{1,32} |
server |
yes | hostname or IP |
share |
yes | SMB share name |
mountpoint |
yes | absolute path; must exist and be empty (or already our mount) |
username |
yes | SMB user; password comes from the keychain |
Any [defaults] key may be repeated inside a [[mount]] to override it.
Config is validated on load (and on reload). Validation fails fast on: a
mount missing soft, a password in mount_options, duplicate/invalid names,
relative mountpoints, or nonsensical numbers. A missing keychain credential or a
not-yet-existing mountpoint is a warning for that one mount, not a fatal
error.
With idle_unmount_min > 0, a mount with no open file descriptors for that long
is cleanly unmounted (it never force-unmounts something in use). It is not
auto-remounted — bring it back explicitly with smbmounter mount <name>. Mounts
left Unmounted for this reason are intentional; smbmounter status shows them.
- Logs:
/var/log/smbmounter.log(and.err). Alsolog show --predicate 'subsystem == "com.brian.smbmounter"' --last 1h. Setlog_level = "debug"andsmbmounter reloadfor verbose output. - "could not reach the daemon": it isn't running. Check
sudo launchctl print system/com.brian.smbmounterand the logs. - A mount is
Failed: the credential is probably missing or wrong (sudo smbmounter setup <name>), or the server is unreachable. Transient (network) failures retry automatically everyfailed_retry_secand on a network-reachable event; auth/config failures do not auto-retry (fix them, thensmbmounter mount <name>). - Stale mount returns? That's what the prober fixes — look for
entering recovery/recovery succeededlines in the log with timing. /etc/nsmb.conf: the daemon assumes your stability-tunednsmb.conf(soft=yes,notify_off=yes,dir_cache_off=yes, …) is in place. It does not touch it.- symlink/firmlink mountpoints: if
/mammothis a symlink (e.g. →/System/Volumes/Data/mammoth), the daemon resolves it withrealpathand mounts at the real target (NetFS refuses to mount onto a symlinked path). You can put either path inmountpoint; check withreadlink /mammoth.
See MIGRATING.md or run smbmounter setup --check. In short:
unmount the path, remove the auto_smb line from /etc/auto_master,
sudo automount -cv, confirm it's gone, then install smbmounter.
Copyright © 2026 Brian Martin.
smbmounter is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License v3.0 or later (GPL-3.0-or-later) as published by the Free Software Foundation. See LICENSE for the full text.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.