diff --git a/internal/app/workflow.go b/internal/app/workflow.go index d8a4d5e..4602c8a 100644 --- a/internal/app/workflow.go +++ b/internal/app/workflow.go @@ -23,6 +23,7 @@ import ( "github.com/tailstick/tailstick/internal/tailscale" ) +// Runtime holds filesystem paths and flags that control Manager behaviour. type Runtime struct { ConfigPath string StatePath string @@ -31,6 +32,7 @@ type Runtime struct { DryRun bool } +// Manager orchestrates enrollment, reconciliation, and cleanup of device leases. type Manager struct { Runtime Runtime Logger *logging.Logger @@ -39,6 +41,7 @@ type Manager struct { HostCtx platform.Context } +// NewManager creates a Manager with resolved paths, a logger, and a platform context. func NewManager(rt Runtime) (*Manager, error) { host, err := platform.Detect() if err != nil { @@ -80,6 +83,8 @@ func (m *Manager) Close() { _ = m.Logger.Close() } +// Enroll creates a new device lease: validates options, installs/connects tailscale, +// persists state, and optionally installs the background agent. func (m *Manager) Enroll(ctx context.Context, opts model.RuntimeOptions) (model.LeaseRecord, error) { if !m.Runtime.DryRun && !platform.IsElevated() { return model.LeaseRecord{}, fmt.Errorf("elevated privileges are required for enrollment; %s", platform.ElevationHint(m.HostCtx.ExePath, nil)) @@ -208,6 +213,7 @@ func (m *Manager) Enroll(ctx context.Context, opts model.RuntimeOptions) (model. return rec, nil } +// AgentOnce runs a single reconciliation pass over all lease records. func (m *Manager) AgentOnce(ctx context.Context) error { st, err := state.Load(m.Runtime.StatePath) if err != nil { @@ -249,6 +255,8 @@ func (m *Manager) AgentOnce(ctx context.Context) error { return nil } +// AgentRun runs continuous reconciliation at the given interval until all +// managed leases are cleaned or the context is cancelled. func (m *Manager) AgentRun(ctx context.Context, interval time.Duration) error { if interval <= 0 { interval = 1 * time.Minute @@ -277,6 +285,7 @@ func (m *Manager) AgentRun(ctx context.Context, interval time.Duration) error { } } +// ForceCleanup immediately cleans up the lease with the given ID. func (m *Manager) ForceCleanup(ctx context.Context, leaseID string) error { if strings.TrimSpace(leaseID) == "" { return fmt.Errorf("lease id is required") diff --git a/internal/config/config.go b/internal/config/config.go index 0ee9c3f..f375cb6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,9 @@ const ( DefaultConfigFile = "tailstick.config.json" ) +// Load reads and validates the TailStick configuration from the given file path. +// If path is empty, DefaultConfigFile is used. +// Environment variable placeholders (${VAR}) are expanded before parsing. func Load(path string) (model.Config, error) { if path == "" { path = DefaultConfigFile @@ -40,6 +43,7 @@ func Load(path string) (model.Config, error) { return cfg, nil } +// Validate checks that a Config has at least one preset, unique IDs, and auth material. func Validate(cfg model.Config) error { if len(cfg.Presets) == 0 { return errors.New("config must define at least one preset") @@ -65,6 +69,7 @@ func Validate(cfg model.Config) error { return nil } +// FindPreset returns the preset matching id, or the default/first preset if id is empty. func FindPreset(cfg model.Config, id string) (model.Preset, error) { if id == "" { id = cfg.DefaultPreset @@ -90,6 +95,8 @@ func ResolvePath(baseDir, path string) string { return filepath.Join(baseDir, path) } +// ResolvePresetSecrets fills in secret fields from environment variables when +// the inline values are empty. Returns a copy; the original preset is not modified. func ResolvePresetSecrets(p model.Preset) model.Preset { out := p if strings.TrimSpace(out.AuthKey) == "" && strings.TrimSpace(out.AuthKeyEnv) != "" { diff --git a/internal/gui/server.go b/internal/gui/server.go index 737d494..4375b4f 100644 --- a/internal/gui/server.go +++ b/internal/gui/server.go @@ -21,12 +21,17 @@ import ( //go:embed index.html tailstick-favicon.png var staticFS embed.FS +// Server hosts the TailStick web UI and JSON API. type Server struct { + // ConfigPath is the filesystem path to the TailStick configuration file. ConfigPath string - Logf func(format string, args ...any) - EnrollFn func(context.Context, model.RuntimeOptions) (model.LeaseRecord, error) + // Logf writes structured log lines. May be nil. + Logf func(format string, args ...any) + // EnrollFn is called by POST /api/enroll to create a new device lease. + EnrollFn func(context.Context, model.RuntimeOptions) (model.LeaseRecord, error) } +// enrollRequest is the JSON body accepted by POST /api/enroll. type enrollRequest struct { PresetID string `json:"presetId"` Mode string `json:"mode"` @@ -39,6 +44,9 @@ type enrollRequest struct { Password string `json:"password"` } +// Run starts the GUI HTTP server on the given host:port. +// If openBrowser is true the default browser is launched to the UI URL. +// The server shuts down when ctx is cancelled. func Run(ctx context.Context, srv *Server, openBrowser bool, host string, port int) error { host = strings.TrimSpace(host) if host == "" { @@ -82,13 +90,49 @@ func Run(ctx context.Context, srv *Server, openBrowser bool, host string, port i return err } +// presetSummary is the safe subset of a Preset returned by the API. +type presetSummary struct { + ID string `json:"id"` + Description string `json:"description"` + Tags []string `json:"tags"` + AcceptRoutes bool `json:"acceptRoutes"` + AllowExitNodeSelection bool `json:"allowExitNodeSelection"` + ApprovedExitNodes []string `json:"approvedExitNodes"` +} + func (s *Server) presets(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } cfg, err := config.Load(s.ConfigPath) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - writeJSON(w, map[string]any{"defaultPreset": cfg.DefaultPreset, "presets": cfg.Presets}) + summaries := make([]presetSummary, len(cfg.Presets)) + for i, p := range cfg.Presets { + summaries[i] = presetSummary{ + ID: p.ID, + Description: p.Description, + Tags: p.Tags, + AcceptRoutes: p.AcceptRoutes, + AllowExitNodeSelection: p.AllowExitNodeSelection, + ApprovedExitNodes: p.ApprovedExitNodes, + } + } + writeJSON(w, map[string]any{"defaultPreset": cfg.DefaultPreset, "presets": summaries}) +} + +var validModes = map[string]bool{ + string(model.LeaseModeSession): true, + string(model.LeaseModeTimed): true, + string(model.LeaseModePermanent): true, +} + +var validChannels = map[string]bool{ + string(model.ChannelStable): true, + string(model.ChannelLatest): true, } func (s *Server) enroll(w http.ResponseWriter, r *http.Request) { @@ -101,6 +145,22 @@ func (s *Server) enroll(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid json body", http.StatusBadRequest) return } + if req.Mode != "" && !validModes[req.Mode] { + writeJSONCode(w, http.StatusBadRequest, map[string]any{"ok": false, "error": fmt.Sprintf("invalid mode %q: must be session, timed, or permanent", req.Mode)}) + return + } + if req.Channel != "" && !validChannels[req.Channel] { + writeJSONCode(w, http.StatusBadRequest, map[string]any{"ok": false, "error": fmt.Sprintf("invalid channel %q: must be stable or latest", req.Channel)}) + return + } + if req.Days < 0 { + writeJSONCode(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "days must be non-negative"}) + return + } + if req.CustomDays < 0 { + writeJSONCode(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "customDays must be non-negative"}) + return + } password := strings.TrimSpace(req.Password) if password == "" { password = strings.TrimSpace(os.Getenv("TAILSTICK_OPERATOR_PASSWORD")) diff --git a/internal/model/types.go b/internal/model/types.go index c47acbd..ccbae70 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -1,7 +1,9 @@ +// Package model defines the shared data types used across TailStick packages. package model import "time" +// LeaseMode controls how long a device lease persists. type LeaseMode string const ( @@ -10,6 +12,7 @@ const ( LeaseModePermanent LeaseMode = "permanent" ) +// Channel selects the Tailscale installation track. type Channel string const ( @@ -17,6 +20,7 @@ const ( ChannelLatest Channel = "latest" ) +// LeaseStatus represents the current lifecycle state of a lease. type LeaseStatus string const ( @@ -26,6 +30,7 @@ const ( LeaseStatusCleaned LeaseStatus = "cleaned" ) +// Config is the top-level TailStick configuration loaded from JSON. type Config struct { StableVersion string `json:"stableVersion"` DefaultPreset string `json:"defaultPreset"` @@ -34,6 +39,7 @@ type Config struct { Presets []Preset `json:"presets"` } +// Preset defines a named enrollment profile within the configuration. type Preset struct { ID string `json:"id"` Description string `json:"description"` @@ -50,6 +56,7 @@ type Preset struct { Cleanup Cleanup `json:"cleanup"` } +// Install holds platform-specific install and uninstall command templates. type Install struct { LinuxStable []string `json:"linuxStable"` LinuxLatest []string `json:"linuxLatest"` @@ -59,6 +66,7 @@ type Install struct { WindowsUninstall []string `json:"windowsUninstall"` } +// Cleanup holds configuration for device removal after lease expiry. type Cleanup struct { Tailnet string `json:"tailnet"` APIKey string `json:"apiKey"` @@ -66,6 +74,7 @@ type Cleanup struct { DeviceDeleteEnabled bool `json:"deviceDeleteEnabled"` } +// RuntimeOptions carries the user-supplied parameters for an enrollment request. type RuntimeOptions struct { PresetID string Mode LeaseMode @@ -79,6 +88,7 @@ type RuntimeOptions struct { Password string } +// LeaseRecord represents a single enrolled device lease persisted in local state. type LeaseRecord struct { LeaseID string `json:"leaseId"` PresetID string `json:"presetId"` @@ -102,12 +112,14 @@ type LeaseRecord struct { EncryptedSecret string `json:"encryptedSecret,omitempty"` } +// LocalState is the on-disk state file containing all lease records. type LocalState struct { SchemaVersion int `json:"schemaVersion"` UpdatedAt time.Time `json:"updatedAt"` Records []LeaseRecord `json:"records"` } +// AuditEntry is a single line in the append-only audit log. type AuditEntry struct { Timestamp time.Time `json:"timestamp"` LeaseID string `json:"leaseId"` diff --git a/internal/state/store.go b/internal/state/store.go index 0015e43..27eb3e9 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -10,6 +10,8 @@ import ( "github.com/tailstick/tailstick/internal/model" ) +// Load reads the local state file. Returns an empty but valid state if the file +// does not exist yet. func Load(path string) (model.LocalState, error) { b, err := os.ReadFile(path) if err != nil { @@ -31,6 +33,8 @@ func Load(path string) (model.LocalState, error) { return st, nil } +// Save writes state to disk atomically. It updates SchemaVersion and UpdatedAt +// on a copy to avoid mutating the caller's struct. func Save(path string, st model.LocalState) error { st.SchemaVersion = 1 st.UpdatedAt = time.Now().UTC() @@ -49,6 +53,7 @@ func Save(path string, st model.LocalState) error { return os.Rename(tmp, path) } +// UpsertRecord inserts or replaces a lease record by LeaseID. func UpsertRecord(st model.LocalState, rec model.LeaseRecord) model.LocalState { for i := range st.Records { if st.Records[i].LeaseID == rec.LeaseID { @@ -60,6 +65,8 @@ func UpsertRecord(st model.LocalState, rec model.LeaseRecord) model.LocalState { return st } +// AppendAudit appends a JSON-encoded audit entry to the log file. +// The entry's Timestamp field is overwritten with the current UTC time. func AppendAudit(path string, entry model.AuditEntry) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err diff --git a/internal/tailscale/client.go b/internal/tailscale/client.go index 1ea245d..2663f17 100644 --- a/internal/tailscale/client.go +++ b/internal/tailscale/client.go @@ -15,17 +15,21 @@ import ( "github.com/tailstick/tailstick/internal/platform" ) +// Client wraps command-line interactions with the tailscale binary. type Client struct { Runner platform.Runner } var deleteDeviceHTTPClient = http.DefaultClient +// IsInstalled reports whether the tailscale binary is available on PATH. func (c Client) IsInstalled(ctx context.Context) bool { _, err := c.Runner.Run(ctx, []string{"tailscale", "version"}) return err == nil } +// EnsureInstalled installs tailscale if not already present. +// For the stable channel it also pins the exact version. func (c Client) EnsureInstalled(ctx context.Context, preset model.Preset, channel model.Channel, stableVersion string) error { if !c.IsInstalled(ctx) { cmd := installCommand(preset, channel) @@ -49,6 +53,7 @@ func (c Client) EnsureInstalled(ctx context.Context, preset model.Preset, channe return nil } +// Up runs tailscale up with the given auth key, hostname, and options. func (c Client) Up(ctx context.Context, preset model.Preset, deviceName string, mode model.LeaseMode, exitNode string) error { auth := preset.AuthKey if mode == model.LeaseModeSession { @@ -80,16 +85,19 @@ func (c Client) Up(ctx context.Context, preset model.Preset, deviceName string, return err } +// Down disconnects the active tailscale node. func (c Client) Down(ctx context.Context) error { _, err := c.Runner.Run(ctx, []string{"tailscale", "down"}) return err } +// Logout logs out the current tailscale node. func (c Client) Logout(ctx context.Context) error { _, err := c.Runner.Run(ctx, []string{"tailscale", "logout"}) return err } +// Status returns the current tailscale status including the self peer identity. func (c Client) Status(ctx context.Context) (model.TailscaleStatus, error) { out, err := c.Runner.Run(ctx, []string{"tailscale", "status", "--json"}) if err != nil { @@ -122,6 +130,8 @@ func (c Client) Status(ctx context.Context) (model.TailscaleStatus, error) { return st, nil } +// Uninstall removes tailscale using the platform-appropriate command. +// Returns nil silently if no uninstall command is configured. func (c Client) Uninstall(ctx context.Context, preset model.Preset) error { cmd := uninstallCommand(preset) if len(cmd) == 0 { @@ -131,6 +141,8 @@ func (c Client) Uninstall(ctx context.Context, preset model.Preset) error { return err } +// DeleteDevice removes a device from the tailnet via the Tailscale API. +// Returns nil if either apiKey or deviceID is empty (treated as no-op). func DeleteDevice(ctx context.Context, apiKey, deviceID string) error { if strings.TrimSpace(apiKey) == "" || strings.TrimSpace(deviceID) == "" { return nil