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
19 changes: 12 additions & 7 deletions internal/app/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import (
"github.com/tailstick/tailstick/internal/model"
)

// Version is the current tailstick release version.
const Version = "1.0.0"

// RunCLI dispatches the CLI subcommand indicated by args and returns an exit code.
func RunCLI(args []string, rt Runtime) int {
if len(args) == 0 {
return runEnroll(args, rt)
Expand Down Expand Up @@ -72,7 +74,7 @@ func runEnroll(args []string, rt Runtime) int {
DryRun: *dryRun,
})
if err != nil {
fmt.Fprintln(os.Stderr, "init:", err)
fmt.Fprintf(os.Stderr, "init: %v\n", err)
return 1
}
defer mgr.Close()
Expand All @@ -91,7 +93,7 @@ func runEnroll(args []string, rt Runtime) int {
Password: *password,
})
if err != nil {
fmt.Fprintln(os.Stderr, "enroll:", err)
fmt.Fprintf(os.Stderr, "enroll: %v\n", err)
return 1
}
fmt.Printf("Lease created: %s\nDevice name: %s\nMode: %s\n", rec.LeaseID, rec.DeviceName, rec.Mode)
Expand All @@ -101,6 +103,7 @@ func runEnroll(args []string, rt Runtime) int {
return 0
}

// runAgent executes the agent (lease reconciliation) CLI subcommand.
func runAgent(args []string, rt Runtime) int {
fs := flag.NewFlagSet("agent", flag.ContinueOnError)
var (
Expand All @@ -124,26 +127,27 @@ func runAgent(args []string, rt Runtime) int {
DryRun: *dryRun,
})
if err != nil {
fmt.Fprintln(os.Stderr, "init:", err)
fmt.Fprintf(os.Stderr, "init: %v\n", err)
return 1
}
defer mgr.Close()

ctx := interruptContext()
if *once {
if err := mgr.AgentOnce(ctx); err != nil {
fmt.Fprintln(os.Stderr, "agent:", err)
fmt.Fprintf(os.Stderr, "agent: %v\n", err)
return 1
}
return 0
}
if err := mgr.AgentRun(ctx, *interval); err != nil && err != context.Canceled {
fmt.Fprintln(os.Stderr, "agent:", err)
fmt.Fprintf(os.Stderr, "agent: %v\n", err)
return 1
}
return 0
}

// runCleanup executes the forced cleanup CLI subcommand.
func runCleanup(args []string, rt Runtime) int {
fs := flag.NewFlagSet("cleanup", flag.ContinueOnError)
var (
Expand All @@ -165,18 +169,19 @@ func runCleanup(args []string, rt Runtime) int {
DryRun: *dryRun,
})
if err != nil {
fmt.Fprintln(os.Stderr, "init:", err)
fmt.Fprintf(os.Stderr, "init: %v\n", err)
return 1
}
defer mgr.Close()
if err := mgr.ForceCleanup(interruptContext(), *leaseID); err != nil {
fmt.Fprintln(os.Stderr, "cleanup:", err)
fmt.Fprintf(os.Stderr, "cleanup: %v\n", err)
return 1
}
fmt.Println("cleanup complete")
return 0
}

// interruptContext returns a context that is cancelled on SIGINT or SIGTERM.
func interruptContext() context.Context {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
go func() {
Expand Down
5 changes: 3 additions & 2 deletions internal/app/gui.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/tailstick/tailstick/internal/gui"
)

// RunGUI starts the browser-based setup wizard and returns an exit code.
func RunGUI(args []string, rt Runtime) int {
fs := flag.NewFlagSet("gui", flag.ContinueOnError)
var (
Expand All @@ -31,7 +32,7 @@ func RunGUI(args []string, rt Runtime) int {
DryRun: *dryRun,
})
if err != nil {
fmt.Fprintln(os.Stderr, "init:", err)
fmt.Fprintf(os.Stderr, "init: %v\n", err)
return 1
}
defer mgr.Close()
Expand All @@ -41,7 +42,7 @@ func RunGUI(args []string, rt Runtime) int {
Logf: mgr.Logger.Info,
EnrollFn: mgr.Enroll,
}, *openBrowser, *host, *port); err != nil {
fmt.Fprintln(os.Stderr, "gui:", err)
fmt.Fprintf(os.Stderr, "gui: %v\n", err)
return 1
}
return 0
Expand Down
51 changes: 37 additions & 14 deletions internal/app/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import (
"github.com/tailstick/tailstick/internal/tailscale"
)

// Runtime holds the resolved filesystem paths and runtime flags shared by
// CLI and GUI entry points.
type Runtime struct {
ConfigPath string
StatePath string
Expand All @@ -34,6 +36,9 @@ type Runtime struct {
DryRun bool
}

// Manager coordinates the full lease lifecycle: enrollment, reconciliation,
// and cleanup. It composes the tailscale client, state store, crypto, and
// platform packages.
type Manager struct {
Runtime Runtime
Logger *logging.Logger
Expand All @@ -42,6 +47,8 @@ type Manager struct {
HostCtx platform.Context
}

// NewManager initialises a Manager by detecting the host platform, resolving
// default paths, creating required directories, and opening the log file.
func NewManager(rt Runtime) (*Manager, error) {
host, err := platform.Detect()
if err != nil {
Expand All @@ -60,10 +67,10 @@ func NewManager(rt Runtime) (*Manager, error) {
rt.LogPath = platform.LogPath()
}
if err := platform.EnsureParent(rt.StatePath); err != nil {
return nil, err
return nil, fmt.Errorf("ensure state directory: %w", err)
}
if err := platform.EnsureParent(rt.LogPath); err != nil {
return nil, err
return nil, fmt.Errorf("ensure log directory: %w", err)
}
log, err := logging.New(rt.LogPath)
if err != nil {
Expand All @@ -79,27 +86,30 @@ func NewManager(rt Runtime) (*Manager, error) {
}, nil
}

// Close releases resources held by the Manager, including the log file.
func (m *Manager) Close() {
_ = m.Logger.Close()
}

// Enroll creates a new lease, installs and enrols Tailscale, persists state,
// and optionally installs the background agent for non-permanent leases.
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))
}
if runtime.GOOS == "linux" {
if err := platform.RequireSupportedLinux(); err != nil {
return model.LeaseRecord{}, err
return model.LeaseRecord{}, fmt.Errorf("linux platform check: %w", err)
}
}

cfg, err := config.Load(m.Runtime.ConfigPath)
if err != nil {
return model.LeaseRecord{}, err
return model.LeaseRecord{}, fmt.Errorf("load config: %w", err)
}
preset, err := config.FindPreset(cfg, opts.PresetID)
if err != nil {
return model.LeaseRecord{}, err
return model.LeaseRecord{}, fmt.Errorf("find preset: %w", err)
}
if expected := strings.TrimSpace(cfg.OperatorPassword); expected != "" {
if subtle.ConstantTimeCompare([]byte(opts.Password), []byte(expected)) != 1 {
Expand Down Expand Up @@ -145,16 +155,16 @@ func (m *Manager) Enroll(ctx context.Context, opts model.RuntimeOptions) (model.

secretPayload, err := json.Marshal(preset.Cleanup)
if err != nil {
return model.LeaseRecord{}, err
return model.LeaseRecord{}, fmt.Errorf("marshal cleanup secret: %w", err)
}
machineCtx := tailscale.BuildMachineContext(m.HostCtx.Host, m.HostCtx.ExePath)
encSecret, err := intcrypto.Encrypt(string(secretPayload), "", machineCtx)
if err != nil {
return model.LeaseRecord{}, err
return model.LeaseRecord{}, fmt.Errorf("encrypt cleanup secret: %w", err)
}
secretRef, err := m.writeLeaseSecret(leaseID, encSecret)
if err != nil {
return model.LeaseRecord{}, err
return model.LeaseRecord{}, fmt.Errorf("write lease secret: %w", err)
}

cleanupState := preset.Cleanup
Expand Down Expand Up @@ -183,11 +193,11 @@ func (m *Manager) Enroll(ctx context.Context, opts model.RuntimeOptions) (model.

st, err := state.Load(m.Runtime.StatePath)
if err != nil {
return model.LeaseRecord{}, err
return model.LeaseRecord{}, fmt.Errorf("load state: %w", err)
}
st = state.UpsertRecord(st, rec)
if err := state.Save(m.Runtime.StatePath, st); err != nil {
return model.LeaseRecord{}, err
return model.LeaseRecord{}, fmt.Errorf("save state: %w", err)
}
if err := state.AppendAudit(m.Runtime.AuditPath, model.AuditEntry{
LeaseID: rec.LeaseID,
Expand All @@ -211,6 +221,8 @@ func (m *Manager) Enroll(ctx context.Context, opts model.RuntimeOptions) (model.
return rec, nil
}

// AgentOnce performs a single reconciliation pass over all stored leases,
// cleaning up expired ones and self-removing the agent if no active leases remain.
func (m *Manager) AgentOnce(ctx context.Context) error {
st, err := state.Load(m.Runtime.StatePath)
if err != nil {
Expand Down Expand Up @@ -252,6 +264,8 @@ func (m *Manager) AgentOnce(ctx context.Context) error {
return nil
}

// AgentRun runs AgentOnce in a loop at the given interval until no active
// managed leases remain 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 @@ -280,6 +294,7 @@ func (m *Manager) AgentRun(ctx context.Context, interval time.Duration) error {
}
}

// ForceCleanup forces immediate cleanup of 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 Expand Up @@ -331,7 +346,9 @@ func (m *Manager) cleanupRecord(ctx context.Context, rec model.LeaseRecord) mode
machineCtx := tailscale.BuildMachineContext(m.HostCtx.Host, m.HostCtx.ExePath)
raw, err := intcrypto.Decrypt(encodedSecret, "", machineCtx)
if err == nil {
_ = json.Unmarshal([]byte(raw), &cleanupCfg)
if jsonErr := json.Unmarshal([]byte(raw), &cleanupCfg); jsonErr != nil {
m.Logger.Error("parse decrypted cleanup config: %v", jsonErr)
}
}
}
if strings.TrimSpace(cleanupCfg.APIKey) == "" {
Expand All @@ -342,10 +359,10 @@ func (m *Manager) cleanupRecord(ctx context.Context, rec model.LeaseRecord) mode

if cleanupCfg.DeviceDeleteEnabled && cleanupCfg.APIKey != "" && rec.DeviceID != "" {
delCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := tailscale.DeleteDevice(delCtx, cleanupCfg.APIKey, rec.DeviceID); err != nil {
errs = append(errs, "delete device API: "+err.Error())
}
cancel()
}

if rec.Mode != model.LeaseModePermanent {
Expand Down Expand Up @@ -381,6 +398,8 @@ func (m *Manager) cleanupRecord(ctx context.Context, rec model.LeaseRecord) mode
return rec
}

// shouldCleanup reports whether the lease should be cleaned up based on its
// mode, boot ID, and expiration time.
func shouldCleanup(rec model.LeaseRecord, currentBootID string, now time.Time) bool {
switch rec.Mode {
case model.LeaseModeSession:
Expand Down Expand Up @@ -677,6 +696,7 @@ func sanitizeNamePart(in string) string {
return strings.Trim(strings.Join(strings.Fields(strings.ReplaceAll(string(out), "--", "-")), "-"), "-")
}

// newLeaseID generates a cryptographically random, lowercase base32 lease identifier.
func newLeaseID() (string, error) {
b := make([]byte, 5)
if _, err := rand.Read(b); err != nil {
Expand All @@ -685,6 +705,7 @@ func newLeaseID() (string, error) {
return strings.ToLower(strings.TrimRight(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b), "=")), nil
}

// hasActiveManagedLeases reports whether any non-permanent, non-cleaned leases exist.
func hasActiveManagedLeases(st model.LocalState) bool {
for _, rec := range st.Records {
if rec.Mode == model.LeaseModePermanent {
Expand All @@ -697,6 +718,7 @@ func hasActiveManagedLeases(st model.LocalState) bool {
return false
}

// installSnapshotEmpty reports whether the install snapshot contains no commands.
func installSnapshotEmpty(inst model.Install) bool {
return len(inst.LinuxStable) == 0 &&
len(inst.LinuxLatest) == 0 &&
Expand Down Expand Up @@ -739,17 +761,18 @@ func (m *Manager) resolvePresetFromConfig(presetID string) model.Preset {
return config.ResolvePresetSecrets(p)
}

// writeLeaseSecret persists the encrypted lease secret to disk and returns its path.
func (m *Manager) writeLeaseSecret(leaseID, encrypted string) (string, error) {
if m.Runtime.DryRun {
return "dry-run://secret/" + leaseID, nil
}
secretDir := platform.LocalSecretPath()
if err := os.MkdirAll(secretDir, 0o700); err != nil {
return "", err
return "", fmt.Errorf("create secret directory: %w", err)
}
path := filepath.Join(secretDir, leaseID+".enc")
if err := os.WriteFile(path, []byte(encrypted), 0o600); err != nil {
return "", err
return "", fmt.Errorf("write secret file: %w", err)
}
return path, nil
}
7 changes: 6 additions & 1 deletion internal/crypto/secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,24 @@
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"

Check failure on line 14 in internal/crypto/secret.go

View workflow job for this annotation

GitHub Actions / test-and-build

"fmt" imported and not used
"io"
"strings"

"golang.org/x/crypto/scrypt"
)

// Envelope holds the encrypted payload and the parameters needed for decryption.
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 key derived from the
// password or machine context. The result is a base64-encoded JSON envelope.
func Encrypt(plain, password, machineContext string) (string, error) {
key, salt, mode, err := deriveKey(password, machineContext)
if err != nil {
Expand Down Expand Up @@ -55,6 +59,7 @@
return base64.StdEncoding.EncodeToString(b), nil
}

// Decrypt decrypts a base64-encoded JSON envelope produced by Encrypt.
func Decrypt(encoded, password, machineContext string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
Expand Down Expand Up @@ -117,7 +122,7 @@
base = machineContext
}
if strings.TrimSpace(base) == "" {
return nil, nil, "", fmt.Errorf("empty key material")
return nil, nil, "", errors.New("empty key material")
}
combined := sha256.Sum256([]byte(base))
key, err := scrypt.Key(combined[:], salt, 1<<15, 8, 1, 32)
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,12 +24,14 @@ import (
//go:embed index.html tailstick-favicon.png
var staticFS embed.FS

// Server serves the browser-based setup wizard via a local HTTP server.
type Server struct {
ConfigPath string
Logf func(format string, args ...any)
EnrollFn func(context.Context, model.RuntimeOptions) (model.LeaseRecord, error)
}

// enrollRequest represents the JSON payload sent by the browser to create a lease.
type enrollRequest struct {
PresetID string `json:"presetId"`
Mode string `json:"mode"`
Expand Down
Loading