Skip to content

Latest commit

 

History

431 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

HubFuse

Network file sharing for local networks. Mount remote directories transparently via SSHFS, coordinated by a central hub server.

How it works

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.

Requirements

  • Go 1.26+
  • protoc with protoc-gen-go and protoc-gen-go-grpc (for proto regeneration only)
  • sshfs installed on agent machines (see Installing the mount tool)

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-sshfs

FUSE-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.

Quick start

# 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 start

Join 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.

Security

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.

Installation

Install via go install

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@latest

This requires a Go toolchain and $GOPATH/bin (or $GOBIN) on your PATH.

Updating

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@latest

Upgrade 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.

Running the agent on macOS

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.plist

If 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 Mac

Apple 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.

Running the hub and agent on Linux

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.service

Then enable lingering, and treat it as part of the installation rather than an option:

loginctl enable-linger $USER     # over SSH this usually needs sudo

A 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 -f

Restarting 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.

Prebuilt binaries

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 it

Alternatively, 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.

Version

Both binaries report their version. The version subcommand prints a detailed block (version, commit, build date, Go version, OS/arch):

hubfuse version
hubfuse-hub version

The --version flag prints the single-line version (e.g. hubfuse --version).

Configuration

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.

Mount tool

agent { mount-tool "..." } selects the mount backend for this device (device-global; it applies to every mount). Allowed values:

  • "sshfs" (default) — the distribution sshfs (macFUSE on macOS, fusermount on Linux).
  • "fuse-t" — macOS only; requires fuse-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.

Share access control

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-devices lists 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.

Commands

hubfuse-hub

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.

hubfuse (agent)

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

Recovery

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.

Development

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)

License

HubFuse is licensed under the Apache License 2.0.

About

Network file sharing for local networks. Mount remote directories transparently via SSHFS, coordinated by a central hub server

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages