package client import ( "context" "crypto/tls" "errors" "fmt" "log" "os" "sync" "sync/atomic" "time" ) // Service is one provisioning + serving lifecycle for a config file. type Service struct { ConfigPath string Paths Paths Config *Config API *APIClient Log *log.Logger Version string cert atomic.Pointer[tls.Certificate] mu sync.RWMutex // guards Config and API during the renewal poller statusMu sync.RWMutex handler func(Status) status atomic.Value } // Status is a snapshot of the bridge connection for UIs. type Status struct { Connected bool Session string Routes []string LastError string LastConnectedAt time.Time } // SetStatusHandler registers a callback invoked whenever the status changes. func (s *Service) SetStatusHandler(handler func(Status)) { s.statusMu.Lock() s.handler = handler s.statusMu.Unlock() } // SetConfig replaces the loaded config and API client, used by the desktop UI. func (s *Service) SetConfig(cfg *Config) { s.mu.Lock() defer s.mu.Unlock() s.Config = cfg s.API = NewAPIClient(cfg.API) } // CurrentStatus returns the latest bridge status. func (s *Service) CurrentStatus() Status { if value := s.status.Load(); value != nil { return value.(Status) } return Status{} } func (s *Service) report(status Status) { s.status.Store(status) s.statusMu.RLock() handler := s.handler s.statusMu.RUnlock() if handler != nil { handler(status) } } // NewService loads a config (which may not exist yet). func NewService(configPath, version string, logger *log.Logger) (*Service, error) { cfg, err := Load(configPath) if err != nil { return nil, err } if logger == nil { logger = log.New(os.Stderr, "", log.LstdFlags) } s := &Service{ ConfigPath: configPath, Paths: ConfigPaths(configPath), Config: cfg, Log: logger, Version: version, } if cfg.API != "" { s.API = NewAPIClient(cfg.API) } return s, nil } // Ensure creates the tunnel if needed, makes the local key/CSR, and makes // sure a usable certificate is cached. func (s *Service) Ensure(ctx context.Context) error { cfg, err := Load(s.ConfigPath) if err != nil { return err } if cfg.API == "" { return errors.New("no API configured; run: tunnel config set-api https://your-server") } s.Paths = ConfigPaths(s.ConfigPath) s.mu.Lock() s.Config = cfg s.API = NewAPIClient(cfg.API) s.mu.Unlock() if cfg.TunnelID == "" || cfg.Token == "" { ctx, cancel := context.WithTimeout(ctx, 60*time.Second) tunnel, token, err := s.API.CreateTunnel(ctx) cancel() if err != nil { return fmt.Errorf("create tunnel: %w", err) } cfg.TunnelID, cfg.Hostname, cfg.Token = tunnel.ID, tunnel.Hostname, token // Persist the identity before binding, so an interrupted provision // can resume with the same token and hostname. if err := cfg.Save(s.ConfigPath); err != nil { return err } s.Log.Printf("created tunnel %s (%s)", cfg.TunnelID, cfg.Hostname) } info, err := s.API.GetTunnel(ctx, cfg.TunnelID, cfg.Token) if err != nil { var apiErr *APIError if errors.As(err, &apiErr) && apiErr.Status == 404 { return fmt.Errorf("tunnel %s no longer exists on the server", cfg.TunnelID) } if errors.As(err, &apiErr) && apiErr.Status == 401 { return fmt.Errorf("the server rejected the saved token for tunnel %s", cfg.TunnelID) } return fmt.Errorf("get tunnel: %w", err) } if info.Hostname != "" && info.Hostname != cfg.Hostname { cfg.Hostname = info.Hostname if err := cfg.Save(s.ConfigPath); err != nil { return err } } if cfg.Hostname == "" { return errors.New("server did not return a tunnel hostname") } keyPEM, csrPEM, err := EnsureIdentity(s.Paths, cfg.Hostname) if err != nil { return err } return s.ensureCertificate(ctx, keyPEM, csrPEM) } func (s *Service) ensureCertificate(ctx context.Context, keyPEM, csrPEM []byte) error { cfg := s.Config localCert, localExpiry, localErr := LoadCertificate(s.Paths, cfg.Hostname) info, err := s.API.GetCertificate(ctx, cfg.TunnelID, cfg.Token) if err != nil { var apiErr *APIError if errors.As(err, &apiErr) && apiErr.Status == 404 { return s.bindAndWait(ctx, keyPEM, csrPEM) } if localErr == nil { s.Log.Printf("certificate check failed (%v); using saved certificate expiring %s", err, localExpiry.Format(time.RFC3339)) s.cert.Store(&localCert) return nil } return fmt.Errorf("get certificate: %w", err) } switch info.State.Type { case "ready": return s.useReadyCertificate(keyPEM, localCert, localExpiry, localErr, info) case "failed": return s.bindAndWait(ctx, keyPEM, csrPEM) case "issuing": return s.waitForCertificate(ctx, keyPEM) default: return s.bindAndWait(ctx, keyPEM, csrPEM) } } func (s *Service) useReadyCertificate(keyPEM []byte, localCert tls.Certificate, localExpiry time.Time, localErr error, info CertificateInfo) error { serverExpiry, err := CertificateExpiry(info.State.Expiry) if err != nil { return fmt.Errorf("server certificate expiry: %w", err) } if localErr == nil && !localExpiry.Before(serverExpiry) && sameExpiry(s.Config.CertificateExpiry, serverExpiry) { s.cert.Store(&localCert) return nil } cert, expiry, err := SaveCertificate(s.Paths, []byte(info.State.Certificate), []byte(info.State.Chain), keyPEM, s.Config.Hostname) if err != nil { return fmt.Errorf("save certificate: %w", err) } s.cert.Store(&cert) s.setExpiry(expiry) s.Log.Printf("certificate ready (expires %s)", expiry.Format(time.RFC3339)) return nil } func (s *Service) bindAndWait(ctx context.Context, keyPEM, csrPEM []byte) error { s.Log.Printf("requesting certificate for %s", s.Config.Hostname) info, err := s.API.BindCertificate(ctx, s.Config.TunnelID, s.Config.Token, string(csrPEM)) if err != nil { var apiErr *APIError if !errors.As(err, &apiErr) || apiErr.Status != 409 { return fmt.Errorf("bind certificate: %w", err) } } if info.State.Type == "ready" { return s.storeReady(keyPEM, info) } if info.State.Type == "failed" { return fmt.Errorf("certificate issuance failed: %s", info.State.Reason) } return s.waitForCertificate(ctx, keyPEM) } func (s *Service) waitForCertificate(ctx context.Context, keyPEM []byte) error { ctx, cancel := context.WithTimeout(ctx, 10*time.Minute) defer cancel() ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() for { info, err := s.API.GetCertificate(ctx, s.Config.TunnelID, s.Config.Token) if err != nil { return fmt.Errorf("poll certificate: %w", err) } switch info.State.Type { case "ready": return s.storeReady(keyPEM, info) case "failed": return fmt.Errorf("certificate issuance failed: %s", info.State.Reason) } select { case <-ctx.Done(): return fmt.Errorf("certificate not ready: %w", ctx.Err()) case <-ticker.C: } } } func (s *Service) storeReady(keyPEM []byte, info CertificateInfo) error { cert, expiry, err := SaveCertificate(s.Paths, []byte(info.State.Certificate), []byte(info.State.Chain), keyPEM, s.Config.Hostname) if err != nil { return fmt.Errorf("save certificate: %w", err) } s.cert.Store(&cert) s.setExpiry(expiry) s.Log.Printf("certificate ready (expires %s)", expiry.Format(time.RFC3339)) return nil } func (s *Service) setExpiry(expiry time.Time) { s.mu.Lock() defer s.mu.Unlock() s.Config.CertificateExpiry = expiry.UTC().Format(time.RFC3339) if err := s.Config.Save(s.ConfigPath); err != nil { s.Log.Printf("save config: %v", err) } } func sameExpiry(stored string, expiry time.Time) bool { parsed, err := CertificateExpiry(stored) return err == nil && parsed.Equal(expiry) } // RefreshCertificate checks the server for a renewed certificate. A transient // failure leaves the current certificate serving. func (s *Service) RefreshCertificate(ctx context.Context) { s.mu.RLock() cfg := *s.Config api := s.API s.mu.RUnlock() info, err := api.GetCertificate(ctx, cfg.TunnelID, cfg.Token) if err != nil { s.Log.Printf("certificate refresh check: %v", err) return } if info.State.Type != "ready" { if info.State.Type == "failed" { s.Log.Printf("certificate renewal failed on the server: %s", info.State.Reason) } return } expiry, err := CertificateExpiry(info.State.Expiry) if err != nil { s.Log.Printf("certificate expiry: %v", err) return } if sameExpiry(cfg.CertificateExpiry, expiry) { return } keyPEM, err := os.ReadFile(s.Paths.Key) if err != nil { s.Log.Printf("read key: %v", err) return } cert, expiry, err := SaveCertificate(s.Paths, []byte(info.State.Certificate), []byte(info.State.Chain), keyPEM, cfg.Hostname) if err != nil { s.Log.Printf("save renewed certificate: %v", err) return } s.cert.Store(&cert) s.setExpiry(expiry) s.Log.Printf("certificate renewed (expires %s)", expiry.Format(time.RFC3339)) } func (s *Service) refreshLoop(ctx context.Context) { for { interval := 12 * time.Hour s.mu.RLock() expiryText := s.Config.CertificateExpiry s.mu.RUnlock() if expiry, err := CertificateExpiry(expiryText); err == nil { if time.Until(expiry) < 30*24*time.Hour { interval = time.Minute } } timer := time.NewTimer(interval) select { case <-ctx.Done(): timer.Stop() return case <-timer.C: s.RefreshCertificate(ctx) } } } // Run keeps the bridge connected, reloading routes when the config changes. func (s *Service) Run(ctx context.Context) error { s.report(Status{Connected: false}) if err := s.Ensure(ctx); err != nil { s.report(Status{Connected: false, LastError: err.Error()}) return err } go s.refreshLoop(ctx) reload := make(chan struct{}, 1) go watchConfig(ctx, s.ConfigPath, reload) delay := 250 * time.Millisecond for { cfg, err := s.reload(ctx) if err != nil { s.Log.Printf("config: %v", err) s.report(Status{Connected: false, LastError: err.Error()}) if !wait(ctx, delay) { return ctx.Err() } delay = nextBackoff(delay) continue } sess, err := s.connect(ctx, cfg) if err != nil { s.Log.Printf("bridge: %v", err) if errors.Is(err, errFatalAttach) { return err } if !wait(ctx, delay) { return ctx.Err() } delay = nextBackoff(delay) continue } delay = 250 * time.Millisecond select { case <-ctx.Done(): sess.close("shutdown") s.report(Status{Connected: false}) return ctx.Err() case <-reload: s.Log.Printf("config changed; reconnecting") sess.close("config changed") s.report(Status{Connected: false}) case <-sess.done: s.report(Status{Connected: false}) } } } func (s *Service) reload(ctx context.Context) (*Config, error) { cfg, err := Load(s.ConfigPath) if err != nil { return nil, err } if cfg.API == "" { return nil, errors.New("no API configured; run: tunnel config set-api https://your-server") } if cfg.TunnelID == "" || cfg.Token == "" || cfg.Hostname == "" || s.cert.Load() == nil { if err := s.Ensure(ctx); err != nil { return nil, err } s.mu.RLock() cfg = s.Config s.mu.RUnlock() } s.mu.Lock() s.Config = cfg s.API = NewAPIClient(cfg.API) s.mu.Unlock() return cfg, nil } // TLSConfig serves the current certificate and supports hot renewal. func (s *Service) TLSConfig() *tls.Config { return &tls.Config{ MinVersion: tls.VersionTLS12, NextProtos: []string{"http/1.1"}, GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { cert := s.cert.Load() if cert == nil { return nil, errors.New("no certificate loaded") } return cert, nil }, } } func watchConfig(ctx context.Context, path string, changed chan<- struct{}) { var last time.Time if info, err := os.Stat(path); err == nil { last = info.ModTime() } ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: info, err := os.Stat(path) if err != nil || !info.ModTime().After(last) { continue } last = info.ModTime() select { case changed <- struct{}{}: default: } } } } func wait(ctx context.Context, d time.Duration) bool { timer := time.NewTimer(d) defer timer.Stop() select { case <-ctx.Done(): return false case <-timer.C: return true } } func nextBackoff(d time.Duration) time.Duration { d *= 2 if d > 30*time.Second { return 30 * time.Second } return d }