Files
netbird/management/server/certificate_challenge.go
T

345 lines
12 KiB
Go

package server
import (
"context"
"encoding/binary"
"hash/fnv"
"maps"
"slices"
"sync"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/certposture"
)
const (
minCertChallengeTick = time.Second
maxCertChallengeTick = 15 * time.Minute
maxCertChallengeRefresh = 30 * time.Second
)
// certChallengePeriod is how often an account whose policies carry a certificate
// posture check is pushed a fresh challenge. A nonce stays valid through the window
// after its own, so one issued at the very end of a window lives only one window. A
// third of that leaves a missed run well clear of the edge, where a half would put it
// exactly on it.
func certChallengePeriod() time.Duration {
return certposture.EffectiveWindow() / 3
}
// certChallengeTick is how often the refresher looks for accounts that are due. It is
// derived from the period rather than fixed, so shortening the challenge window for a
// test shortens this with it; the bounds keep a tiny window from spinning and a normal
// one from checking less often than is useful.
func certChallengeTick(period time.Duration) time.Duration {
return min(max(period/10, minCertChallengeTick), maxCertChallengeTick)
}
// certChallengeRefresh is how long one account's refresh is given before it is
// abandoned. Resolving the target peers reads the store and accounts are swept one
// after another, so an unbounded refresh lets one wedged read starve all the others.
func certChallengeRefresh(tick time.Duration) time.Duration {
return min(tick, maxCertChallengeRefresh)
}
// certChallengeRefresher pushes a fresh certificate challenge to the peers connected to
// this instance that answer one, from a single goroutine.
//
// A nonce only reaches a peer attached to a network map, and a quiet account sends no
// map, so without this the peer eventually re-sends an expired nonce, has its whole
// proof set rejected, and silently leaves the policies the check gates.
//
// Each instance renews only the peers whose sync stream it holds, so a peer is renewed
// once however many instances run, and an account is dropped as soon as none of its
// peers here answers a challenge.
type certChallengeRefresher struct {
mu sync.Mutex
accounts map[string]*certChallengeAccount
// refreshing holds the accounts whose refresh is running, and whether Track was
// called for one meanwhile, which makes its refresh's verdict stale.
refreshing map[string]bool
period time.Duration
tick time.Duration
timeout time.Duration
now func() time.Time
// refresh pushes an update to those of peerIDs that answer a certificate challenge,
// and reports whether any of them does.
refresh func(ctx context.Context, accountID string, peerIDs []string) bool
}
// certChallengeAccount is one account's renewal schedule and the peers it covers, each
// with the start of the sync stream it was stamped on.
type certChallengeAccount struct {
due time.Time
peers map[string]time.Time
}
// certChallengeDue is an account handed out for refresh, with its peers at that moment.
type certChallengeDue struct {
accountID string
peerIDs []string
}
func newCertChallengeRefresher(refresh func(ctx context.Context, accountID string, peerIDs []string) bool) *certChallengeRefresher {
period := certChallengePeriod()
tick := certChallengeTick(period)
return &certChallengeRefresher{
accounts: map[string]*certChallengeAccount{},
refreshing: map[string]bool{},
period: period,
tick: tick,
timeout: certChallengeRefresh(tick),
now: time.Now,
refresh: refresh,
}
}
// Start runs the refresh loop until ctx is done.
func (r *certChallengeRefresher) Start(ctx context.Context) {
go r.run(ctx)
}
// Track renews the challenge of peerID, which was stamped one on the sync stream that
// started at streamStart. An account seen for the first time has its first run spread
// over one period, so that a global window rollover does not fan out to every account in
// the same moment; an account already tracked keeps its schedule. A stamp from an older
// stream than the one recorded for the peer is ignored.
func (r *certChallengeRefresher) Track(ctx context.Context, accountID, peerID string, streamStart time.Time) {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.refreshing[accountID]; ok {
r.refreshing[accountID] = true
}
account, ok := r.accounts[accountID]
if !ok {
account = &certChallengeAccount{
due: r.now().Add(offsetWithin(accountID, r.period)),
peers: map[string]time.Time{},
}
r.accounts[accountID] = account
log.WithContext(ctx).Debugf("tracking certificate challenge refresh for account %s", accountID)
}
if started, ok := account.peers[peerID]; ok && streamStart.Before(started) {
return
}
account.peers[peerID] = streamStart
}
// Untrack stops renewing peerID once the sync stream that started at streamStart ends.
// A newer stream of the same peer keeps it tracked. The account is dropped with its
// last peer.
func (r *certChallengeRefresher) Untrack(accountID, peerID string, streamStart time.Time) {
r.mu.Lock()
defer r.mu.Unlock()
account, ok := r.accounts[accountID]
if !ok {
return
}
if started, ok := account.peers[peerID]; !ok || !started.Equal(streamStart) {
return
}
delete(account.peers, peerID)
if len(account.peers) == 0 {
delete(r.accounts, accountID)
}
}
// Forget stops refreshing accountID.
func (r *certChallengeRefresher) Forget(accountID string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.accounts, accountID)
}
func (r *certChallengeRefresher) tracked(accountID string) bool {
r.mu.Lock()
defer r.mu.Unlock()
_, ok := r.accounts[accountID]
return ok
}
func (r *certChallengeRefresher) run(ctx context.Context) {
ticker := time.NewTicker(r.tick)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
for _, due := range r.takeDue() {
r.finish(due.accountID, r.refreshOne(ctx, due))
}
}
}
}
// refreshOne refreshes a single account under the refresh deadline. A refresh that runs
// out of time reports the account as still wanting challenges, since a deadline says
// nothing about the account's posture checks; the next sweep tries again.
func (r *certChallengeRefresher) refreshOne(ctx context.Context, due certChallengeDue) bool {
ctx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
return r.refresh(ctx, due.accountID, due.peerIDs)
}
// finish records the outcome of an account's refresh. An account none of whose peers
// answers a challenge any more is dropped, unless one was tracked again while the
// refresh ran: the refresh may have read the store before a certificate check was added.
// A dropped peer is tracked again the next time it is stamped a challenge.
func (r *certChallengeRefresher) finish(accountID string, wanted bool) {
r.mu.Lock()
defer r.mu.Unlock()
retracked := r.refreshing[accountID]
delete(r.refreshing, accountID)
if !wanted && !retracked {
delete(r.accounts, accountID)
}
}
// takeDue returns the accounts due now and books their next run straight away, so a
// slow refresh cannot make an account fall due twice, and so the refresh itself runs
// without the lock.
func (r *certChallengeRefresher) takeDue() []certChallengeDue {
r.mu.Lock()
defer r.mu.Unlock()
now := r.now()
var due []certChallengeDue
for accountID, account := range r.accounts {
if account.due.After(now) {
continue
}
due = append(due, certChallengeDue{accountID: accountID, peerIDs: slices.Collect(maps.Keys(account.peers))})
account.due = now.Add(r.period)
r.refreshing[accountID] = false
}
return due
}
// offsetWithin maps a key to a stable duration in [0, period).
func offsetWithin(key string, period time.Duration) time.Duration {
h := fnv.New64a()
//nolint:errcheck // hash.Write never returns an error
h.Write([]byte(key))
return time.Duration(binary.BigEndian.Uint64(h.Sum(nil)) % uint64(period))
}
// refreshCertificateChallenges pushes an update to those of peerIDs that answer a
// certificate challenge, so each is stamped with a nonce for the current window. It
// reports whether any of them answers one.
func (am *DefaultAccountManager) refreshCertificateChallenges(ctx context.Context, accountID string, peerIDs []string) bool {
targets, err := am.certificateChallengeTargets(ctx, accountID)
if err != nil {
log.WithContext(ctx).Debugf("cannot resolve the certificate challenge targets of account %s: %v", accountID, err)
// Keep the account tracked: a store error now says nothing about its checks.
return true
}
peerIDs = slices.DeleteFunc(peerIDs, func(id string) bool { return !slices.Contains(targets, id) })
if len(peerIDs) == 0 {
log.WithContext(ctx).Debugf("no peer of account %s on this instance answers a certificate challenge, stopping challenge refresh", accountID)
return false
}
log.WithContext(ctx).Debugf("refreshing certificate challenges for %d peers of account %s", len(peerIDs), accountID)
reason := types.UpdateReason{Resource: types.UpdateResourcePostureCheck, Operation: types.UpdateOperationRefresh}
if err := am.networkMapController.BufferUpdateAffectedPeers(ctx, accountID, peerIDs, reason); err != nil {
log.WithContext(ctx).Warnf("failed refreshing certificate challenges for account %s: %v", accountID, err)
}
return true
}
// certificateChallengeTargets returns the peers that are sent a certificate challenge.
// Only those peers hold a nonce, so pushing to the whole account would wake every peer
// that never uses the feature.
//
// It is the inverse of processPeerPostureChecks, which decides the same thing one peer
// at a time; TestCertificateChallengeTargets_MatchesThePerPeerRule holds them together.
func (am *DefaultAccountManager) certificateChallengeTargets(ctx context.Context, accountID string) ([]string, error) {
certCheckIDs, err := am.certificatePostureCheckIDs(ctx, accountID)
if err != nil {
return nil, err
}
if len(certCheckIDs) == 0 {
return nil, nil
}
policies, err := am.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, err
}
groups, err := am.Store.GetAccountGroups(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, err
}
groupPeers := make(map[string][]string, len(groups))
for _, g := range groups {
groupPeers[g.ID] = g.Peers
}
return certificateChallengeTargets(policies, groupPeers, certCheckIDs), nil
}
// certificateChallengeTargets collects the source peers of every enabled policy whose
// posture checks include a certificate check.
func certificateChallengeTargets(policies []*types.Policy, groupPeers map[string][]string, certCheckIDs map[string]struct{}) []string {
targets := map[string]struct{}{}
for _, policy := range policies {
if !policy.Enabled || !slices.ContainsFunc(policy.SourcePostureChecks, func(id string) bool {
_, ok := certCheckIDs[id]
return ok
}) {
continue
}
for _, rule := range policy.Rules {
if !rule.Enabled {
continue
}
if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID != "" {
targets[rule.SourceResource.ID] = struct{}{}
}
for _, groupID := range rule.Sources {
for _, peerID := range groupPeers[groupID] {
targets[peerID] = struct{}{}
}
}
}
}
return slices.Collect(maps.Keys(targets))
}
// certificatePostureCheckIDs returns the IDs of the account's posture checks that ask a
// peer to prove a certificate.
func (am *DefaultAccountManager) certificatePostureCheckIDs(ctx context.Context, accountID string) (map[string]struct{}, error) {
checks, err := am.Store.GetAccountPostureChecks(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, err
}
ids := map[string]struct{}{}
for _, check := range checks {
if check.Checks.CertificateCheck != nil {
ids[check.ID] = struct{}{}
}
}
return ids, nil
}
// TrackCertificateChallenges starts renewing the certificate challenge of a peer that was
// stamped one on the sync stream started at streamStart. It is called on every stamped
// update, so it has to stay cheap: no store access, just a map the refresher sweeps.
func (am *DefaultAccountManager) TrackCertificateChallenges(ctx context.Context, accountID, peerID string, streamStart time.Time) {
am.certChallenges.Track(ctx, accountID, peerID, streamStart)
}
// UntrackCertificateChallenges stops renewing the certificate challenge of a peer whose
// sync stream started at streamStart has ended.
func (am *DefaultAccountManager) UntrackCertificateChallenges(accountID, peerID string, streamStart time.Time) {
am.certChallenges.Untrack(accountID, peerID, streamStart)
}