Network file sharing for local networks. Mount remote directories transparently via SSHFS, coordinated by a central hub server.
HubFuse uses a hub-and-spoke architecture:
- Hub (
hubfuse-hub) — a central gRPC server that tracks devices, manages pairing, and broadcasts events. - Agent (
hubfuse) — a daemon on each device that connects to the hub, exports local directories via an embedded SSH server, and mounts remote shares via SSHFS.
All communication is secured with mTLS. Devices pair using short-lived invite codes to exchange SSH public keys.
- Go 1.26+
protocwithprotoc-gen-goandprotoc-gen-go-grpc(for proto regeneration only)sshfsinstalled on agent machines (see Installing the mount tool)
Agents mount remote shares with sshfs. The FUSE implementation behind it
depends on the platform.
macOS — FUSE-T is the recommended, kext-free path. Its casks live in a third-party tap, so tap it first:
brew tap macos-fuse-t/homebrew-cask
brew install --cask fuse-t fuse-t-sshfsFUSE-T runs a local NFS server instead of a kernel extension, so there is
nothing to approve and no reboot. The alternative, macFUSE, installs a kernel
extension that requires System Settings approval plus a reboot, and on Apple
Silicon also forces enabling reduced-security mode. To use FUSE-T set
mount-tool "fuse-t" in the agent config (see Configuration);
FUSE-T is macOS-only. Note: FUSE-T is free for personal use; commercial use
requires a license (see fuse-t.org).
Linux — install the distribution's sshfs package (which uses fusermount),
e.g. apt install sshfs or dnf install fuse-sshfs. The default
mount-tool "sshfs" applies; "fuse-t" is not available on Linux.
# Build
make build
# Install binaries to $GOPATH/bin
make install
# Start the hub (default :9090)
hubfuse-hub start
# On the hub host — issue a single-use token for the joining device
hubfuse-hub issue-join
# -> HUB-AB2-9XY.mfqwcylbmfqwcylbmfqwcylb
# ^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^
# DB prefix hub TLS fingerprint (26-char base32)
# On each device — join the hub with the full token, then start the agent
hubfuse join <hub-address>:9090 --token HUB-AB2-9XY.mfqwcylbmfqwcylbmfqwcylb
hubfuse startJoin tokens expire after 10 minutes and are consumed atomically by the first
Join call that presents them — a Join that fails after claiming the token
(for example, a nickname collision) does not restore it, so a retry requires
issuing a fresh token. This is deliberate: it keeps the token single-use under
concurrent requests and prevents exposing it to brute-force attempts. Configure
the TTL with join-token-ttl "<duration>" in ~/.hubfuse-hub/config.kdl.
The suffix after the . is a truncated SHA-256 fingerprint of the hub's TLS
leaf certificate (first 16 bytes, base32-encoded). During hubfuse join, the
agent pins this fingerprint against the server's certificate before sending
any data — an active MITM presenting a different certificate is rejected before
the Join RPC is ever issued. Rotating the hub's server certificate invalidates
all outstanding join tokens; issue fresh ones after rotation.
With Go installed, install either binary directly from the module path:
go install github.com/ykhdr/hubfuse/cmd/hubfuse@latest
go install github.com/ykhdr/hubfuse/cmd/hubfuse-hub@latestThis requires a Go toolchain and $GOPATH/bin (or $GOBIN) on your PATH.
Updating is the same command — re-run go install ...@latest to pull the
newest released tag:
go install github.com/ykhdr/hubfuse/cmd/hubfuse@latest
go install github.com/ykhdr/hubfuse/cmd/hubfuse-hub@latestUpgrade the hub before the agents. From v0.1.3 the agent keeps its hub
connection alive with a gRPC keepalive ping every 10 seconds, and a hub from
v0.1.2 or earlier rejects that cadence with GOAWAY too_many_pings and closes
the connection.
A running agent is not affected: its heartbeat keeps the connection busy
enough that no ping is ever sent, and an agent talking to an old hub survives
indefinitely. What is affected is any connection that goes quiet for ~30
seconds — most visibly hubfuse join, which holds a connection open while it
waits for you to type a nickname. Each rejection also permanently doubles that
connection's keepalive interval, which weakens the agent's ability to notice a
hub that has gone away without closing the socket.
If it happens, the agent says so once, at error level, naming the hub as too old. Upgrading the hub is the fix; agents do not need to be downgraded or restarted in any particular order afterwards.
HUBFUSE_MOUNT_VERIFY_TIMEOUT (default 10s) bounds how long the agent waits for a
mount point to appear before calling the attempt failed. Raise it if your storage
or network is slow enough that legitimate mounts are being reported as failures;
there is no reason to lower it.
One related setting: leave HUBFUSE_HEARTBEAT_INTERVAL alone unless you have a
reason. Anything at or above 30 seconds exceeds the hub's own liveness timeout,
so the hub marks the device offline and peers unmount its shares — against any
hub version, not just old ones.
macOS polices access to the local network with a policy engine called NECP. When
it refuses, a connection to a LAN address fails with connect: no route to host
while the internet keeps working — so the symptom looks like broken routing and
is not. The kernel names it outright in the unified log:
kernel process: hubfuse t_state: SYN_SENT error: 65 reason: NECP
Apple's TN3179 lists which processes are exempt: a launchd daemon, any process running as root, and command-line tools run from Terminal or over SSH including their child processes — but explicitly not a launchd agent. So where you start the daemon from decides what it is allowed to reach, and the exemption lasts only as long as the session that carries it.
hubfuse may never appear under System Settings → Privacy & Security → Local
Network, and that is not something you can fix from there. It is a plain
command-line binary with no bundle identifier, and macOS says so when it tries to
build the entry:
nehelper Could not find bundle ID or display name for app:
(bundleID: hubfuse-<hash>, name: (null), teamID: (null))
With no name and no team ID there is nothing to list, so an absent entry is not a setting waiting to be switched on. If macOS does show a Local Network prompt for hubfuse, allow it.
Since v0.2.0 the agent no longer dies when its first hub connection fails. It
retries with backoff, so a Mac that was asleep, a network that is not up yet, a
hub that is still booting — and a first LAN connect macOS refuses while it
registers the binary — all recover on their own. When dials keep failing this way
the agent says so once, at error level, and only when the hub address really is
on the local network; no route to host to a routable address is ordinary
routing and is reported as such.
To have the agent start with your login session and be restarted if it fails:
hubfuse install-agent # writes the plist
launchctl bootstrap gui/$(id -u) \
~/Library/LaunchAgents/com.github.ykhdr.hubfuse.plistIf a LAN hub stays unreachable, TN3179 documents a machine-wide alternative (macOS 15.5+) that works for any program regardless of how it was started or signed — it declares a whole range non-local, so weigh that before using it:
sudo defaults write com.apple.network.local-network \
AllowedWiFiLocalNetworkAddresses -array "192.168.0.0/16"
# then restart the MacApple documents these keys with 169.254.0.0/16; whether other ranges are
accepted has not been tested by this project.
One more macOS detail, unrelated to permissions: binaries that are not
code-signed are killed on launch by macOS 26 (Killed: 9, with no output). A
binary you built yourself locally is signed by the toolchain; one copied from
elsewhere may not be, and codesign -f -s - ./hubfuse fixes it.
Neither binary survives a reboot on its own. hubfuse start --daemon detaches
but dies with the machine, so both ship a command that writes a systemd user
unit:
hubfuse-hub install-service # writes ~/.config/systemd/user/hubfuse-hub.service
hubfuse install-service # writes ~/.config/systemd/user/hubfuse-agent.service
systemctl --user daemon-reload
systemctl --user enable --now hubfuse-hub.service
systemctl --user enable --now hubfuse-agent.serviceThen enable lingering, and treat it as part of the installation rather than an option:
loginctl enable-linger $USER # over SSH this usually needs sudoA user manager stops when you log out. Without lingering the two enable --now
commands above still report success and the services still run — right up to the
next reboot, after which nothing starts and, if it was the hub, every device
reads offline. Over SSH the command commonly needs sudo, because polkit has no
active local session to authorise against.
They are user units rather than system units because both processes are per-user
by construction: the agent mounts into your home directory and holds your SSH
keys, and the hub keeps its store in ~/.hubfuse-hub.
Logs go to the journal rather than to a file — note the difference from macOS,
where the LaunchAgent writes ~/.hubfuse/agent.log:
journalctl --user -u hubfuse-agent.service -f
journalctl --user -u hubfuse-hub.service -fRestarting the agent is safe for live mounts. The unit sets KillMode=mixed, so
systemctl --user restart signals only the daemon and lets it unmount its own
shares in order; systemd's default would signal the whole control group and tear
sshfs down underneath it, leaving Transport endpoint is not connected behind.
If you don't have Go, prebuilt binaries are published on the project's
GitHub Releases page as tar.gz
archives (one per binary, per OS/arch) alongside a checksums.txt.
On macOS, a binary downloaded through a browser is blocked on first run. macOS shows a dialog saying it "could not verify that hubfuse is free of malware", and offers only to move it to the Trash. The release binaries are not notarized by Apple, and browsers mark everything they download with a quarantine attribute that makes macOS enforce that.
Verify the archives you downloaded against checksums.txt, then extract them
and clear the attribute from the binaries:
shasum -a 256 -c checksums.txt --ignore-missing # run where the .tar.gz files are
tar xzf hubfuse_*_darwin_arm64.tar.gz
xattr -d com.apple.quarantine ./hubfuse # only if macOS refuses to run itAlternatively, try to run it once and then allow it under System Settings → Privacy & Security, where macOS offers an "Open Anyway" button after the first refusal.
Whether the extracted binary inherits the attribute depends on how you unpack
it — Finder propagates quarantine to the extracted files, tar in a terminal
usually does not — so the xattr line is only needed if macOS actually blocks
it. None of this applies to a binary built locally with go install.
Both binaries report their version. The version subcommand prints a detailed
block (version, commit, build date, Go version, OS/arch):
hubfuse version
hubfuse-hub versionThe --version flag prints the single-line version (e.g. hubfuse --version).
Agent configuration lives in ~/.hubfuse/config.kdl (KDL format). Example:
device {
nickname "my-laptop"
}
hub {
address "192.168.1.10:9090"
}
agent {
ssh-port 2222
mount-tool "sshfs" // "sshfs" (default) | "fuse-t"
}
shares {
share "/home/user/projects" alias="projects" permissions="rw" {
allowed-devices "all"
}
}
mounts {
mount device="work-pc" share="docs" to="/mnt/hubfuse/docs"
}Changes to shares and mounts in config.kdl are hot-reloaded — no restart
needed. Settings in the agent block (ssh-port, mount-tool) are read once at
startup and require a daemon restart to take effect.
ssh-port must be free when the daemon starts. If something else is already
listening there — most often a leftover hubfuse process — hubfuse start
fails immediately and says so:
start SSH server: listen on port 2222: listen tcp :2222: bind: address already in use
The daemon deliberately does not continue in that state. Without its own SSH
server it has nothing to serve, and registering anyway would hand every peer a
port owned by another process — peers would then mount from whatever answers
there rather than getting a clean failure. Free the port (hubfuse stop, or
lsof -i :2222) or pick another one, then start again. The same rule applies
while the daemon runs: if the SSH server stops serving, the daemon leaves the
hub and exits instead of staying online behind a port it no longer holds.
agent { mount-tool "..." } selects the mount backend for this device
(device-global; it applies to every mount). Allowed values:
"sshfs"(default) — the distributionsshfs(macFUSE on macOS,fusermounton Linux)."fuse-t"— macOS only; requiresfuse-t-sshfs(brew tap macos-fuse-t/homebrew-cask && brew install --cask fuse-t fuse-t-sshfs). The kext-free path described in Installing the mount tool. Selecting"fuse-t"on a non-macOS host is a configuration error.
Unlike shares and mounts, changing mount-tool requires a daemon restart —
the backend is selected once at startup and is not picked up by hot-reload.
Both values run the sshfs binary found on PATH; mount-tool does not pick a
binary by itself. If both macFUSE's and FUSE-T's sshfs are installed, whichever
comes first on PATH is the engine that actually serves the mount — so keep only
one installed (e.g. brew uninstall sshfs-mac to let FUSE-T win). As a safety
net, with mount-tool "fuse-t" the agent warns at startup when the FUSE-T runtime
isn't detected, and a mount that never materializes is reported as an error rather
than logged as a (false) success.
permissions and allowed-devices are enforced by the agent's SFTP
server for every incoming request:
permissions="ro"rejects every SFTP write (create, write, rename, remove, mkdir, chmod, symlink, link).allowed-deviceslists the peers that may see and access the share. Tokens match the peer's nickname or raw device_id. Use the literal token"all"to grant access to every paired device. Nickname tokens resolve correctly even before the peer comes online (e.g. right after a daemon restart) because paired nicknames are persisted locally and loaded before the SSH server begins serving. If a peer's nickname changes, the mapping self-heals on the next online event or daemon restart.
Defaults are secure: omitting permissions treats the share as
read-only, and omitting (or leaving empty) allowed-devices makes the
share inaccessible to every peer. This is a deliberate tightening — in
earlier releases these fields were documented but not enforced.
| Command | Description |
|---|---|
start [--listen :9090] [--device-retention 168h] [-d] |
Start the hub server (use -d to run in the background) |
stop |
Stop the running hub |
status |
Show hub status (running/stopped, pid) |
issue-join [--ttl 10m] |
Issue a single-use join token; print it on stdout |
version |
Print version, commit, build date, Go version, and OS/arch |
Offline devices older than one week (168h) are pruned automatically. Customize the window with --device-retention <duration> or set device-retention "<duration>" in ~/.hubfuse-hub/config.kdl. Use 0 to disable pruning.
| Command | Description |
|---|---|
join <hub-address> --token HUB-XXX-YYY.<fp> [--force] |
Register this device with a hub using a token issued via hubfuse-hub issue-join; receives TLS certs. Refuses if already joined unless --force is passed. |
leave [--force-local] |
Permanently remove this device from the hub and wipe local TLS state. Pass --force-local to wipe even if the hub is unreachable. |
start [-d] |
Start the agent daemon |
stop |
Stop the running agent |
restart [-d] |
Stop the running agent (if any) and start a fresh one. Mirrors start: runs in the foreground by default, detaches with -d, and honors --log-file/--log-level/--verbose (logging flags apply to the foreground form; a detached restart uses default logging). |
status |
Show agent status |
devices |
List all devices known to the hub |
rename <nickname> |
Change this device's nickname |
pair <device> |
Request pairing with a remote device (prints invite code) |
share add <path> --alias <name> [--permissions ro|rw] [--allow ...] |
Share a local directory |
share remove <alias> |
Remove a share |
share list |
List local shares |
share allow <alias> <device>... |
Grant device(s) access to an existing share |
share deny <alias> <device>... |
Revoke device(s) access to an existing share |
mount add <device>:<share> --to <path> |
Mount a remote share |
mount remove <device>:<share> |
Unmount |
mount list |
List mounts |
version |
Print version, commit, build date, Go version, and OS/arch |
If local state is lost (e.g. after wiping ~/.hubfuse/) but the device record
still exists on the hub, ask the hub operator to prune it or wait for the
retention window to expire. Then rejoin with a fresh token as normal.
If the hub is unreachable, run hubfuse leave --force-local to wipe the stale
local state before rejoining a new hub.
make build # compile all packages
make test # run unit + integration tests
make test-unit # unit tests only
make test-integration # integration tests (120s timeout)
make vet # static analysis
make proto-gen # regenerate gRPC code from proto/hubfuse.proto
make release-snapshot # build a local snapshot release with GoReleaser (no publish)
make release-check # validate .goreleaser.yaml (goreleaser check)HubFuse is licensed under the Apache License 2.0.