mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-10 07:29:06 +02:00
387 lines
14 KiB
Go
387 lines
14 KiB
Go
package internal
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/certproof"
|
|
cProto "github.com/netbirdio/netbird/client/proto"
|
|
"github.com/netbirdio/netbird/client/system"
|
|
"github.com/netbirdio/netbird/shared/management/certposture"
|
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
|
)
|
|
|
|
const (
|
|
// certContextPollInterval is how often the engine checks whether the user who can
|
|
// answer certificate challenges has changed, such as a login after an autostart.
|
|
certContextPollInterval = time.Minute
|
|
// certRetryInterval is how long after a collection that proved nothing it is tried
|
|
// again with the same user, for stores that come up late: a keychain unlocked after
|
|
// login, or a TPM resource manager started after the daemon.
|
|
certRetryInterval = 5 * time.Minute
|
|
)
|
|
|
|
// undeliveredContext never matches a real user context, so a collection whose meta sync
|
|
// failed is seen as stale on the next poll.
|
|
const undeliveredContext = "\x00undelivered"
|
|
|
|
// errSystemInfoTimeout reports that gathering the system info for a meta sync timed out,
|
|
// so the sync was skipped rather than holding syncMsgMux on a stuck system call.
|
|
var errSystemInfoTimeout = errors.New("system info gathering timed out")
|
|
|
|
// certPostureState remembers what the last certificate proof collection saw, so the
|
|
// engine can collect again when it went stale and tell the user when proofs go missing.
|
|
//
|
|
// Collection runs only on the posture watcher, never on the sync path, because a token,
|
|
// a TPM or a user's keychain prompt can take long; the sync path attaches the proofs the
|
|
// last collection cached. delivered identifies the chains management last received, so
|
|
// a collection that proves the same chains is not sent again: every meta sync makes
|
|
// management recompute and push network maps to the peer and its neighbours.
|
|
type certPostureState struct {
|
|
mu sync.Mutex
|
|
attempted bool
|
|
attemptedAt time.Time
|
|
userContext string
|
|
proven bool
|
|
delivered string
|
|
hasDelivery bool
|
|
cached []certposture.Proof
|
|
cachedFor string
|
|
// stuck is whether the collector was last seen with every slot held by a lost
|
|
// collection.
|
|
stuck bool
|
|
}
|
|
|
|
// record stores the outcome of a collection for the challenges identified by
|
|
// challengesKey and reports whether it changed from proven to unproven or back. The first
|
|
// collection only counts as a change when it proved nothing.
|
|
func (s *certPostureState) record(challengesKey, userContext string, proofs []certposture.Proof, now time.Time) (changed bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
proven := len(proofs) > 0
|
|
changed = (s.attempted && s.proven != proven) || (!s.attempted && !proven)
|
|
s.attempted = true
|
|
s.attemptedAt = now
|
|
s.userContext = userContext
|
|
s.proven = proven
|
|
s.cached = proofs
|
|
s.cachedFor = challengesKey
|
|
return changed
|
|
}
|
|
|
|
// undelivered marks the last collection as not having reached management, so the next
|
|
// poll collects and sends again instead of treating it as current.
|
|
func (s *certPostureState) undelivered() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.attempted {
|
|
s.userContext = undeliveredContext
|
|
}
|
|
s.hasDelivery = false
|
|
}
|
|
|
|
// markDelivered records the chains of proofs as the ones management holds.
|
|
func (s *certPostureState) markDelivered(proofs []certposture.Proof) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.delivered = provenChainsKey(proofs)
|
|
s.hasDelivery = true
|
|
}
|
|
|
|
// sameAsDelivered reports whether proofs prove exactly the chains management already
|
|
// holds. Nonces and signatures are left out: they differ on every signing while the
|
|
// result management stores, the verified chains, stays the same.
|
|
func (s *certPostureState) sameAsDelivered(proofs []certposture.Proof) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.hasDelivery && s.delivered == provenChainsKey(proofs)
|
|
}
|
|
|
|
// needsCollection reports whether the cached proofs no longer reflect what the device
|
|
// can prove for the challenges identified by challengesKey: they answer other
|
|
// challenges, the user who can answer has changed, or nothing was proven and the retry
|
|
// interval has passed.
|
|
func (s *certPostureState) needsCollection(challengesKey, userContext string, now time.Time) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
switch {
|
|
case !s.attempted || s.cachedFor != challengesKey:
|
|
return true
|
|
case userContext != s.userContext:
|
|
return true
|
|
default:
|
|
return !s.proven && now.Sub(s.attemptedAt) >= certRetryInterval
|
|
}
|
|
}
|
|
|
|
// setStuck records whether the collector is stuck and reports whether it just became so.
|
|
func (s *certPostureState) setStuck(stuck bool) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
became := stuck && !s.stuck
|
|
s.stuck = stuck
|
|
return became
|
|
}
|
|
|
|
// cachedProofs returns the cached proofs management still accepts for the challenges in
|
|
// checks: those whose nonce is current, or from an adjacent window, for one of them. Proofs
|
|
// signed for the previous nonce bridge the time until the watcher has signed the new one.
|
|
func (s *certPostureState) cachedProofs(checks []*mgmProto.Checks) []certposture.Proof {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
var nonces [][]byte
|
|
for _, check := range checks {
|
|
if challenge := check.GetCertificateChallenge(); challenge != nil {
|
|
nonces = append(nonces, challenge.GetNonce())
|
|
}
|
|
}
|
|
var accepted []certposture.Proof
|
|
for _, proof := range s.cached {
|
|
if slices.ContainsFunc(nonces, func(nonce []byte) bool {
|
|
return certposture.NonceAcceptedAlongside(proof.Nonce, nonce)
|
|
}) {
|
|
accepted = append(accepted, proof)
|
|
}
|
|
}
|
|
return accepted
|
|
}
|
|
|
|
// provenChainsKey identifies the set of chains in proofs, independent of their order.
|
|
func provenChainsKey(proofs []certposture.Proof) string {
|
|
keys := make([]string, 0, len(proofs))
|
|
for _, proof := range proofs {
|
|
h := sha256.New()
|
|
for _, cert := range proof.Chain {
|
|
writeLengthPrefixed(h, cert)
|
|
}
|
|
keys = append(keys, hex.EncodeToString(h.Sum(nil)))
|
|
}
|
|
slices.Sort(keys)
|
|
return strings.Join(keys, ",")
|
|
}
|
|
|
|
// challengesKey identifies the certificate challenges in checks: their nonces and CAs.
|
|
func challengesKey(checks []*mgmProto.Checks) string {
|
|
h := sha256.New()
|
|
for _, check := range checks {
|
|
challenge := check.GetCertificateChallenge()
|
|
if challenge == nil {
|
|
continue
|
|
}
|
|
writeLengthPrefixed(h, challenge.GetNonce())
|
|
for _, ca := range challenge.GetCaCertificates() {
|
|
writeLengthPrefixed(h, []byte(ca))
|
|
}
|
|
writeLengthPrefixed(h, nil)
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
func writeLengthPrefixed(h interface{ Write([]byte) (int, error) }, data []byte) {
|
|
var size [8]byte
|
|
binary.BigEndian.PutUint64(size[:], uint64(len(data)))
|
|
_, _ = h.Write(size[:])
|
|
_, _ = h.Write(data)
|
|
}
|
|
|
|
// attachCertificateProofs attaches the cached proofs management accepts for the
|
|
// challenges in checks, without collecting: the caller is on the sync path. The info is
|
|
// about to be sent on the sync stream, which resends it on every reconnect, so its proofs
|
|
// count as delivered. The watcher is woken to collect when the cache is out of date.
|
|
func (e *Engine) attachCertificateProofs(info *system.Info, checks []*mgmProto.Checks) {
|
|
if !certproof.HasChallenges(checks) {
|
|
info.CertificateProofs = nil
|
|
return
|
|
}
|
|
info.CertificateProofs = e.certState.cachedProofs(checks)
|
|
e.certState.markDelivered(info.CertificateProofs)
|
|
e.wakeCertificatePosture()
|
|
}
|
|
|
|
// wakeCertificatePosture asks the posture watcher to check its proofs now rather than at
|
|
// its next tick. It never blocks.
|
|
func (e *Engine) wakeCertificatePosture() {
|
|
if e.certWake == nil {
|
|
return
|
|
}
|
|
select {
|
|
case e.certWake <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// publishCertificatePostureEvent tells the user when the device stops proving any
|
|
// certificate, which management treats as failing every certificate posture check, and
|
|
// when it proves one again. Without it the loss of access would have no visible cause.
|
|
func (e *Engine) publishCertificatePostureEvent(proven bool) {
|
|
if e.statusRecorder == nil {
|
|
return
|
|
}
|
|
if proven {
|
|
e.statusRecorder.PublishEvent(cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM,
|
|
"certificate posture: a certificate is proven again",
|
|
"A certificate required by your organization's device policy is available again.", nil)
|
|
return
|
|
}
|
|
e.statusRecorder.PublishEvent(cProto.SystemEvent_WARNING, cProto.SystemEvent_SYSTEM,
|
|
"certificate posture: no certificate could be proven",
|
|
"NetBird could not use a certificate required by your organization's device policy. "+
|
|
"Access to some resources may be blocked until one is available.", nil)
|
|
}
|
|
|
|
// publishCertificateStoreStuckEvent tells the user that reading the certificate store
|
|
// stopped answering. Collection resumes only once a stuck call returns, which a store
|
|
// that hangs for good never does short of a restart.
|
|
func (e *Engine) publishCertificateStoreStuckEvent() {
|
|
if e.statusRecorder == nil {
|
|
return
|
|
}
|
|
e.statusRecorder.PublishEvent(cProto.SystemEvent_WARNING, cProto.SystemEvent_SYSTEM,
|
|
"certificate posture: the certificate store stopped responding",
|
|
"NetBird cannot read the certificates required by your organization's device policy because "+
|
|
"the certificate store stopped responding. If this persists, restart the NetBird service.", nil)
|
|
}
|
|
|
|
// watchCertificatePosture owns certificate proof collection until ctx is done. It
|
|
// collects when woken by new checks or a sync, and on every tick when the cached proofs
|
|
// went stale, such as after a login following an autostart.
|
|
func (e *Engine) watchCertificatePosture(ctx context.Context) {
|
|
ticker := time.NewTicker(certContextPollInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
// A pending update that times out again stays pending; the applied checks'
|
|
// proofs are still refreshed so a lost certificate is reported meanwhile.
|
|
if err := e.retryPendingChecks(); err != nil && !errors.Is(err, errSystemInfoTimeout) {
|
|
log.Warnf("failed to sync posture checks that timed out before: %v", err)
|
|
}
|
|
if err := e.refreshCertificateProofs(); err != nil && !errors.Is(err, errSystemInfoTimeout) {
|
|
log.Warnf("failed to refresh certificate posture proofs: %v", err)
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
case <-e.certWake:
|
|
}
|
|
}
|
|
}
|
|
|
|
// retryPendingChecks sends the meta sync for checks whose earlier sync timed out
|
|
// gathering the system info, and applies them once it succeeds. Without it they would
|
|
// wait for the next network map that changes the checks.
|
|
func (e *Engine) retryPendingChecks() error {
|
|
if !e.hasPendingChecks.Load() {
|
|
return nil
|
|
}
|
|
|
|
e.syncMsgMux.Lock()
|
|
defer e.syncMsgMux.Unlock()
|
|
if e.stopping() || !e.hasPendingChecks.Load() {
|
|
return nil
|
|
}
|
|
checks := e.pendingChecks
|
|
log.Debugf("posture checks: retrying the meta sync that timed out")
|
|
if err := e.syncChecksMeta(checks); err != nil {
|
|
return err
|
|
}
|
|
e.setAppliedChecks(checks)
|
|
e.clearPendingChecks()
|
|
return nil
|
|
}
|
|
|
|
// refreshCertificateProofs collects the proofs for the applied checks when the cached
|
|
// ones are out of date, and sends them to management when they prove other chains than
|
|
// it holds. It holds no engine lock while collecting.
|
|
func (e *Engine) refreshCertificateProofs() error {
|
|
checks := e.appliedChecks()
|
|
if e.stopping() || !certproof.HasChallenges(checks) {
|
|
return nil
|
|
}
|
|
key := challengesKey(checks)
|
|
userContext := certproof.UserContext(e.config.CertStore)
|
|
if !e.certState.needsCollection(key, userContext, time.Now()) {
|
|
return nil
|
|
}
|
|
|
|
log.Debugf("certificate posture: collecting proofs")
|
|
peerKey := e.config.WgPrivateKey.PublicKey()
|
|
proofs := e.certProofs.Collect(e.ctx, checks, peerKey[:], e.config.CertStore)
|
|
// A collection cut short by the engine stopping proved nothing about the device, and
|
|
// must not be reported to the user as a lost certificate.
|
|
if e.stopping() {
|
|
return nil
|
|
}
|
|
if e.certState.record(key, userContext, proofs, time.Now()) {
|
|
e.publishCertificatePostureEvent(len(proofs) > 0)
|
|
}
|
|
if e.certState.setStuck(e.certProofs.Stuck()) {
|
|
e.publishCertificateStoreStuckEvent()
|
|
}
|
|
|
|
if e.certState.sameAsDelivered(proofs) {
|
|
log.Debugf("certificate posture: proofs unchanged since the last meta sync, not sending")
|
|
return nil
|
|
}
|
|
info, ok := e.infoSource.Refresh(e.ctx, e.infoGatherTimeout(), checks, e.overlayAddresses()...)
|
|
if !ok {
|
|
e.certState.undelivered()
|
|
return errSystemInfoTimeout
|
|
}
|
|
e.applyInfoFlags(info)
|
|
return e.sendMetaWithProofs(info, checks)
|
|
}
|
|
|
|
// stopping reports whether the engine is shutting down.
|
|
func (e *Engine) stopping() bool {
|
|
select {
|
|
case <-e.ctx.Done():
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// syncChecksMeta gathers the system info that checks evaluate and sends it to management
|
|
// with the cached certificate proofs. The caller holds syncMsgMux.
|
|
func (e *Engine) syncChecksMeta(checks []*mgmProto.Checks) error {
|
|
info, ok := e.infoSource.Refresh(e.ctx, e.infoGatherTimeout(), checks, e.overlayAddresses()...)
|
|
if !ok {
|
|
// Gathering timed out; skip the meta sync this cycle rather than blocking the
|
|
// sync loop (and syncMsgMux) on a stuck system call. The posture watcher retries.
|
|
e.certState.undelivered()
|
|
return errSystemInfoTimeout
|
|
}
|
|
e.applyInfoFlags(info)
|
|
return e.sendMetaWithProofs(info, checks)
|
|
}
|
|
|
|
// sendMetaWithProofs sends info to management with the cached proofs for checks. Sends
|
|
// are serialized and read the cache under that lock, so a send from the sync path cannot
|
|
// overwrite newer proofs the watcher sent in the meantime.
|
|
func (e *Engine) sendMetaWithProofs(info *system.Info, checks []*mgmProto.Checks) error {
|
|
e.certSendMu.Lock()
|
|
defer e.certSendMu.Unlock()
|
|
|
|
info.CertificateProofs = e.certState.cachedProofs(checks)
|
|
if err := e.mgmClient.SyncMeta(info); err != nil {
|
|
e.certState.undelivered()
|
|
return fmt.Errorf("sync meta: %w", err)
|
|
}
|
|
e.certState.markDelivered(info.CertificateProofs)
|
|
return nil
|
|
}
|