Documentation
¶
Index ¶
- Variables
- func GenerateDeviceID() string
- func GenerateSSHKeyPair(dir string) (publicKeyStr string, err error)
- func ListPairedDevices(knownDevicesDir string) ([]string, error)
- func LoadNicknames(knownDevicesDir string) (map[string]string, error)
- func LoadPeerPublicKey(knownDevicesDir, deviceID string) (string, error)
- func LoadPublicKey(path string) (string, error)
- func ResolveSharePath(shareRoot, virtualPath, expectedAlias string) (string, error)
- func SaveIdentity(path string, id *DeviceIdentity) error
- func SaveNicknames(knownDevicesDir string, m map[string]string) error
- func SavePeerPublicKey(knownDevicesDir, deviceID, publicKey string) error
- func SetNickname(knownDevicesDir, deviceID, nickname string) error
- type AccessDecision
- type Connector
- type Daemon
- type DaemonOptions
- type DeviceIdentity
- type DeviceResolver
- type HubClient
- func (c *HubClient) Close() error
- func (c *HubClient) ConfirmPairing(ctx context.Context, inviteCode, publicKey string) (peerPublicKey, peerDeviceID, peerNickname string, err error)
- func (c *HubClient) Deregister(ctx context.Context) error
- func (c *HubClient) Heartbeat(ctx context.Context) error
- func (c *HubClient) Join(ctx context.Context, deviceID, nickname, joinToken string) (*pb.JoinResponse, error)
- func (c *HubClient) Leave(ctx context.Context) error
- func (c *HubClient) ListDevices(ctx context.Context) (*pb.ListDevicesResponse, error)
- func (c *HubClient) Register(ctx context.Context, shares []*pb.Share, sshPort int) (*pb.RegisterResponse, error)
- func (c *HubClient) Rename(ctx context.Context, newNickname string) (*pb.RenameResponse, error)
- func (c *HubClient) RequestPairing(ctx context.Context, toDevice, publicKey string) (string, error)
- func (c *HubClient) Subscribe(ctx context.Context) (pb.HubFuse_SubscribeClient, error)
- func (c *HubClient) UpdateShares(ctx context.Context, shares []*pb.Share) error
- type Mount
- type Mounter
- func (m *Mounter) ActiveMounts() []*Mount
- func (m *Mounter) IsActive(device, share string) bool
- func (m *Mounter) Mount(ctx context.Context, mc agentconfig.MountConfig, deviceID, deviceIP string, ...) error
- func (m *Mounter) SetExecCommandForTests(fn func(ctx context.Context, name string, args ...string) *exec.Cmd)
- func (m *Mounter) SetMountpointCheckForTests(fn func(string) (bool, error))
- func (m *Mounter) SetUnmountForTests(fn func(ctx context.Context, path string, force bool) error)
- func (m *Mounter) Unmount(device, share string) error
- func (m *Mounter) UnmountAll() error
- func (m *Mounter) UnmountAllForce(ctx context.Context) error
- func (m *Mounter) UnmountDevice(deviceNickname string) error
- type OnlineDevice
- type SSHServer
- type ShareACL
Constants ¶
This section is empty.
Variables ¶
var ErrHubRejected = errors.New("hub rejected the request")
ErrHubRejected marks an application-level refusal: the RPC itself completed (nil gRPC error) but the hub answered Success=false. Callers that must tell "the hub is unreachable, keep retrying" apart from "the hub answered and said no" match on this — Daemon.Shutdown uses it so a deregistration the hub refuses (typically because it already pruned the device) does not turn a clean shutdown into a failure. (#69)
Functions ¶
func GenerateDeviceID ¶
func GenerateDeviceID() string
GenerateDeviceID returns a new random UUID v4 string.
func GenerateSSHKeyPair ¶
GenerateSSHKeyPair generates an ed25519 key pair and saves it to dir. The private key is saved to dir/id_ed25519 (mode 0600) and the public key to dir/id_ed25519.pub (mode 0644). Returns the OpenSSH-format public key string.
func ListPairedDevices ¶
ListPairedDevices returns the device IDs of all paired devices whose public keys are stored in knownDevicesDir. Each *.pub file contributes one device ID.
func LoadNicknames ¶
LoadNicknames reads the device_id → nickname map from <knownDevicesDir>/nicknames.json. An absent file is not an error — it simply means no nicknames have been persisted yet; the caller receives an empty (non-nil) map and a nil error.
func LoadPeerPublicKey ¶
LoadPeerPublicKey reads the stored public key for deviceID from knownDevicesDir.
func LoadPublicKey ¶
LoadPublicKey reads and returns the SSH public key string from path.
func ResolveSharePath ¶
ResolveSharePath translates a virtual SFTP path of the form "/<alias>/sub/path" into a cleaned real filesystem path under shareRoot. Returns an error if the first segment does not match expectedAlias, or if the result would escape shareRoot after cleaning.
func SaveIdentity ¶
func SaveIdentity(path string, id *DeviceIdentity) error
SaveIdentity writes id as JSON to path, creating parent directories as needed. The file is written with 0600 permissions to protect the identity data.
func SaveNicknames ¶
SaveNicknames atomically writes the device_id → nickname map to <knownDevicesDir>/nicknames.json using a temp-file + rename so a crash during the write never leaves a truncated file. Each device_id key is validated before the write; the function returns an error if any key is invalid. The directory is created if it does not yet exist.
func SavePeerPublicKey ¶
SavePeerPublicKey stores the public key for deviceID under knownDevicesDir. The file is named <deviceID>.pub and is written with 0644 permissions. Parent directories are created as needed.
The write is atomic (temp file + rename). This matters because the same <deviceID>.pub path is written by two separate processes during pair-confirm: the `hubfuse pair-confirm` CLI and the running daemon's handlePairingCompleted handler — and the daemon then reads it straight back in reloadSSHAllowedKeys. A plain os.WriteFile opens with O_TRUNC, exposing a "truncated, not yet rewritten" window in which a concurrent reader sees an empty file and parses it as `ssh: no key found`, leaving the SSH allowed-keys map without the new peer. os.Rename is atomic within a directory on POSIX, so a reader always observes either the previous or the new complete file, never an empty one.
func SetNickname ¶
SetNickname is a load-modify-save convenience used by the CLI (pair-confirm) to record a peer's nickname alongside its public key. It is a no-op when nickname is empty. Callers that hold an in-memory daemon map should use (*Daemon).rememberNickname instead so that concurrent updates are not lost.
Types ¶
type AccessDecision ¶
AccessDecision is the result of evaluating a ShareACL for a specific peer.
type Connector ¶
type Connector struct {
// contains filtered or unexported fields
}
Connector manages reconnection to the hub with exponential backoff.
func NewConnector ¶
NewConnector creates a Connector with the given hub address and TLS cert paths.
type Daemon ¶
type Daemon struct {
// contains filtered or unexported fields
}
Daemon is the main orchestrator that ties together hub client, mounter, SSH server, config watcher, heartbeat, and event handling.
func NewDaemon ¶
NewDaemon loads the config and identity, creates the connector, mounter, and SSH server, and returns a ready-to-run Daemon.
func (*Daemon) NicknameForDeviceID ¶
NicknameForDeviceID implements DeviceResolver. Used by the SFTP handler to match ACL tokens that reference human-readable nicknames.
Resolution is two-tier:
- Live onlineDevices (authoritative when the peer is currently online).
- Persisted nicknames map (covers the restart / pre-online window for already-paired peers — this is the fix for the ACL race in issue #48).
func (*Daemon) Run ¶
Run is the main daemon loop. It connects to the hub, starts all subsystems, and blocks until ctx is cancelled.
func (*Daemon) Shutdown ¶
Shutdown unmounts all shares, deregisters from the hub, stops the SSH server, stops the config watcher, and closes the hub client. UnmountAllForce runs under a 5s timeout so a wedged mount cannot prevent clean shutdown — comfortably under the daemonize 10s SIGKILL deadline. (#50 bounded/force)
type DaemonOptions ¶
type DaemonOptions struct {
// OnReady, if non-nil, is invoked exactly once by Run, immediately
// after successful Register with the hub. The cmd layer uses this
// hook to write the PID file.
OnReady func()
}
DaemonOptions configures a Daemon at construction time.
type DeviceIdentity ¶
DeviceIdentity holds the persistent identity of this device.
func LoadIdentity ¶
func LoadIdentity(path string) (*DeviceIdentity, error)
LoadIdentity reads a DeviceIdentity from the JSON file at path.
type DeviceResolver ¶
DeviceResolver maps a peer device_id to its hub-advertised nickname. Returns ok=false if the mapping is not yet known (e.g. right after pairing, before the hub has emitted a DeviceOnline event).
type HubClient ¶
type HubClient struct {
// contains filtered or unexported fields
}
HubClient wraps a gRPC connection to the hub.
func DialPinned ¶
DialPinned creates a HubClient for the Join bootstrap using TLS with certificate pinning. InsecureSkipVerify is set but the VerifyPeerCertificate callback enforces the expected fingerprint, so the connection is not actually insecure — the pin is the security control.
func DialWithMTLS ¶
func DialWithMTLS(hubAddr, caCertPath, clientCertPath, clientKeyPath string, logger *slog.Logger) (*HubClient, error)
DialWithMTLS creates a HubClient using mutual TLS with the given certificates.
func (*HubClient) ConfirmPairing ¶
func (c *HubClient) ConfirmPairing(ctx context.Context, inviteCode, publicKey string) (peerPublicKey, peerDeviceID, peerNickname string, err error)
ConfirmPairing completes a pairing handshake and returns the peer's public key, device ID, and nickname. The hub identifies the caller from the mTLS client certificate. Application-level failures (invalid/expired invite, attempts exceeded) come back as Success=false with a nil gRPC error — those are translated into a real Go error so the CLI doesn't print a misleading "paired with ..." line on failure.
func (*HubClient) Deregister ¶
Deregister removes this device from the hub. (#69: Success=false is an error so shutdown reports a deregistration the hub did not perform.)
func (*HubClient) Heartbeat ¶
Heartbeat sends a liveness ping to the hub. A hub that does not know this device (pruned identity, or a hub restarted on an empty database) answers Success=false with a nil gRPC error; that must surface as an error, otherwise the daemon keeps "heartbeating" into the void while the hub counts it as absent. HeartbeatResponse carries no error string, so the message is generic — the actionable one comes from the Register path. (#69)
func (*HubClient) Join ¶
func (c *HubClient) Join(ctx context.Context, deviceID, nickname, joinToken string) (*pb.JoinResponse, error)
Join calls the hub's Join RPC to register a new device and obtain certificates.
func (*HubClient) Leave ¶
Leave deregisters this device permanently. The hub deletes the device row and all dependent shares/pairings. Returns nil on success.
func (*HubClient) ListDevices ¶
ListDevices retrieves all devices known to the hub.
func (*HubClient) Register ¶
func (c *HubClient) Register(ctx context.Context, shares []*pb.Share, sshPort int) (*pb.RegisterResponse, error)
Register announces this device to the hub with its current shares and SSH port.
A hub that refuses the registration answers with a nil gRPC error and Success=false (see hub.Server.Register) — the transport call worked, the application-level request did not. Returning that as success made a daemon whose identity the hub had pruned log "registered with hub" and write its PID file while the hub knew nothing about it, so the failure is translated into a real Go error here. resp.Error carries the hub's own, actionable text. (#69)
func (*HubClient) Rename ¶
Rename requests a nickname change for this device. An application-level refusal (nickname taken) comes back as Success=false and is returned as an error, so callers only have to handle err. (#69)
func (*HubClient) RequestPairing ¶
RequestPairing initiates pairing with another device and returns the invite code.
func (*HubClient) Subscribe ¶
Subscribe opens a server-streaming RPC to receive events from the hub. The hub identifies the subscriber from the mTLS client certificate.
func (*HubClient) UpdateShares ¶
UpdateShares pushes the current share list to the hub. (#69: Success=false is an error, not a silent no-op — a share list the hub never stored would leave peers mounting shares that are not published.)
type Mount ¶
type Mount struct {
Device string
LocalPath string
IP string
SSHPort int
// contains filtered or unexported fields
}
Mount represents an active SSHFS mount.
type Mounter ¶
type Mounter struct {
// contains filtered or unexported fields
}
Mounter manages SSHFS mounts for remote shares.
func NewMounter ¶
func NewMounter(keyPath, knownDevicesDir, knownHostsDir, mountTool string, logger *slog.Logger) *Mounter
NewMounter creates a new Mounter. mountTool selects the mount backend ("sshfs" default, or "fuse-t"); an empty or unknown value falls back to the "sshfs" profile (see resolveBackend).
When HUBFUSE_STUB_MOUNT_DIR is set (the scenario-test harness that uses stub-sshfs, which never creates a real FUSE mountpoint), the real isMountpoint/unmountPath are replaced by marker-protocol implementations (see stubmount.go): a mount is "alive" while the stub's JSON marker exists and its recorded PID is alive, and unmounting SIGTERMs the stub and waits for the marker to vanish. This keeps the stub lifecycle faithful — Mount's verify poll genuinely waits for the stub to come up, a killed stub is genuinely detected as a dead mount (so scenario tests can exercise dead-mount healing), and unmount genuinely tears the stub down instead of always failing against a nonexistent mountpoint. (#67 truthful stub)
func (*Mounter) ActiveMounts ¶
ActiveMounts returns a snapshot of all currently active mounts.
func (*Mounter) Mount ¶
func (m *Mounter) Mount(ctx context.Context, mc agentconfig.MountConfig, deviceID, deviceIP string, sshPort int) error
Mount mounts the remote share described by mc from deviceIP:sshPort using SSHFS. Callers are responsible for ensuring the device is paired before calling Mount; the peer's public key stored at <knownDevicesDir>/<deviceID>.pub is pinned as the only accepted SSH host key for the connection.
func (*Mounter) SetExecCommandForTests ¶
func (m *Mounter) SetExecCommandForTests(fn func(ctx context.Context, name string, args ...string) *exec.Cmd)
SetExecCommandForTests overrides the command builder (used in tests).
func (*Mounter) SetMountpointCheckForTests ¶
SetMountpointCheckForTests overrides the mountpoint check (used in tests). The stub-sshfs scenario harness does not go through this seam — NewMounter installs stubMountpointCheck directly when HUBFUSE_STUB_MOUNT_DIR is set (see stubmount.go). (#67 truthful stub)
func (*Mounter) SetUnmountForTests ¶
SetUnmountForTests overrides the unmount implementation (used in tests). The new signature matches the updated seam: (ctx, path, force). (#50 bounded/force)
func (*Mounter) Unmount ¶
Unmount unmounts the share identified by device and share name. This is the back-compat interactive path: force=false, reguard=true. The 3s timeout bounds the unmount command itself; unmountKey gives the post-failure re-check its own independent window, so the interactive call can never hang indefinitely. (#47 reap, back-compat; #50 bounded; #49 guard-target)
func (*Mounter) UnmountAll ¶
UnmountAll unmounts all active mounts (interactive, force=false, reguard=true). Each mount is bounded by unmountOpTimeout (unmountAll adds a per-mount deadline because the background ctx has none) so a wedged mount cannot hang the sweep. (#50 bounded; #49 guard-target)
func (*Mounter) UnmountAllForce ¶
UnmountAllForce unmounts all active mounts with force=true under the provided context. Used by daemon.Shutdown to guarantee a bounded teardown. reguard=false because the process is exiting and there is no benefit to chmoding the dirs. (#50 bounded/force; #49 guard-target)
func (*Mounter) UnmountDevice ¶
UnmountDevice unmounts all shares from the named device (force=true, reguard=true, because device-offline/-removed teardown should never leave wedged mounts behind, and the target stays in config so it must be re-restricted). (#50 force; #49 guard-target)
type OnlineDevice ¶
OnlineDevice holds the known state of a currently-online remote device.
type SSHServer ¶
type SSHServer struct {
// contains filtered or unexported fields
}
SSHServer is an embedded SSH/SFTP server that serves share aliases to authenticated peer devices.
func NewSSHServer ¶
NewSSHServer creates a new SSHServer that listens on port and uses hostKeyPath as the host key (agent's SSH private key).
func (*SSHServer) SetDeviceResolver ¶
func (s *SSHServer) SetDeviceResolver(r DeviceResolver)
SetDeviceResolver installs a resolver that maps device_id -> nickname. The resolver is consulted by the SFTP handler when matching ACL tokens.
func (*SSHServer) Start ¶
Start begins listening for SSH connections on the configured port. It runs until ctx is cancelled or Stop is called.
func (*SSHServer) Stop ¶
Stop closes the listener, causing Start to return. It is safe to call Stop after Start's ctx-cancel path has already closed the listener: net.ErrClosed is treated as success so double-close during shutdown (signal cancels ctx → Daemon.Shutdown calls Stop) does not surface.
func (*SSHServer) UpdateAllowedKeys ¶
UpdateAllowedKeys replaces the set of paired peers. The map is keyed by device_id; the server rebuilds the fingerprint->device_id reverse index used by publicKeyCallback.
func (*SSHServer) UpdateShares ¶
UpdateShares replaces the current ACL snapshot. The slice is copied so the caller is free to mutate its own buffer after the call returns.
type ShareACL ¶
type ShareACL struct {
}
ShareACL is a runtime, flattened representation of a share's access policy. It is derived from config.ShareConfig by ShareACLsFromConfig.
func ShareACLsFromConfig ¶
func ShareACLsFromConfig(shares []shareConfigView) []ShareACL
ShareACLsFromConfig flattens a slice of share configs into runtime ACLs. Applies secure defaults: missing permissions = "ro"; the reserved token "all" is lifted into AllowAll and removed from the token list.
func (ShareACL) Decide ¶
func (a ShareACL) Decide(deviceID string, resolver DeviceResolver) AccessDecision
Decide returns whether deviceID may access the share and, if so, whether the share is read-only.