Ctrl K
ComunidadGuías…

internal/servertls/servertls.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Package servertls serves the gateway's own listener over TLS from PEM files
// that are re-read when they change on disk, so a rotated certificate takes
// effect without a restart. It covers inbound connections only; outbound
// trust lives in internal/httpclient.
package servertls

import (
	"crypto/tls"
	"crypto/x509"
	"errors"
	"fmt"
	"log/slog"
	"os"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"github.com/enterpilot/gomodel/internal/pemfile"
)

// DefaultCheckInterval is how often, at most, the files are checked for a
// change. A rotation is picked up by the first handshake after it.
const DefaultCheckInterval = 5 * time.Second

// Settings names the PEM files the listener serves from. The zero value
// means plain HTTP.
type Settings struct {
	// CertFile and KeyFile hold the serving certificate (with any
	// intermediates after it) and its private key. Both or neither.
	CertFile string
	KeyFile  string
	// ClientCAFile, when set, verifies every client certificate presented
	// against the CAs in this bundle (mTLS). A handshake without one still
	// completes, so the health probes can get through; the HTTP layer rejects
	// every other request that lacks a verified certificate.
	ClientCAFile string
}

// Source hands each TLS handshake the configuration built from the files'
// current contents.
//
// The files are polled rather than watched. A handshake that finds the last
// check older than the interval stats the files, and only a changed stat
// (another inode, size, or modification time) re-reads them. Polling needs no
// goroutine to stop when a reload replaces the server, follows the symlink
// swap Kubernetes uses to update a mounted Secret (which file watchers
// commonly miss once the watched link target is deleted), and costs every
// other handshake one atomic load. A rotation therefore lands within the
// interval of the next handshake, which is far inside any certificate's
// renewal window.
//
// A failed reload, such as a pair caught half written, keeps the last good
// configuration and logs a warning; it never stops the listener.
type Source struct {
	settings Settings
	interval time.Duration

	current atomic.Pointer[tls.Config]
	// nextCheck is nanoseconds since base. time.Since reads the monotonic
	// clock, so a wall clock stepped backwards cannot stall the checks.
	base      time.Time
	nextCheck atomic.Int64

	mu          sync.Mutex // serializes reloads; guards the fields below
	stamp       []os.FileInfo
	lastFailure string
}

// New loads settings for the first time. Unlike a later reload, a missing or
// unusable file is an error, so a typo fails startup instead of serving
// nothing.
func New(settings Settings) (*Source, error) {
	settings.CertFile = strings.TrimSpace(settings.CertFile)
	settings.KeyFile = strings.TrimSpace(settings.KeyFile)
	settings.ClientCAFile = strings.TrimSpace(settings.ClientCAFile)
	if settings.CertFile == "" || settings.KeyFile == "" {
		return nil, errors.New("server TLS: both a certificate and a key file are required")
	}
	if err := errors.Join(
		pemfile.CheckPath("server.tls_cert_file", settings.CertFile),
		pemfile.CheckPath("server.tls_key_file", settings.KeyFile),
		pemfile.CheckPath("server.tls_client_ca_file", settings.ClientCAFile),
	); err != nil {
		return nil, err
	}
	s := &Source{settings: settings, interval: DefaultCheckInterval, base: time.Now()}
	stamp, err := statFiles(settings)
	if err != nil {
		return nil, err
	}
	cfg, err := load(settings)
	if err != nil {
		return nil, err
	}
	s.stamp = stamp
	s.current.Store(cfg)
	s.nextCheck.Store(int64(s.interval))
	return s, nil
}

// ServerConfig returns the configuration to wrap the listener with. It
// carries no certificate of its own: every handshake asks the Source.
func (s *Source) ServerConfig() *tls.Config {
	return &tls.Config{
		MinVersion:         tls.VersionTLS12,
		GetConfigForClient: s.configForClient,
	}
}

// MutualTLS reports whether requests must carry a verified client
// certificate.
func (s *Source) MutualTLS() bool { return s.settings.ClientCAFile != "" }

// Leaf returns the certificate currently served.
func (s *Source) Leaf() *x509.Certificate {
	return s.current.Load().Certificates[0].Leaf
}

func (s *Source) configForClient(*tls.ClientHelloInfo) (*tls.Config, error) {
	now := int64(time.Since(s.base))
	next := s.nextCheck.Load()
	// One handshake per interval claims the check; the others never wait.
	if now >= next && s.nextCheck.CompareAndSwap(next, now+int64(s.interval)) {
		s.refresh()
	}
	return s.current.Load(), nil
}

// refresh reloads the files when their stat changed since the last attempt.
func (s *Source) refresh() {
	if !s.mu.TryLock() {
		return
	}
	defer s.mu.Unlock()

	stamp, err := statFiles(s.settings)
	if err != nil {
		s.warn(err)
		return
	}
	if sameStamp(s.stamp, stamp) {
		return
	}
	// The stamp is recorded only once a load succeeds, so a failed one is
	// retried every interval: a fix that leaves the stat unchanged, such as
	// restored permissions, is still picked up. warn logs each failure once.
	cfg, err := load(s.settings)
	if err != nil {
		s.warn(err)
		return
	}
	s.stamp = stamp
	s.lastFailure = ""
	s.current.Store(cfg)
	leaf := cfg.Certificates[0].Leaf
	slog.Info("server TLS certificate reloaded",
		"subject", leaf.Subject.String(), "not_after", leaf.NotAfter)
}

// warn logs a failed reload once per distinct failure.
func (s *Source) warn(err error) {
	if msg := err.Error(); msg != s.lastFailure {
		s.lastFailure = msg
		slog.Warn("server TLS reload failed; still serving the previous certificate", "error", err)
	}
}

func (s Settings) files() []string {
	files := []string{s.CertFile, s.KeyFile}
	if s.ClientCAFile != "" {
		files = append(files, s.ClientCAFile)
	}
	return files
}

func statFiles(settings Settings) ([]os.FileInfo, error) {
	files := settings.files()
	stamp := make([]os.FileInfo, len(files))
	for i, name := range files {
		info, err := os.Stat(name) // follows symlinks to the file served
		if err != nil {
			return nil, fmt.Errorf("server TLS: %w", err)
		}
		// Reading a FIFO or device could block the handshake that holds the
		// reload, or startup, forever.
		if !info.Mode().IsRegular() {
			return nil, fmt.Errorf("server TLS: %s is not a regular file", name)
		}
		stamp[i] = info
	}
	return stamp, nil
}

func sameStamp(a, b []os.FileInfo) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if !os.SameFile(a[i], b[i]) || a[i].Size() != b[i].Size() || !a[i].ModTime().Equal(b[i].ModTime()) {
			return false
		}
	}
	return true
}

// load builds the per-handshake configuration from the files' contents.
func load(settings Settings) (*tls.Config, error) {
	cert, err := tls.LoadX509KeyPair(settings.CertFile, settings.KeyFile)
	if err != nil {
		return nil, fmt.Errorf("server TLS: load certificate: %w", err)
	}
	cfg := &tls.Config{
		MinVersion:   tls.VersionTLS12,
		Certificates: []tls.Certificate{cert},
		// Advertised explicitly: a configuration returned by
		// GetConfigForClient replaces the listener's for ALPN too.
		NextProtos: []string{"h2", "http/1.1"},
	}
	if settings.ClientCAFile != "" {
		caPEM, err := os.ReadFile(settings.ClientCAFile)
		if err != nil {
			return nil, fmt.Errorf("server TLS: read client CA: %w", err)
		}
		// Only the configured CAs: the system store must not vouch for clients.
		pool := x509.NewCertPool()
		if err := pemfile.AppendCertificates(pool, caPEM); err != nil {
			return nil, fmt.Errorf("server TLS: client CA file %s: %w", settings.ClientCAFile, err)
		}
		cfg.ClientCAs = pool
		cfg.ClientAuth = tls.VerifyClientCertIfGiven
	}
	return cfg, nil
}