470 lines
11 KiB
Go
470 lines
11 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"tailscale.com/tsnet"
|
|
)
|
|
|
|
// Phase is the coarse lifecycle state of the service, shown as the header
|
|
// status pill in the GUI.
|
|
type Phase int
|
|
|
|
const (
|
|
PhaseIdle Phase = iota
|
|
PhaseStarting
|
|
PhaseReady
|
|
PhaseRetrying
|
|
PhaseError
|
|
PhaseStopped
|
|
)
|
|
|
|
func (p Phase) String() string {
|
|
switch p {
|
|
case PhaseStarting:
|
|
return "starting"
|
|
case PhaseReady:
|
|
return "ready"
|
|
case PhaseRetrying:
|
|
return "retrying"
|
|
case PhaseError:
|
|
return "error"
|
|
case PhaseStopped:
|
|
return "stopped"
|
|
default:
|
|
return "idle"
|
|
}
|
|
}
|
|
|
|
// StepState is the state of one boot step.
|
|
type StepState int
|
|
|
|
const (
|
|
StepPending StepState = iota
|
|
StepRunning
|
|
StepDone
|
|
StepFailed
|
|
StepSkipped
|
|
)
|
|
|
|
// Boot step keys. The GUI maps these onto localised titles.
|
|
const (
|
|
StepKeyConfig = "config"
|
|
StepKeyTsnet = "tsnet"
|
|
StepKeyRules = "rules"
|
|
StepKeyServices = "services"
|
|
StepKeyMonitors = "monitors"
|
|
StepKeyReady = "ready"
|
|
)
|
|
|
|
// BootStep is one entry in the startup checklist.
|
|
type BootStep struct {
|
|
Key string
|
|
State StepState
|
|
Err string
|
|
Started time.Time
|
|
Finished time.Time
|
|
}
|
|
|
|
// Elapsed is how long the step took, or how long it has been running.
|
|
func (s BootStep) Elapsed() time.Duration {
|
|
if s.Started.IsZero() {
|
|
return 0
|
|
}
|
|
if s.Finished.IsZero() {
|
|
return time.Since(s.Started)
|
|
}
|
|
return s.Finished.Sub(s.Started)
|
|
}
|
|
|
|
// State is an immutable snapshot of the supervisor, safe to read from the UI
|
|
// goroutine.
|
|
type State struct {
|
|
Phase Phase
|
|
Steps []BootStep
|
|
Err string
|
|
StartedAt time.Time
|
|
ReadyAt time.Time
|
|
Restarts int
|
|
// NextRetryAt is set while Phase is PhaseRetrying.
|
|
NextRetryAt time.Time
|
|
|
|
Config *Config
|
|
Server *tsnet.Server
|
|
Peers *PeerMonitor
|
|
Lan *LanScanner
|
|
}
|
|
|
|
// Ready reports whether the service finished booting.
|
|
func (s State) Ready() bool { return s.Phase == PhaseReady }
|
|
|
|
// Progress is the fraction of boot steps completed, for the splash bar.
|
|
func (s State) Progress() float32 {
|
|
if len(s.Steps) == 0 {
|
|
return 0
|
|
}
|
|
done := 0
|
|
for _, st := range s.Steps {
|
|
if st.State == StepDone || st.State == StepSkipped {
|
|
done++
|
|
}
|
|
}
|
|
return float32(done) / float32(len(s.Steps))
|
|
}
|
|
|
|
// SupervisorOptions configures a Supervisor.
|
|
type SupervisorOptions struct {
|
|
ConfigPath string
|
|
ConfigURL string
|
|
TsnetDebug bool
|
|
Logger *slog.Logger
|
|
// MaxBackoff caps the retry delay. Zero means 30s.
|
|
MaxBackoff time.Duration
|
|
}
|
|
|
|
// Supervisor owns the service lifecycle for the GUI. It is the same startup
|
|
// sequence the headless binary runs in serviceLogic, split into observable
|
|
// steps and wrapped in a restart loop that keeps the window alive when
|
|
// tailscale is unreachable — a CLI can exit on failure, a GUI must explain
|
|
// itself instead.
|
|
type Supervisor struct {
|
|
opt SupervisorOptions
|
|
logger *slog.Logger
|
|
|
|
mu sync.RWMutex
|
|
state State
|
|
|
|
subsMu sync.Mutex
|
|
subs map[int]chan struct{}
|
|
nextSub int
|
|
|
|
restartCh chan struct{}
|
|
stopOnce sync.Once
|
|
}
|
|
|
|
// NewSupervisor creates an unstarted supervisor.
|
|
func NewSupervisor(opt SupervisorOptions) *Supervisor {
|
|
logger := opt.Logger
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
if opt.MaxBackoff <= 0 {
|
|
opt.MaxBackoff = 30 * time.Second
|
|
}
|
|
return &Supervisor{
|
|
opt: opt,
|
|
logger: logger.With("from", "supervisor"),
|
|
subs: make(map[int]chan struct{}),
|
|
restartCh: make(chan struct{}, 1),
|
|
state: State{
|
|
Phase: PhaseIdle,
|
|
Steps: freshSteps(),
|
|
},
|
|
}
|
|
}
|
|
|
|
func freshSteps() []BootStep {
|
|
keys := []string{
|
|
StepKeyConfig, StepKeyTsnet, StepKeyRules,
|
|
StepKeyServices, StepKeyMonitors, StepKeyReady,
|
|
}
|
|
steps := make([]BootStep, len(keys))
|
|
for i, k := range keys {
|
|
steps[i] = BootStep{Key: k}
|
|
}
|
|
return steps
|
|
}
|
|
|
|
// Snapshot returns the current state.
|
|
func (s *Supervisor) Snapshot() State {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
st := s.state
|
|
st.Steps = append([]BootStep(nil), s.state.Steps...)
|
|
return st
|
|
}
|
|
|
|
// Subscribe returns a coalescing wakeup channel and a cancel func.
|
|
func (s *Supervisor) Subscribe() (<-chan struct{}, func()) {
|
|
ch := make(chan struct{}, 1)
|
|
s.subsMu.Lock()
|
|
id := s.nextSub
|
|
s.nextSub++
|
|
s.subs[id] = ch
|
|
s.subsMu.Unlock()
|
|
|
|
var once sync.Once
|
|
return ch, func() {
|
|
once.Do(func() {
|
|
s.subsMu.Lock()
|
|
delete(s.subs, id)
|
|
s.subsMu.Unlock()
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s *Supervisor) notify() {
|
|
s.subsMu.Lock()
|
|
for _, ch := range s.subs {
|
|
select {
|
|
case ch <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
s.subsMu.Unlock()
|
|
}
|
|
|
|
func (s *Supervisor) update(f func(*State)) {
|
|
s.mu.Lock()
|
|
f(&s.state)
|
|
s.mu.Unlock()
|
|
s.notify()
|
|
}
|
|
|
|
func (s *Supervisor) stepStart(key string) {
|
|
s.update(func(st *State) {
|
|
for i := range st.Steps {
|
|
if st.Steps[i].Key == key {
|
|
st.Steps[i].State = StepRunning
|
|
st.Steps[i].Started = time.Now()
|
|
st.Steps[i].Err = ""
|
|
return
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func (s *Supervisor) stepDone(key string, err error) {
|
|
s.update(func(st *State) {
|
|
for i := range st.Steps {
|
|
if st.Steps[i].Key != key {
|
|
continue
|
|
}
|
|
st.Steps[i].Finished = time.Now()
|
|
if err != nil {
|
|
st.Steps[i].State = StepFailed
|
|
st.Steps[i].Err = err.Error()
|
|
} else {
|
|
st.Steps[i].State = StepDone
|
|
}
|
|
return
|
|
}
|
|
})
|
|
}
|
|
|
|
// Restart asks the supervisor to tear down and boot again. It never blocks.
|
|
func (s *Supervisor) Restart() {
|
|
select {
|
|
case s.restartCh <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// Run drives the boot-and-supervise loop until ctx is cancelled. It blocks, so
|
|
// callers run it on their own goroutine.
|
|
func (s *Supervisor) Run(ctx context.Context) {
|
|
backoff := time.Second
|
|
for {
|
|
if ctx.Err() != nil {
|
|
s.update(func(st *State) { st.Phase = PhaseStopped })
|
|
return
|
|
}
|
|
|
|
runCtx, cancel := context.WithCancel(ctx)
|
|
err := s.boot(runCtx)
|
|
if err == nil {
|
|
backoff = time.Second
|
|
// Supervise until something asks us to restart.
|
|
reason := s.supervise(runCtx)
|
|
cancel()
|
|
s.teardown()
|
|
if ctx.Err() != nil {
|
|
s.update(func(st *State) { st.Phase = PhaseStopped })
|
|
return
|
|
}
|
|
s.logger.Warn("restarting service", "reason", reason)
|
|
s.update(func(st *State) {
|
|
st.Phase = PhaseRetrying
|
|
st.Restarts++
|
|
st.Steps = freshSteps()
|
|
st.NextRetryAt = time.Now().Add(time.Second)
|
|
})
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-time.After(time.Second):
|
|
}
|
|
continue
|
|
}
|
|
|
|
cancel()
|
|
s.teardown()
|
|
if ctx.Err() != nil {
|
|
s.update(func(st *State) { st.Phase = PhaseStopped })
|
|
return
|
|
}
|
|
|
|
// Configuration errors will not fix themselves; surface them and wait
|
|
// for an explicit Restart rather than looping on a broken file.
|
|
if errors.Is(err, errFatalConfig) {
|
|
// Log it as well as showing it: the on-screen log sheet is the
|
|
// thing users screenshot, and a bare error panel with an empty log
|
|
// tells whoever is helping them nothing.
|
|
s.logger.Error("configuration error, waiting for retry", "err", err)
|
|
s.update(func(st *State) {
|
|
st.Phase = PhaseError
|
|
st.Err = err.Error()
|
|
})
|
|
select {
|
|
case <-ctx.Done():
|
|
s.update(func(st *State) { st.Phase = PhaseStopped })
|
|
return
|
|
case <-s.restartCh:
|
|
s.update(func(st *State) {
|
|
st.Phase = PhaseStarting
|
|
st.Err = ""
|
|
st.Steps = freshSteps()
|
|
})
|
|
continue
|
|
}
|
|
}
|
|
|
|
s.logger.Warn("startup failed, retrying", "err", err, "backoff", backoff)
|
|
s.update(func(st *State) {
|
|
st.Phase = PhaseRetrying
|
|
st.Err = err.Error()
|
|
st.Restarts++
|
|
st.NextRetryAt = time.Now().Add(backoff)
|
|
})
|
|
select {
|
|
case <-ctx.Done():
|
|
s.update(func(st *State) { st.Phase = PhaseStopped })
|
|
return
|
|
case <-s.restartCh:
|
|
case <-time.After(backoff):
|
|
}
|
|
backoff *= 2
|
|
if backoff > s.opt.MaxBackoff {
|
|
backoff = s.opt.MaxBackoff
|
|
}
|
|
s.update(func(st *State) { st.Steps = freshSteps() })
|
|
}
|
|
}
|
|
|
|
// errFatalConfig marks an error that retrying cannot fix.
|
|
var errFatalConfig = errors.New("configuration error")
|
|
|
|
// boot runs the startup sequence, reporting each step.
|
|
func (s *Supervisor) boot(ctx context.Context) error {
|
|
s.update(func(st *State) {
|
|
st.Phase = PhaseStarting
|
|
st.Err = ""
|
|
st.StartedAt = time.Now()
|
|
st.ReadyAt = time.Time{}
|
|
st.NextRetryAt = time.Time{}
|
|
})
|
|
|
|
// --- config -----------------------------------------------------------
|
|
s.stepStart(StepKeyConfig)
|
|
source := s.opt.ConfigPath
|
|
if s.opt.ConfigURL != "" {
|
|
source = s.opt.ConfigURL
|
|
s.logger.Info("using config url", "url", s.opt.ConfigURL)
|
|
}
|
|
cfg, err := LoadConfig(source)
|
|
if err != nil {
|
|
s.logger.Error("failed to load config", "source", source, "err", err)
|
|
s.stepDone(StepKeyConfig, err)
|
|
return errors.Join(errFatalConfig, err)
|
|
}
|
|
SetDoHServers(cfg.DNS.DoHServers)
|
|
if len(cfg.DNS.DoHServers) > 0 {
|
|
s.logger.Info("dns-over-https fallback enabled", "servers", cfg.DNS.DoHServers)
|
|
}
|
|
s.update(func(st *State) { st.Config = cfg })
|
|
s.stepDone(StepKeyConfig, nil)
|
|
|
|
// --- tsnet ------------------------------------------------------------
|
|
s.stepStart(StepKeyTsnet)
|
|
srv, err := InitTsNet(ctx, &cfg.Core, s.logger, s.opt.TsnetDebug)
|
|
if err != nil {
|
|
s.stepDone(StepKeyTsnet, err)
|
|
return err
|
|
}
|
|
s.update(func(st *State) { st.Server = srv })
|
|
s.stepDone(StepKeyTsnet, nil)
|
|
|
|
// --- rules ------------------------------------------------------------
|
|
s.stepStart(StepKeyRules)
|
|
NormalizeConnectRulesDstAddr(ctx, srv, cfg.Connect, s.logger)
|
|
s.stepDone(StepKeyRules, nil)
|
|
|
|
// --- services ---------------------------------------------------------
|
|
s.stepStart(StepKeyServices)
|
|
StartForwarders(ctx, srv, cfg.Forward)
|
|
StartConnectors(ctx, srv, cfg.Connect)
|
|
RunLanDiscoverService(ctx, cfg.Connect, s.logger.With("from", "lan_service"))
|
|
s.stepDone(StepKeyServices, nil)
|
|
|
|
// --- monitors ---------------------------------------------------------
|
|
s.stepStart(StepKeyMonitors)
|
|
peers := NewPeerMonitor(srv, cfg.Connect, s.logger, PeerMonitorOptions{})
|
|
peers.Start(ctx)
|
|
|
|
lan := NewLanScanner(s.logger.With("from", "lan_scan"))
|
|
lan.SetSelfEntries(LanEntriesFromRules(cfg.Connect))
|
|
lan.Start(ctx)
|
|
|
|
s.update(func(st *State) {
|
|
st.Peers = peers
|
|
st.Lan = lan
|
|
})
|
|
s.stepDone(StepKeyMonitors, nil)
|
|
|
|
// --- ready ------------------------------------------------------------
|
|
s.stepStart(StepKeyReady)
|
|
s.stepDone(StepKeyReady, nil)
|
|
s.update(func(st *State) {
|
|
st.Phase = PhaseReady
|
|
st.ReadyAt = time.Now()
|
|
st.Err = ""
|
|
})
|
|
s.logger.Info("service ready", "took", time.Since(s.Snapshot().StartedAt).Round(time.Millisecond))
|
|
return nil
|
|
}
|
|
|
|
// supervise blocks until the service should be restarted, returning why.
|
|
func (s *Supervisor) supervise(ctx context.Context) string {
|
|
watchdog := StartTimeWatchDog(ctx, s.logger.With("from", "watchdog"))
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return "context cancelled"
|
|
case <-watchdog:
|
|
return "system time jump"
|
|
case <-s.restartCh:
|
|
return "requested by user"
|
|
}
|
|
}
|
|
}
|
|
|
|
// teardown closes the tsnet server and clears the per-run state.
|
|
func (s *Supervisor) teardown() {
|
|
s.mu.Lock()
|
|
srv := s.state.Server
|
|
s.state.Server = nil
|
|
s.state.Peers = nil
|
|
s.state.Lan = nil
|
|
s.mu.Unlock()
|
|
|
|
if srv != nil {
|
|
if err := srv.Close(); err != nil {
|
|
s.logger.Debug("closing tsnet server", "err", err)
|
|
}
|
|
}
|
|
s.notify()
|
|
}
|