add: gui and tsdiag
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user