internal/tlsrevocation/refresher.go
Rama main · réplica de lectura del repositorio original.
// Package tlsrevocation connects safetransport's in-memory CRL cache to a
// Sparrow cluster: POST /v1/tls/crls:refresh sends a Postgres NOTIFY, and
// every instance listening on the channel refreshes its own cache. No table,
// no scheduled job: CRLs are public and re-downloadable, so each instance
// keeps its own copy.
package tlsrevocation
import (
"context"
"errors"
"log/slog"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/sarathsp06/sparrow/pkg/safetransport"
)
// Channel is the NOTIFY channel that asks every instance to refresh its CRLs.
const Channel = "sparrow_crl_refresh"
// RefreshInterval is the minimum time between refresh requests on one
// server, so the endpoint cannot be used to hammer CAs' CRL servers.
const RefreshInterval = time.Minute
// ErrTooSoon is returned by RequestRefresh within RefreshInterval of the
// previous request on this server.
var ErrTooSoon = errors.New("a CRL refresh was requested less than a minute ago")
// ErrDisabled is returned by RequestRefresh when SPARROW_TLS_REVOCATION=off.
var ErrDisabled = errors.New("revocation checking is off (SPARROW_TLS_REVOCATION=off)")
// Refresher owns this instance's revocation checker and its refresh channel.
// A nil *Refresher means revocation checking is off.
type Refresher struct {
checker *safetransport.RevocationChecker
pool *pgxpool.Pool
mode string
mu sync.Mutex
last time.Time
// Test hooks: called once LISTEN is in place and after each refresh.
onListen, onRefresh func()
}
// New returns a Refresher for checker, which runs in mode (soft or hard).
func New(pool *pgxpool.Pool, checker *safetransport.RevocationChecker, mode string) *Refresher {
return &Refresher{checker: checker, pool: pool, mode: mode}
}
// Mode reports soft, hard or off.
func (r *Refresher) Mode() string {
if r == nil {
return "off"
}
return r.mode
}
// Status lists this instance's cached CRLs.
func (r *Refresher) Status() []safetransport.CRLStatus {
if r == nil {
return nil
}
return r.checker.Status()
}
// RequestRefresh asks every instance, this one included, to download its
// known CRLs now. It returns once the request is sent, not when the
// downloads finish.
func (r *Refresher) RequestRefresh(ctx context.Context) error {
if r == nil {
return ErrDisabled
}
r.mu.Lock()
if !r.last.IsZero() && time.Since(r.last) < RefreshInterval {
r.mu.Unlock()
return ErrTooSoon
}
r.last = time.Now()
r.mu.Unlock()
_, err := r.pool.Exec(ctx, "SELECT pg_notify($1, '')", Channel)
return err
}
// Listen refreshes the checker on every notification until ctx is done,
// reconnecting after connection errors. A notification missed while
// reconnecting is covered by the next handshake older than the max age.
func (r *Refresher) Listen(ctx context.Context) {
for {
err := r.listen(ctx)
if ctx.Err() != nil {
return
}
slog.Warn("CRL refresh listener stopped, reconnecting", "error", err)
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
}
}
}
func (r *Refresher) listen(ctx context.Context) error {
conn, err := r.pool.Acquire(ctx)
if err != nil {
return err
}
// The connection stays in LISTEN mode, so it never goes back to the pool.
pgConn := conn.Hijack()
defer func() { _ = pgConn.Close(context.Background()) }()
if _, err := pgConn.Exec(ctx, "LISTEN "+Channel); err != nil {
return err
}
if r.onListen != nil {
r.onListen()
}
for {
if _, err := pgConn.WaitForNotification(ctx); err != nil {
return err
}
refreshCtx, cancel := context.WithTimeout(ctx, time.Minute)
if err := r.checker.Refresh(refreshCtx); err != nil {
slog.Warn("CRL refresh finished with errors", "error", err)
} else {
slog.Info("CRLs refreshed", "count", len(r.checker.Status()))
}
cancel()
if r.onRefresh != nil {
r.onRefresh()
}
}
}