agent

package
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 16, 2026 License: Apache-2.0 Imports: 33 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

View Source
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

func GenerateSSHKeyPair(dir string) (publicKeyStr string, err error)

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

func ListPairedDevices(knownDevicesDir string) ([]string, error)

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

func LoadNicknames(knownDevicesDir string) (map[string]string, error)

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

func LoadPeerPublicKey(knownDevicesDir, deviceID string) (string, error)

LoadPeerPublicKey reads the stored public key for deviceID from knownDevicesDir.

func LoadPublicKey

func LoadPublicKey(path string) (string, error)

LoadPublicKey reads and returns the SSH public key string from path.

func ResolveSharePath

func ResolveSharePath(shareRoot, virtualPath, expectedAlias string) (string, error)

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

func SaveNicknames(knownDevicesDir string, m map[string]string) error

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

func SavePeerPublicKey(knownDevicesDir, deviceID, publicKey string) error

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

func SetNickname(knownDevicesDir, deviceID, nickname string) error

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

type AccessDecision struct {
	Allow    bool
	ReadOnly bool
}

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

func NewConnector(hubAddr, caCert, clientCert, clientKey string, logger *slog.Logger) *Connector

NewConnector creates a Connector with the given hub address and TLS cert paths.

func (*Connector) Connect

func (c *Connector) Connect(ctx context.Context) (*HubClient, error)

Connect attempts to establish a mTLS connection to the hub. On failure it retries with exponential backoff starting at 1s and capped at 60s. It returns when a connection succeeds or the context is cancelled.

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

func NewDaemon(cfgPath string, logger *slog.Logger, opts DaemonOptions) (*Daemon, error)

NewDaemon loads the config and identity, creates the connector, mounter, and SSH server, and returns a ready-to-run Daemon.

func (*Daemon) NicknameForDeviceID

func (d *Daemon) NicknameForDeviceID(id string) (string, bool)

NicknameForDeviceID implements DeviceResolver. Used by the SFTP handler to match ACL tokens that reference human-readable nicknames.

Resolution is two-tier:

  1. Live onlineDevices (authoritative when the peer is currently online).
  2. 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

func (d *Daemon) Run(ctx context.Context) error

Run is the main daemon loop. It connects to the hub, starts all subsystems, and blocks until ctx is cancelled.

func (*Daemon) Shutdown

func (d *Daemon) Shutdown() error

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

type DeviceIdentity struct {
	DeviceID string `json:"device_id"`
	Nickname string `json:"nickname"`
}

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

type DeviceResolver interface {
	NicknameForDeviceID(id string) (string, bool)
}

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

func DialPinned(hubAddr, expectedFP string, logger *slog.Logger) (*HubClient, error)

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) Close

func (c *HubClient) Close() error

Close shuts down the underlying gRPC connection.

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

func (c *HubClient) Deregister(ctx context.Context) error

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

func (c *HubClient) Heartbeat(ctx context.Context) error

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

func (c *HubClient) Leave(ctx context.Context) error

Leave deregisters this device permanently. The hub deletes the device row and all dependent shares/pairings. Returns nil on success.

func (*HubClient) ListDevices

func (c *HubClient) ListDevices(ctx context.Context) (*pb.ListDevicesResponse, error)

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

func (c *HubClient) Rename(ctx context.Context, newNickname string) (*pb.RenameResponse, error)

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

func (c *HubClient) RequestPairing(ctx context.Context, toDevice, publicKey string) (string, error)

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

func (c *HubClient) UpdateShares(ctx context.Context, shares []*pb.Share) error

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

func (m *Mounter) ActiveMounts() []*Mount

ActiveMounts returns a snapshot of all currently active mounts.

func (*Mounter) IsActive

func (m *Mounter) IsActive(device, share string) bool

IsActive reports whether the share from device is currently mounted.

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

func (m *Mounter) SetMountpointCheckForTests(fn func(string) (bool, error))

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

func (m *Mounter) SetUnmountForTests(fn func(ctx context.Context, path string, force bool) error)

SetUnmountForTests overrides the unmount implementation (used in tests). The new signature matches the updated seam: (ctx, path, force). (#50 bounded/force)

func (*Mounter) Unmount

func (m *Mounter) Unmount(device, share string) error

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

func (m *Mounter) UnmountAll() error

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

func (m *Mounter) UnmountAllForce(ctx context.Context) error

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

func (m *Mounter) UnmountDevice(deviceNickname string) error

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

type OnlineDevice struct {
	DeviceID string
	Nickname string
	IP       string
	SSHPort  int
	Shares   []string // share aliases
}

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

func NewSSHServer(port int, hostKeyPath string, logger *slog.Logger) (*SSHServer, error)

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

func (s *SSHServer) Start(ctx context.Context) error

Start begins listening for SSH connections on the configured port. It runs until ctx is cancelled or Stop is called.

func (*SSHServer) Stop

func (s *SSHServer) Stop() error

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

func (s *SSHServer) UpdateAllowedKeys(keys map[string]gossh.PublicKey)

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

func (s *SSHServer) UpdateShares(shares []ShareACL)

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 {
	Alias          string
	Path           string
	ReadOnly       bool
	AllowAll       bool     // true when AllowedDevices contained the literal "all"
	AllowedDevices []string // remaining tokens (nicknames or device_ids)
}

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.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL
⌂ 换网址