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
8 changes: 8 additions & 0 deletions internal/app/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/tailstick/tailstick/internal/tailscale"
)

// Runtime holds filesystem paths and flags that configure a Manager instance.
type Runtime struct {
ConfigPath string
StatePath string
Expand All @@ -34,6 +35,7 @@ type Runtime struct {
DryRun bool
}

// Manager orchestrates lease enrollment, agent scheduling, and cleanup.
type Manager struct {
Runtime Runtime
Logger *logging.Logger
Expand All @@ -42,6 +44,7 @@ type Manager struct {
HostCtx platform.Context
}

// NewManager creates a Manager by detecting the host platform, initializing logging, and preparing paths.
func NewManager(rt Runtime) (*Manager, error) {
host, err := platform.Detect()
if err != nil {
Expand Down Expand Up @@ -79,10 +82,12 @@ func NewManager(rt Runtime) (*Manager, error) {
}, nil
}

// Close releases the Manager's log file handle.
func (m *Manager) Close() {
_ = m.Logger.Close()
}

// Enroll creates a new lease: validates the preset, installs Tailscale, authenticates, and persists the lease record.
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 @@ -211,6 +216,7 @@ func (m *Manager) Enroll(ctx context.Context, opts model.RuntimeOptions) (model.
return rec, nil
}

// AgentOnce performs a single reconciliation pass over all leases, cleaning up expired ones.
func (m *Manager) AgentOnce(ctx context.Context) error {
st, err := state.Load(m.Runtime.StatePath)
if err != nil {
Expand Down Expand Up @@ -252,6 +258,7 @@ func (m *Manager) AgentOnce(ctx context.Context) error {
return nil
}

// AgentRun starts a long-running agent loop that periodically calls AgentOnce until no managed leases remain.
func (m *Manager) AgentRun(ctx context.Context, interval time.Duration) error {
if interval <= 0 {
interval = 1 * time.Minute
Expand Down Expand Up @@ -280,6 +287,7 @@ func (m *Manager) AgentRun(ctx context.Context, interval time.Duration) error {
}
}

// ForceCleanup immediately cleans up a specific lease by ID, regardless of its expiry status.
func (m *Manager) ForceCleanup(ctx context.Context, leaseID string) error {
if strings.TrimSpace(leaseID) == "" {
return fmt.Errorf("lease id is required")
Expand Down
5 changes: 5 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const (
DefaultConfigFile = "tailstick.config.json"
)

// Load reads and parses the configuration file, expanding environment variables and validating presets.
func Load(path string) (model.Config, error) {
if path == "" {
path = DefaultConfigFile
Expand All @@ -43,6 +44,7 @@ func Load(path string) (model.Config, error) {
return cfg, nil
}

// Validate checks that the configuration has at least one preset with valid 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 @@ -68,6 +70,7 @@ func Validate(cfg model.Config) error {
return nil
}

// FindPreset returns the preset matching the given ID, the default preset, or the first preset.
func FindPreset(cfg model.Config, id string) (model.Preset, error) {
if id == "" {
id = cfg.DefaultPreset
Expand All @@ -83,6 +86,7 @@ func FindPreset(cfg model.Config, id string) (model.Preset, error) {
return model.Preset{}, fmt.Errorf("preset %q not found", id)
}

// ResolvePath joins a relative path to a base directory, returning absolute paths unchanged.
func ResolvePath(baseDir, path string) string {
if path == "" {
return ""
Expand All @@ -93,6 +97,7 @@ func ResolvePath(baseDir, path string) string {
return filepath.Join(baseDir, path)
}

// ResolvePresetSecrets fills in preset auth keys and API keys from environment variables when inline values are empty.
func ResolvePresetSecrets(p model.Preset) model.Preset {
out := p
if strings.TrimSpace(out.AuthKey) == "" && strings.TrimSpace(out.AuthKeyEnv) != "" {
Expand Down
3 changes: 3 additions & 0 deletions internal/crypto/secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ import (
"golang.org/x/crypto/scrypt"
)

// Envelope holds the encrypted payload and metadata for AES-GCM sealed secrets.
type Envelope struct {
Mode string `json:"mode"`
Salt string `json:"salt"`
Nonce string `json:"nonce"`
Cipher string `json:"cipher"`
}

// Encrypt encrypts plaintext using AES-GCM with a scrypt-derived key bound to the machine or a password.
func Encrypt(plain, password, machineContext string) (string, error) {
key, salt, mode, err := deriveKey(password, machineContext)
if err != nil {
Expand Down Expand Up @@ -55,6 +57,7 @@ func Encrypt(plain, password, machineContext string) (string, error) {
return base64.StdEncoding.EncodeToString(b), nil
}

// Decrypt decodes and decrypts a base64-encoded Envelope back to plaintext.
func Decrypt(encoded, password, machineContext string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
Expand Down
2 changes: 2 additions & 0 deletions internal/gui/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
//go:embed index.html tailstick-favicon.png
var staticFS embed.FS

// Server is the GUI HTTP server providing preset listing and enrollment endpoints.
type Server struct {
ConfigPath string
Logf func(format string, args ...any)
Expand Down Expand Up @@ -62,6 +63,7 @@ var validChannels = map[string]bool{
string(model.ChannelLatest): true,
}

// Run starts the GUI HTTP server on the given host:port, optionally opening a browser.
func Run(ctx context.Context, srv *Server, openBrowser bool, host string, port int) error {
host = strings.TrimSpace(host)
if host == "" {
Expand Down
5 changes: 5 additions & 0 deletions internal/logging/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ import (
"time"
)

// Logger provides thread-safe, leveled logging to both a file and stdout.
type Logger struct {
mu sync.Mutex
file *os.File
std *log.Logger
}

// New creates a Logger that appends to the given file path.
func New(path string) (*Logger, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
Expand All @@ -32,6 +34,7 @@ func New(path string) (*Logger, error) {
}, nil
}

// Close flushes and closes the underlying log file.
func (l *Logger) Close() error {
l.mu.Lock()
defer l.mu.Unlock()
Expand All @@ -43,10 +46,12 @@ func (l *Logger) Close() error {
return err
}

// Info logs a message at INFO level.
func (l *Logger) Info(format string, args ...any) {
l.write("INFO", format, args...)
}

// Error logs a message at ERROR level.
func (l *Logger) Error(format string, args ...any) {
l.write("ERROR", format, args...)
}
Expand Down
13 changes: 13 additions & 0 deletions internal/model/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package model

import "time"

// LeaseMode represents the lifecycle policy for a Tailscale device lease.
type LeaseMode string

const (
Expand All @@ -12,13 +13,15 @@ const (
LeaseModePermanent LeaseMode = "permanent"
)

// Channel selects between stable and latest Tailscale release tracks.
type Channel string

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

// LeaseStatus tracks the current phase of a lease in its lifecycle.
type LeaseStatus string

const (
Expand All @@ -28,6 +31,7 @@ const (
LeaseStatusCleaned LeaseStatus = "cleaned"
)

// Config is the top-level tailstick configuration loaded from tailstick.config.json.
type Config struct {
StableVersion string `json:"stableVersion"`
DefaultPreset string `json:"defaultPreset"`
Expand All @@ -36,6 +40,7 @@ type Config struct {
Presets []Preset `json:"presets"`
}

// Preset defines a named enrollment profile with auth keys, tags, and install commands.
type Preset struct {
ID string `json:"id"`
Description string `json:"description"`
Expand All @@ -52,6 +57,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 @@ -61,13 +67,15 @@ type Install struct {
WindowsUninstall []string `json:"windowsUninstall"`
}

// Cleanup configures post-lease device removal via the Tailscale API.
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 a single enrollment operation.
type RuntimeOptions struct {
PresetID string
Mode LeaseMode
Expand All @@ -81,6 +89,7 @@ type RuntimeOptions struct {
Password string
}

// LeaseRecord is the persistent record of a single lease, stored in state.json.
type LeaseRecord struct {
LeaseID string `json:"leaseId"`
PresetID string `json:"presetId"`
Expand All @@ -104,12 +113,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 represents a single line in the NDJSON audit log.
type AuditEntry struct {
Timestamp time.Time `json:"timestamp"`
LeaseID string `json:"leaseId"`
Expand All @@ -122,12 +133,14 @@ type AuditEntry struct {
Message string `json:"message"`
}

// TailscaleSelf holds identity fields from the local Tailscale node.
type TailscaleSelf struct {
ID string `json:"ID"`
DNSName string `json:"DNSName"`
HostName string `json:"HostName"`
}

// TailscaleStatus is the parsed output of "tailscale status --json".
type TailscaleStatus struct {
Self TailscaleSelf `json:"Self"`
}
11 changes: 11 additions & 0 deletions internal/platform/platform.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Package platform provides host detection, privilege checks, and platform-specific path helpers for tailstick.
package platform

import (
Expand All @@ -11,6 +12,7 @@ import (
"strings"
)

// Context holds detected host properties: OS, architecture, hostname, boot ID, and executable path.
type Context struct {
OS string
Arch string
Expand All @@ -19,6 +21,7 @@ type Context struct {
ExePath string
}

// Detect collects the current host Context by reading system information.
func Detect() (Context, error) {
host, err := os.Hostname()
if err != nil {
Expand All @@ -42,6 +45,7 @@ func Detect() (Context, error) {
func IsLinux() bool { return runtime.GOOS == "linux" }
func IsWindows() bool { return runtime.GOOS == "windows" }

// StatePath returns the platform-specific path for the lease state file.
func StatePath() string {
if IsWindows() {
root := os.Getenv("ProgramData")
Expand All @@ -53,6 +57,7 @@ func StatePath() string {
return "/var/lib/tailstick/state.json"
}

// LogPath returns the platform-specific path for the log file.
func LogPath() string {
if IsWindows() {
root := os.Getenv("ProgramData")
Expand All @@ -64,6 +69,7 @@ func LogPath() string {
return "/var/log/tailstick.log"
}

// LocalSecretPath returns the platform-specific directory for encrypted lease secrets.
func LocalSecretPath() string {
if IsWindows() {
root := os.Getenv("ProgramData")
Expand All @@ -75,6 +81,7 @@ func LocalSecretPath() string {
return "/var/lib/tailstick/secrets"
}

// AgentBinaryPath returns the platform-specific path where the agent binary is installed.
func AgentBinaryPath() string {
if IsWindows() {
root := os.Getenv("ProgramData")
Expand All @@ -86,6 +93,7 @@ func AgentBinaryPath() string {
return "/var/lib/tailstick/tailstick-agent"
}

// EnsureParent creates the parent directory of the given path if it does not exist.
func EnsureParent(path string) error {
parent := filepath.Dir(path)
if err := os.MkdirAll(parent, 0o755); err != nil {
Expand Down Expand Up @@ -137,6 +145,7 @@ func sanitizeHost(in string) string {
return strings.Trim(string(out), "-")
}

// RequireSupportedLinux returns an error if the current Linux distro is not Debian or Ubuntu.
func RequireSupportedLinux() error {
if !IsLinux() {
return nil
Expand All @@ -152,6 +161,7 @@ func RequireSupportedLinux() error {
return errors.New("linux target unsupported: only debian/ubuntu are supported in v1")
}

// IsElevated returns true if the current process has administrative/root privileges.
func IsElevated() bool {
if IsLinux() {
return os.Geteuid() == 0
Expand All @@ -164,6 +174,7 @@ func IsElevated() bool {
return true
}

// ElevationHint returns a human-readable suggestion for gaining elevated privileges.
func ElevationHint(exePath string, args []string) string {
if IsLinux() {
return "rerun with sudo"
Expand Down
4 changes: 4 additions & 0 deletions internal/state/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/tailstick/tailstick/internal/model"
)

// Load reads the state file, returning an empty state if the file does not exist.
func Load(path string) (model.LocalState, error) {
b, err := os.ReadFile(path)
if err != nil {
Expand All @@ -34,6 +35,7 @@ func Load(path string) (model.LocalState, error) {
return st, nil
}

// Save atomically writes the state to disk via a temporary file rename.
func Save(path string, st model.LocalState) error {
st.SchemaVersion = 1
st.UpdatedAt = time.Now().UTC()
Expand All @@ -52,6 +54,7 @@ func Save(path string, st model.LocalState) error {
return os.Rename(tmp, path)
}

// UpsertRecord inserts a new lease record or updates an existing one with the same 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 @@ -63,6 +66,7 @@ func UpsertRecord(st model.LocalState, rec model.LeaseRecord) model.LocalState {
return st
}

// AppendAudit appends a timestamped NDJSON audit entry to the audit log file.
func AppendAudit(path string, entry model.AuditEntry) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
Expand Down
Loading
Loading