I have a home server with NFS shares, a couple of SSHFS mounts for remote dev boxes, and an rclone remote pointed at cloud storage. Each one has its own mount script, its own systemd unit (hand-written or cargo-culted from the Arch Wiki), and its own set of flags I have to look up every time something breaks.
Last week I sat down and wrote mntctl, a CLI tool that wraps all of these behind a single interface modeled after systemctl. The idea: mntctl add, mntctl start, mntctl enable, mntctl status, same verbs regardless of whether the underlying mount is FUSE-based, kernel-native, or encrypted.
The problem
Every mount backend has its own CLI, its own options format, and its own approach to persistence:
sshfs → sshfs user@host:/path ~/mnt -o reconnect,ServerAliveInterval=15
rclone → rclone mount remote:bucket ~/mnt --vfs-cache-mode full
NFS → mount -t nfs server:/export ~/mnt -o vers=4,soft
SMB → mount -t cifs //server/share ~/mnt -o credentials=/etc/samba/credsMaking any of these persistent means writing a systemd unit by hand, and the unit format differs too. FUSE mounts need .service units that run the daemon in the foreground. Kernel mounts need .mount units with path-encoded filenames. Getting the [Unit], [Mount], or [Service] sections right for each backend is the kind of thing you do once, forget, and then get wrong the next time.
The interface
mntctl uses systemctl vocabulary because the mental model transfers directly:
# Add a mount (creates ~/.config/mntctl/mounts/devbox.toml)
mntctl add devbox -t sshfs -s user@10.0.0.5:/home/user -T ~/mnt/devbox \
-o reconnect,ServerAliveInterval=15
# Transient mount (not persistent across reboot)
mntctl start devbox
# Persistent mount (installs a systemd user service)
mntctl enable devbox
# Check what's going on
mntctl status devbox
mntctl list
# Tear down
mntctl stop devbox
mntctl disable devbox
mntctl remove devboxThe configuration is a TOML file per mount, stored in ~/.config/mntctl/mounts/:
# ~/.config/mntctl/mounts/devbox.toml
name = "devbox"
backend = "sshfs"
source = "user@10.0.0.5:/home/user"
target = "~/mnt/devbox"
[options]
reconnect = ""
ServerAliveInterval = "15"System-level mounts (NFS, SMB) use --system which routes through pkexec for privilege escalation and stores configs in /etc/mntctl/mounts/.
Backend architecture
The core abstraction is a Backend trait:
pub trait Backend: Send + Sync {
fn name(&self) -> &str;
fn backend_type(&self) -> BackendType;
fn mount(&self, config: &MountConfig) -> Result<()>;
fn unmount(&self, config: &MountConfig) -> Result<()>;
fn is_mounted(&self, config: &MountConfig) -> Result<bool>;
fn validate_config(&self, config: &MountConfig) -> Result<()>;
fn generate_systemd_unit(&self, config: &MountConfig) -> Result<SystemdUnit>;
fn required_binaries(&self) -> Vec<&str>;
}Each backend implements this trait. The registry maps backend types to implementations; no macros, no dynamic loading, just explicit registration:
impl BackendRegistry {
pub fn new() -> Self {
let mut registry = Self { backends: HashMap::new() };
registry.register(Box::new(sshfs::SshfsBackend));
registry.register(Box::new(rclone::RcloneBackend));
registry.register(Box::new(nfs::NfsBackend));
registry.register(Box::new(smb::SmbBackend));
registry
}
}Adding a new backend means implementing the trait, dropping a file in src/backend/, and adding one line to the registry. The generate_systemd_unit method is the interesting part; it’s where the per-backend knowledge lives. FUSE backends generate .service units that run the mount daemon in the foreground. Kernel backends generate .mount units with path-encoded filenames that systemd expects.
FUSE vs kernel mounts
The split between FUSE and kernel mounts runs through the entire design:
| FUSE (sshfs, rclone) | Kernel (NFS, SMB) | |
|---|---|---|
| Privilege | User-level | Root (via pkexec) |
| systemd unit | .service (user scope) | .mount (system scope) |
| Unmount | fusermount -u | umount |
| Mount check | /proc/mounts + FUSE type | /proc/mounts + fs type |
| Daemon | Foreground process | Kernel handles it |
FUSE mounts share enough logic that common helpers (fuse_unmount, fuse_is_mounted, check_binaries) are extracted into the backend module. Kernel mounts still differ in their mount helpers and backend-specific options, but they share the .mount unit generation pattern.
The doctor subcommand
Before anything fails at mount time, mntctl doctor checks the system:
$ mntctl doctor
Checking system dependencies...
systemd (systemctl) ✓
/proc/mounts ✓
sshfs ✓
rclone ✓
mount.nfs ✗ (install nfs-common)
mount.cifs ✓This is one of those features that saves 20 minutes of debugging the first time someone installs the tool on a minimal system. Each backend declares its required binaries via required_binaries(), and the doctor subcommand iterates all registered backends.
What’s next
Four backends are implemented (sshfs, rclone, NFS, SMB). Three encrypted filesystem backends are planned: gocryptfs, cryfs, and encfs. These follow the FUSE pattern but add password management, prompting at mount time or integrating with a keyring.
The source is at github.com/sfoerster/mntctl.