Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions internal/app/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
7 changes: 7 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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
Expand All @@ -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) != "" {
Expand Down
66 changes: 63 additions & 3 deletions internal/gui/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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 == "" {
Expand Down Expand Up @@ -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) {
Expand All @@ -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"))
Expand Down
12 changes: 12 additions & 0 deletions internal/model/types.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -10,13 +12,15 @@ const (
LeaseModePermanent LeaseMode = "permanent"
)

// Channel selects the Tailscale installation track.
type Channel string

const (
ChannelStable Channel = "stable"
ChannelLatest Channel = "latest"
)

// LeaseStatus represents the current lifecycle state of a lease.
type LeaseStatus string

const (
Expand All @@ -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"`
Expand All @@ -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"`
Expand All @@ -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"`
Expand All @@ -59,13 +66,15 @@ 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"`
APIKeyEnv string `json:"apiKeyEnv"`
DeviceDeleteEnabled bool `json:"deviceDeleteEnabled"`
}

// RuntimeOptions carries the user-supplied parameters for an enrollment request.
type RuntimeOptions struct {
PresetID string
Mode LeaseMode
Expand All @@ -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"`
Expand All @@ -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"`
Expand Down
7 changes: 7 additions & 0 deletions internal/state/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
Expand All @@ -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 {
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions internal/tailscale/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down