Notes

mntctl: A Modular Mount Manager with systemd Integration

Tired of maintaining mount scripts for SSH, NFS, SMB, and rclone, I built mntctl: a systemctl-style CLI with persistent systemd units and pluggable backends.

Why I wrote this

Remote and encrypted mounts are everywhere in Linux workflows but the tooling is fragmented; each backend has its own flags, its own fstab syntax, its own systemd unit format. This post describes a Rust CLI that unifies them behind one interface.

build log 3 min read

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/creds

Making 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 devbox

The 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)
PrivilegeUser-levelRoot (via pkexec)
systemd unit.service (user scope).mount (system scope)
Unmountfusermount -uumount
Mount check/proc/mounts + FUSE type/proc/mounts + fs type
DaemonForeground processKernel 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.