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.
417 lines
11 KiB
Go
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
|
|
}
|