mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-10 15:39:07 +02:00
345 lines
12 KiB
Go
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)
|
|
}
|