Files
TZGyn ddcea99f37 Add a cross-platform desktop app (Fyne)
The tunnel client now has a desktop UI over the same internal/client
service: status with live connection state, routes with add/remove/copy,
live logs, settings, profile import, and a start/stop button that runs
the bridge in-process. It uses the existing ~/.config/tunnel/config.json
and shows a warning banner with a stop button when the CLI service is
already running and claiming the routes.
2026-10-29 21:32:21 +08:00

468 lines
12 KiB
Go

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
}