Files
tunnel/internal/client/service.go
T
TZGyn ebae420ab1 Add the native Go client and finish M3 hardening
The new cmd/tunnel client keeps the API URL and tunnel identity in
~/.config/tunnel/config.json (0600), provisions the key/CSR and
certificate, terminates TLS, proxies to local routes, reloads routes
without a restart, and runs as a background service. `tunnel import`
takes over an existing official CLI profile, preserving its tunnel ID,
token, key and certificate.

Server hardening: per-IP and per-tunnel rate limits for creates and
binds, a CA cool-down after repeated ACME failures, a startup sweep for
stale _acme-challenge records, admin metrics, a 60-second half-close
timeout, and separate prioritized control / ordered data bridge queues
(with `end` ordered after the final bytes). The dashboard shows the new
metrics.
2026-10-26 00:32:31 +08:00

417 lines
11 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
}
// 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 {
if err := s.Ensure(ctx); err != nil {
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)
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")
return ctx.Err()
case <-reload:
s.Log.Printf("config changed; reconnecting")
sess.close("config changed")
case <-sess.done:
}
}
}
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
}