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.
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user