~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
252 lines · 5.4 KB Raw History
package tlsreload

import (
	"context"
	"crypto/tls"
	"crypto/x509"
	"encoding/pem"
	"errors"
	"fmt"
	"log/slog"
	"net"
	"os"
	"strings"
	"sync"
	"time"

	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/lease"
)

const LoadWait = 10 * time.Second

type Pool struct {
	log *slog.Logger

	mu     sync.Mutex
	source *lease.Source[*x509.CertPool]
	path   string
	stamp  stamp
	pool   *x509.CertPool
	loaded chan struct{}
}

func NewPool(fetch lease.Fetch[*x509.CertPool], log *slog.Logger) *Pool {
	p := &Pool{log: logOrDiscard(log)}
	if fetch != nil {
		p.source = lease.New(fetch)
	}
	return p
}

func PEMPool(data []byte, log *slog.Logger) (*Pool, error) {
	pool, err := parsePool(data)
	if err != nil {
		return nil, err
	}
	return &Pool{log: logOrDiscard(log), pool: pool}, nil
}

func FilePool(path string, log *slog.Logger) (*Pool, error) {
	path = strings.TrimSpace(path)
	if path == "" {
		return nil, errors.New("tlsreload: no CA bundle file")
	}
	st := statFile(path)
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("tlsreload: reading the CA bundle %s: %w", path, err)
	}
	pool, err := parsePool(data)
	if err != nil {
		return nil, fmt.Errorf("tlsreload: the CA bundle %s: %w", path, err)
	}
	return &Pool{log: logOrDiscard(log), path: path, stamp: st, pool: pool}, nil
}

func PoolFromBundle(value string, log *slog.Logger) (*Pool, error) {
	value = strings.TrimSpace(value)
	if value == "" {
		return nil, errors.New("tlsreload: the CA bundle is empty")
	}
	if strings.Contains(value, "-----BEGIN") {
		return PEMPool([]byte(value), log)
	}
	return FilePool(strings.TrimPrefix(value, "file:"), log)
}

func (p *Pool) Set(data []byte) error {
	pool, err := parsePool(data)
	if err != nil {
		return err
	}
	p.mu.Lock()
	p.pool = pool
	p.source = nil
	p.path = ""
	p.stamp = stamp{}
	loaded := p.loadedLocked()
	select {
	case <-loaded:
	default:
		close(loaded)
	}
	p.mu.Unlock()
	return nil
}

func (p *Pool) loadedLocked() chan struct{} {
	if p.loaded == nil {
		p.loaded = make(chan struct{})
	}
	return p.loaded
}

func (p *Pool) Current(ctx context.Context) (*x509.CertPool, error) {
	p.mu.Lock()
	source := p.source
	if source == nil && p.path != "" {
		p.refreshLocked()
	}
	pool := p.pool
	var loaded chan struct{}
	if source == nil && pool == nil {
		loaded = p.loadedLocked()
	}
	p.mu.Unlock()
	if source != nil {
		fetched, err := source.Get(ctx)
		if err != nil {
			return nil, fmt.Errorf("tlsreload: fetching the CA pool: %w", err)
		}
		pool = fetched
	}
	if loaded != nil {
		pool = p.awaitLoaded(ctx, loaded)
	}
	if pool == nil {
		return nil, errors.New("tlsreload: no CA pool is loaded")
	}
	return pool, nil
}

func (p *Pool) awaitLoaded(ctx context.Context, loaded <-chan struct{}) *x509.CertPool {
	timer := time.NewTimer(LoadWait)
	defer timer.Stop()
	select {
	case <-loaded:
	case <-ctx.Done():
	case <-timer.C:
	}
	p.mu.Lock()
	defer p.mu.Unlock()
	return p.pool
}

func (p *Pool) ServerConfig(base *tls.Config, auth tls.ClientAuthType) *tls.Config {
	if base == nil {
		base = &tls.Config{}
	}
	cfg := base.Clone()
	cfg.ClientAuth = auth
	inner := base.GetConfigForClient
	plain := base.Clone()
	plain.GetConfigForClient = nil
	cfg.GetConfigForClient = func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
		next := plain
		if inner != nil {
			got, err := inner(hello)
			if err != nil {
				return nil, err
			}
			if got != nil {
				next = got
			}
		}
		pool, err := p.Current(hello.Context())
		if err != nil {
			return nil, err
		}
		out := next.Clone()
		out.GetConfigForClient = nil
		out.ClientCAs = pool
		out.ClientAuth = auth
		return out, nil
	}
	return cfg
}

func (p *Pool) DialTLSContext(base *tls.Config) func(ctx context.Context, network, addr string) (net.Conn, error) {
	if base == nil {
		base = &tls.Config{}
	}
	base = base.Clone()
	return func(ctx context.Context, network, addr string) (net.Conn, error) {
		roots, err := p.Current(ctx)
		if err != nil {
			return nil, err
		}
		cfg := base.Clone()
		cfg.RootCAs = roots
		if cfg.ServerName == "" {
			host, _, err := net.SplitHostPort(addr)
			if err != nil {
				host = addr
			}
			cfg.ServerName = host
		}
		if len(cfg.NextProtos) == 0 {
			cfg.NextProtos = []string{"http/1.1"}
		}
		dialer := &tls.Dialer{Config: cfg}
		return dialer.DialContext(ctx, network, addr)
	}
}

func (p *Pool) refreshLocked() {
	st := statFile(p.path)
	if sameStamps([]stamp{st}, []stamp{p.stamp}) {
		return
	}
	data, err := os.ReadFile(p.path)
	var pool *x509.CertPool
	if err == nil {
		pool, err = parsePool(data)
	}
	if err != nil {
		p.log.Warn("keeping the last CA pool", "path", p.path, "err", err)
	} else {
		p.pool = pool
		p.log.Info("reloaded the CA pool", "path", p.path)
	}
	p.stamp = st
}

func statFile(path string) stamp {
	st, err := os.Stat(path)
	if err != nil {
		return stamp{}
	}
	return stamp{mod: st.ModTime(), size: st.Size(), ok: true}
}

func parsePool(data []byte) (*x509.CertPool, error) {
	pool := x509.NewCertPool()
	if pool.AppendCertsFromPEM(data) {
		return pool, nil
	}
	block, _ := pem.Decode(data)
	if block == nil || block.Type != "CERTIFICATE" {
		return nil, errors.New("tlsreload: the CA bundle holds no certificate")
	}
	if _, err := x509.ParseCertificate(block.Bytes); err != nil {
		return nil, fmt.Errorf("tlsreload: parsing the CA certificate: %w", err)
	}
	return nil, errors.New("tlsreload: the CA bundle holds no certificate")
}

func logOrDiscard(log *slog.Logger) *slog.Logger {
	if log == nil {
		return slog.New(slog.DiscardHandler)
	}
	return log
}