Merge remote-tracking branch 'origin/main' into fix_debug_upload_url_from_mgmt

# Conflicts:
#	shared/management/proto/management.pb.go
This commit is contained in:
riccardom
2026-10-09 17:12:38 +02:00
140 changed files with 12817 additions and 1661 deletions
+24
View File
@@ -100,6 +100,8 @@ type DefaultAccountManager struct {
peerInactivityExpiry Scheduler
certChallenges *certChallengeRefresher
// userDeleteFromIDPEnabled allows to delete user from IDP when user is deleted from account
userDeleteFromIDPEnabled bool
@@ -255,6 +257,8 @@ func BuildManager(
disableDefaultPolicy: disableDefaultPolicy,
}
am.certChallenges = newCertChallengeRefresher(am.refreshCertificateChallenges)
am.networkMapController.StartWarmup(ctx)
accountsCounter, err := store.GetAccountsCounter(ctx)
@@ -296,6 +300,10 @@ func BuildManager(
am.onPeersInvalidated(ctx, accountID, peerIDs)
})
// Started last: a manager that fails to build is never returned, so nothing would
// stop its refresher.
am.certChallenges.Start(ctx)
return am, nil
}
@@ -974,6 +982,7 @@ func (am *DefaultAccountManager) DeleteAccount(ctx context.Context, accountID, u
}
// cancel peer login expiry job
am.peerLoginExpiry.Cancel(ctx, []string{account.Id})
am.certChallenges.Forget(account.Id)
meta := map[string]any{"account_id": account.Id, "domain": account.Domain, "created_at": account.CreatedAt}
am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountDeleted, meta)
@@ -2710,6 +2719,8 @@ func (am *DefaultAccountManager) UpdatePeerIP(ctx context.Context, accountID, us
}
if updateNetworkMap {
am.reloadServicesTargetingPeer(ctx, accountID, peerID)
peer, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
if err != nil {
return err
@@ -2724,6 +2735,19 @@ func (am *DefaultAccountManager) UpdatePeerIP(ctx context.Context, accountID, us
return nil
}
// reloadServicesTargetingPeer resends the account's reverse proxy services unless none of them targets the peer.
func (am *DefaultAccountManager) reloadServicesTargetingPeer(ctx context.Context, accountID, peerID string) {
serviceID, err := am.serviceManager.GetServiceIDByTargetID(ctx, accountID, peerID)
if err != nil {
log.WithContext(ctx).Warnf("failed to look up services targeting peer %s, reloading all: %v", peerID, err)
} else if serviceID == "" {
return
}
if err := am.serviceManager.ReloadAllServicesForAccount(ctx, accountID); err != nil {
log.WithContext(ctx).Warnf("failed to reload services for account %s: %v", accountID, err)
}
}
func (am *DefaultAccountManager) updatePeerIPInTransaction(ctx context.Context, accountID, userID, peerID string, newIP netip.Addr) (bool, error) {
var updateNetworkMap bool
err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
+2
View File
@@ -64,6 +64,8 @@ type Manager interface {
ListUsers(ctx context.Context, accountID string) ([]*types.User, error)
GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error)
MarkPeerConnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error
TrackCertificateChallenges(ctx context.Context, accountID, peerID string, streamStart time.Time)
UntrackCertificateChallenges(accountID, peerID string, streamStart time.Time)
MarkPeerDisconnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error
DeletePeer(ctx context.Context, accountID, peerID, userID string) error
UpdatePeer(ctx context.Context, accountID, userID string, p *nbpeer.Peer) (*nbpeer.Peer, error)
+24
View File
@@ -1632,6 +1632,30 @@ func (mr *MockManagerMockRecorder) SyncUserJWTGroups(ctx, userAuth any) *gomock.
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncUserJWTGroups", reflect.TypeOf((*MockManager)(nil).SyncUserJWTGroups), ctx, userAuth)
}
// TrackCertificateChallenges mocks base method.
func (m *MockManager) TrackCertificateChallenges(ctx context.Context, accountID, peerID string, streamStart time.Time) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "TrackCertificateChallenges", ctx, accountID, peerID, streamStart)
}
// TrackCertificateChallenges indicates an expected call of TrackCertificateChallenges.
func (mr *MockManagerMockRecorder) TrackCertificateChallenges(ctx, accountID, peerID, streamStart any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TrackCertificateChallenges", reflect.TypeOf((*MockManager)(nil).TrackCertificateChallenges), ctx, accountID, peerID, streamStart)
}
// UntrackCertificateChallenges mocks base method.
func (m *MockManager) UntrackCertificateChallenges(accountID, peerID string, streamStart time.Time) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "UntrackCertificateChallenges", accountID, peerID, streamStart)
}
// UntrackCertificateChallenges indicates an expected call of UntrackCertificateChallenges.
func (mr *MockManagerMockRecorder) UntrackCertificateChallenges(accountID, peerID, streamStart any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UntrackCertificateChallenges", reflect.TypeOf((*MockManager)(nil).UntrackCertificateChallenges), accountID, peerID, streamStart)
}
// UpdateAccountOnboarding mocks base method.
func (m *MockManager) UpdateAccountOnboarding(ctx context.Context, accountID, userID string, newOnboarding *types.AccountOnboarding) (*types.AccountOnboarding, error) {
m.ctrl.T.Helper()
+81
View File
@@ -3,6 +3,7 @@ package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
@@ -4460,6 +4461,86 @@ func TestDefaultAccountManager_UpdatePeerIP(t *testing.T) {
})
}
func TestDefaultAccountManager_UpdatePeerIP_ReloadsTargetingServices(t *testing.T) {
manager, _, err := createManager(t)
require.NoError(t, err, "unable to create account manager")
ctx := context.Background()
accountID, err := manager.GetAccountIDByUserID(ctx, auth.UserAuth{UserId: userID})
require.NoError(t, err, "unable to create an account")
key, err := wgtypes.GenerateKey()
require.NoError(t, err, "unable to generate WireGuard key")
peer, _, _, _, err := manager.AddPeer(ctx, "", "", userID, &nbpeer.Peer{
Key: key.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "proxied-peer"},
}, false)
require.NoError(t, err, "unable to add peer")
account, err := manager.Store.GetAccount(ctx, accountID)
require.NoError(t, err, "unable to get account")
network := netip.MustParsePrefix(account.Network.Net.String())
t.Run("targeted peer reloads services", func(t *testing.T) {
serviceManager := service.NewMockManager(gomock.NewController(t))
manager.SetServiceManager(serviceManager)
serviceManager.EXPECT().GetServiceIDByTargetID(gomock.Any(), accountID, peer.ID).Return("svc-1", nil)
serviceManager.EXPECT().ReloadAllServicesForAccount(gomock.Any(), accountID).Return(nil)
current, err := manager.Store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peer.ID)
require.NoError(t, err)
newIP, err := types.AllocatePeerIP(network, []netip.Addr{current.IP})
require.NoError(t, err)
require.NoError(t, manager.UpdatePeerIP(ctx, accountID, userID, peer.ID, newIP))
})
t.Run("untargeted peer skips reload", func(t *testing.T) {
serviceManager := service.NewMockManager(gomock.NewController(t))
manager.SetServiceManager(serviceManager)
serviceManager.EXPECT().GetServiceIDByTargetID(gomock.Any(), accountID, peer.ID).Return("", nil)
current, err := manager.Store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peer.ID)
require.NoError(t, err)
newIP, err := types.AllocatePeerIP(network, []netip.Addr{current.IP})
require.NoError(t, err)
require.NoError(t, manager.UpdatePeerIP(ctx, accountID, userID, peer.ID, newIP))
})
t.Run("lookup failure reloads services", func(t *testing.T) {
serviceManager := service.NewMockManager(gomock.NewController(t))
manager.SetServiceManager(serviceManager)
serviceManager.EXPECT().GetServiceIDByTargetID(gomock.Any(), accountID, peer.ID).Return("", errors.New("store unavailable"))
serviceManager.EXPECT().ReloadAllServicesForAccount(gomock.Any(), accountID).Return(nil)
current, err := manager.Store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peer.ID)
require.NoError(t, err)
newIP, err := types.AllocatePeerIP(network, []netip.Addr{current.IP})
require.NoError(t, err)
require.NoError(t, manager.UpdatePeerIP(ctx, accountID, userID, peer.ID, newIP))
})
t.Run("network map notification failure still reloads services", func(t *testing.T) {
ctrl := gomock.NewController(t)
serviceManager := service.NewMockManager(ctrl)
manager.SetServiceManager(serviceManager)
serviceManager.EXPECT().GetServiceIDByTargetID(gomock.Any(), accountID, peer.ID).Return("svc-1", nil)
serviceManager.EXPECT().ReloadAllServicesForAccount(gomock.Any(), accountID).Return(nil)
originalController := manager.networkMapController
t.Cleanup(func() { manager.networkMapController = originalController })
networkMapController := network_map.NewMockController(ctrl)
networkMapController.EXPECT().GetDNSDomain(gomock.Any()).Return("netbird.cloud").AnyTimes()
networkMapController.EXPECT().OnPeersUpdated(gomock.Any(), accountID, []string{peer.ID}, gomock.Any()).Return(errors.New("notify failed"))
manager.networkMapController = networkMapController
current, err := manager.Store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peer.ID)
require.NoError(t, err)
newIP, err := types.AllocatePeerIP(network, []netip.Addr{current.IP})
require.NoError(t, err)
require.Error(t, manager.UpdatePeerIP(ctx, accountID, userID, peer.ID, newIP))
})
}
func TestAddNewUserToDomainAccountWithApproval(t *testing.T) {
manager, _, err := createManager(t)
if err != nil {
+344
View File
@@ -0,0 +1,344 @@
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)
}
@@ -0,0 +1,493 @@
package server
import (
"context"
"slices"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/certposture"
)
// fakeClock lets a test drive the refresher's schedule without waiting for it.
type fakeClock struct {
mu sync.Mutex
at time.Time
}
func (c *fakeClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.at
}
func (c *fakeClock) Advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.at = c.at.Add(d)
}
// scheduleRefresher builds a refresher on a clock the test drives and never starts its
// loop, so the schedule can be checked by calling takeDue directly.
func scheduleRefresher(refresh func(accountID string) bool) (*certChallengeRefresher, *fakeClock) {
clock := &fakeClock{at: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)}
r := newCertChallengeRefresher(func(_ context.Context, accountID string, _ []string) bool {
return refresh(accountID)
})
r.now = clock.Now
return r, clock
}
// runningRefresher builds a refresher whose period and tick are short enough to observe
// in a test, and starts its loop.
func runningRefresher(t *testing.T, refresh func(accountID string) bool) *certChallengeRefresher {
t.Helper()
r := newCertChallengeRefresher(func(_ context.Context, accountID string, _ []string) bool {
return refresh(accountID)
})
r.period = 10 * time.Millisecond
r.tick = time.Millisecond
r.Start(t.Context())
return r
}
// streamStart is the start of the sync stream the tests' peers are stamped on.
var streamStart = time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
// track tracks one peer of accountID, as a stamped sync stream does.
func track(r *certChallengeRefresher, accountID string) {
r.Track(context.Background(), accountID, accountID+"-peer", streamStart)
}
// dueAccounts returns the IDs of the accounts takeDue hands out.
func dueAccounts(r *certChallengeRefresher) []string {
var ids []string
for _, due := range r.takeDue() {
ids = append(ids, due.accountID)
}
return ids
}
func (r *certChallengeRefresher) dueAt(accountID string) time.Time {
r.mu.Lock()
defer r.mu.Unlock()
return r.accounts[accountID].due
}
func TestCertChallengePeriod_LeavesRoomForAMissedRun(t *testing.T) {
// A nonce issued at the very end of a window is accepted for that window and the
// next one only, so the shortest life a peer can be handed is one window. Renewing
// has to stay clear of that edge even when a run is missed.
assert.Less(t, 2*certChallengePeriod(), certposture.EffectiveWindow(),
"a missed refresh must still leave the peer's nonce valid, with margin")
}
func TestCertChallengeTick_FollowsTheWindow(t *testing.T) {
// An end-to-end test shortens the challenge window to watch a renewal happen. The
// tick has to come down with it, or the refresher would still be looking for due
// accounts every quarter of an hour and never renew anything in time.
short := certChallengeTick(30 * time.Second)
assert.Less(t, short, 30*time.Second, "the tick must be shorter than the period it serves")
assert.GreaterOrEqual(t, short, minCertChallengeTick, "the tick must not spin")
assert.Equal(t, maxCertChallengeTick, certChallengeTick(24*time.Hour),
"a long period must not stretch the tick without bound")
assert.Equal(t, minCertChallengeTick, certChallengeTick(time.Millisecond),
"a tiny period must not drive the tick below its floor")
}
func TestCertChallengeRefresher_SpreadsAccountsOverThePeriod(t *testing.T) {
// Every account on an instance shares the same global challenge window, so a
// restart arms them all at once. The offset is what keeps them from fanning out
// to their peers in the same moment.
r, clock := scheduleRefresher(func(string) bool { return true })
ids := []string{"account-a", "account-b", "account-c", "account-d", "account-e", "account-f"}
for _, id := range ids {
track(r, id)
}
start := clock.Now()
offsets := map[time.Duration]bool{}
for _, id := range ids {
offset := r.dueAt(id).Sub(start)
assert.GreaterOrEqual(t, offset, time.Duration(0), "account %s is due in the past", id)
assert.Less(t, offset, r.period, "account %s is due beyond one period", id)
offsets[offset] = true
}
assert.Greater(t, len(offsets), 1, "all accounts were given the same offset, which defeats the spreading")
}
func TestCertChallengeOffset_IsStablePerAccount(t *testing.T) {
assert.Equal(t, offsetWithin("account-a", certChallengePeriod()), offsetWithin("account-a", certChallengePeriod()),
"the same account must keep its slot across restarts")
assert.NotEqual(t, offsetWithin("account-a", certChallengePeriod()), offsetWithin("account-b", certChallengePeriod()),
"two accounts must not share a slot")
}
func TestCertChallengeRefresher_RenewsOncePerPeriod(t *testing.T) {
r, clock := scheduleRefresher(func(string) bool { return true })
track(r, "account-a")
assert.Empty(t, r.takeDue(), "an account just tracked is not due before its offset elapses")
clock.Advance(r.period)
assert.Equal(t, []string{"account-a"}, dueAccounts(r), "the account is due once its offset has elapsed")
clock.Advance(r.period / 2)
assert.Empty(t, r.takeDue(), "the next run is booked a full period out")
clock.Advance(r.period / 2)
assert.Equal(t, []string{"account-a"}, dueAccounts(r), "the account is due again one period later")
}
func TestCertChallengeRefresher_BooksTheNextRunBeforeRefreshing(t *testing.T) {
// takeDue reserves the next run while it holds the lock, so a refresh that outlives
// a tick cannot have the same account handed out twice.
r, clock := scheduleRefresher(func(string) bool { return true })
track(r, "account-a")
clock.Advance(r.period)
require.Len(t, r.takeDue(), 1, "the account is due")
assert.Empty(t, r.takeDue(), "a second pass at the same instant must not hand out the account again")
}
func TestCertChallengeRefresher_TrackIsIdempotent(t *testing.T) {
r, clock := scheduleRefresher(func(string) bool { return true })
track(r, "account-a")
first := r.dueAt("account-a")
clock.Advance(certChallengePeriod())
track(r, "account-a")
assert.Equal(t, first, r.dueAt("account-a"), "re-tracking must not push the next run further out")
}
func TestCertChallengeRefresher_RefreshesATrackedAccount(t *testing.T) {
refreshed := make(chan string, 4)
r := runningRefresher(t, func(accountID string) bool {
select {
case refreshed <- accountID:
default:
}
return true
})
track(r, "account-a")
select {
case got := <-refreshed:
assert.Equal(t, "account-a", got)
case <-time.After(3 * time.Second):
t.Fatal("a tracked account was never refreshed")
}
assert.True(t, r.tracked("account-a"), "an account that still wants challenges stays tracked")
}
func TestCertChallengeRefresher_DropsAnAccountThatNoLongerWantsChallenges(t *testing.T) {
var mu sync.Mutex
var calls int
r := runningRefresher(t, func(string) bool {
mu.Lock()
defer mu.Unlock()
calls++
return false
})
track(r, "account-a")
require.Eventually(t, func() bool { return !r.tracked("account-a") }, 3*time.Second, time.Millisecond,
"an account whose refresh reports it no longer wants challenges must be dropped")
time.Sleep(20 * r.tick)
mu.Lock()
defer mu.Unlock()
assert.Equal(t, 1, calls, "the account must not be refreshed again after being dropped")
}
func TestCertChallengeRefresher_RefreshesWithoutHoldingTheLock(t *testing.T) {
// The refresh fans out to every peer of the account, so holding the lock across it
// would stall every peer that connects meanwhile. Calling back into the refresher
// from inside the refresh deadlocks if the loop still holds it.
reentered := make(chan bool, 1)
var once sync.Once
r := newCertChallengeRefresher(nil)
r.period = 10 * time.Millisecond
r.tick = time.Millisecond
r.refresh = func(_ context.Context, accountID string, _ []string) bool {
once.Do(func() {
answered := make(chan bool, 1)
go func() { answered <- r.tracked(accountID) }()
select {
case got := <-answered:
reentered <- got
case <-time.After(2 * time.Second):
reentered <- false
}
})
return true
}
r.Start(t.Context())
track(r, "account-a")
select {
case ok := <-reentered:
assert.True(t, ok, "the refresher was not reachable while a refresh was in flight")
case <-time.After(5 * time.Second):
t.Fatal("the refresh never ran")
}
}
// TestCertificateChallengeTargets_MatchesThePerPeerRule holds the renewal to the same
// rule management uses when it hands out the challenge. The refresher asks "which peers
// get one", processPeerPostureChecks asks "does this peer get one", and the two have to
// answer identically: a peer the refresher forgets stops being renewed and silently
// falls out of its policies, which is the failure the refresher exists to prevent.
func TestCertificateChallengeTargets_MatchesThePerPeerRule(t *testing.T) {
const certCheck, osCheck = "check-cert", "check-os"
certCheckIDs := map[string]struct{}{certCheck: {}}
groupPeers := map[string][]string{
"g-devs": {"p1", "p2"},
"g-servers": {"p3"},
"g-empty": {},
"g-mixed": {"p2", "p4"},
}
peerGroups := map[string][]string{
"p1": {"g-devs"},
"p2": {"g-devs", "g-mixed"},
"p3": {"g-servers"},
"p4": {"g-mixed"},
"p5": {},
}
policies := []*types.Policy{
{
ID: "gated", Enabled: true, SourcePostureChecks: []string{certCheck},
Rules: []*types.PolicyRule{{Enabled: true, Sources: []string{"g-devs"}}},
},
{
// A disabled policy hands out nothing.
ID: "disabled-policy", Enabled: false, SourcePostureChecks: []string{certCheck},
Rules: []*types.PolicyRule{{Enabled: true, Sources: []string{"g-servers"}}},
},
{
// A disabled rule inside an enabled policy likewise.
ID: "disabled-rule", Enabled: true, SourcePostureChecks: []string{certCheck},
Rules: []*types.PolicyRule{{Enabled: false, Sources: []string{"g-servers"}}},
},
{
// Gated on something other than a certificate: no nonce to renew.
ID: "other-check", Enabled: true, SourcePostureChecks: []string{osCheck},
Rules: []*types.PolicyRule{{Enabled: true, Sources: []string{"g-mixed"}}},
},
{
// A peer named directly rather than through a group.
ID: "by-resource", Enabled: true, SourcePostureChecks: []string{osCheck, certCheck},
Rules: []*types.PolicyRule{{
Enabled: true,
SourceResource: types.Resource{Type: types.ResourceTypePeer, ID: "p4"},
}},
},
{
// Destinations are never filtered, so being one earns no challenge.
ID: "as-destination", Enabled: true, SourcePostureChecks: []string{certCheck},
Rules: []*types.PolicyRule{{Enabled: true, Sources: []string{"g-empty"}, Destinations: []string{"g-servers"}}},
},
}
got := certificateChallengeTargets(policies, groupPeers, certCheckIDs)
// The same question, asked one peer at a time the way the gRPC layer asks it.
var want []string
for peerID, groupIDs := range peerGroups {
for _, policy := range policies {
if !policy.Enabled {
continue
}
ids := processPeerPostureChecks(policy, peerID, groupIDs)
if slices.ContainsFunc(ids, func(id string) bool { _, ok := certCheckIDs[id]; return ok }) {
want = append(want, peerID)
break
}
}
}
slices.Sort(got)
slices.Sort(want)
assert.Equal(t, want, got, "the peers the refresher renews must be exactly the peers that are sent a challenge")
assert.Equal(t, []string{"p1", "p2", "p4"}, got, "p3 is only reachable through disabled rules, p5 is in no source group")
}
func TestCertChallengeRefresher_StopsWithItsContext(t *testing.T) {
// BuildManager hands the loop the manager's context. A refresher that detached from
// it could never be stopped: it would keep sweeping after the server it belongs to
// is gone, and leak a goroutine per manager a test builds.
var mu sync.Mutex
var calls int
r := newCertChallengeRefresher(func(_ context.Context, _ string, _ []string) bool {
mu.Lock()
defer mu.Unlock()
calls++
return true
})
r.period = time.Millisecond
r.tick = time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
r.Start(ctx)
track(r, "account-a")
require.Eventually(t, func() bool {
mu.Lock()
defer mu.Unlock()
return calls > 0
}, 3*time.Second, time.Millisecond, "the refresher never ran")
cancel()
time.Sleep(20 * r.tick)
mu.Lock()
afterCancel := calls
mu.Unlock()
time.Sleep(20 * r.tick)
mu.Lock()
defer mu.Unlock()
assert.Equal(t, afterCancel, calls, "the refresher kept sweeping after its context was cancelled")
}
func TestCertChallengeRefresh_StaysWithinTheSweep(t *testing.T) {
assert.Equal(t, maxCertChallengeRefresh, certChallengeRefresh(time.Hour),
"a long tick must not grant a store read minutes")
assert.Equal(t, time.Second, certChallengeRefresh(time.Second),
"a short tick must bound the refresh to itself, so the sweep keeps its cadence")
}
func TestCertChallengeRefresher_AbandonsAWedgedAccount(t *testing.T) {
// Accounts are swept one after another and resolving the targets reads the store, so
// an account whose read never returns would hold up every other account on the
// instance for as long as the process lives.
entered := make(chan struct{}, 1)
refreshed := make(chan string, 4)
r := newCertChallengeRefresher(func(ctx context.Context, accountID string, _ []string) bool {
if accountID == "account-wedged" {
select {
case entered <- struct{}{}:
default:
}
<-ctx.Done()
return true
}
select {
case refreshed <- accountID:
default:
}
return true
})
r.period = 10 * time.Millisecond
r.tick = time.Millisecond
r.timeout = 20 * time.Millisecond
r.Start(t.Context())
track(r, "account-wedged")
select {
case <-entered:
case <-time.After(3 * time.Second):
t.Fatal("the wedged account was never refreshed")
}
// Tracked only once the sweep is already stuck, so reaching it proves the deadline
// released the loop rather than the map happening to hand this one out first.
track(r, "account-healthy")
select {
case got := <-refreshed:
assert.Equal(t, "account-healthy", got)
case <-time.After(3 * time.Second):
t.Fatal("a healthy account was never refreshed from behind a wedged one")
}
}
// TestCertChallengeRefresher_KeepsAnAccountTrackedDuringItsRefresh: a refresh reads the
// store before an admin adds a certificate check; the push that follows the change
// tracks the account again. The refresh then reports the account as not wanting
// challenges, which is stale, and must not untrack it.
func TestCertChallengeRefresher_KeepsAnAccountTrackedDuringItsRefresh(t *testing.T) {
r, clock := scheduleRefresher(func(string) bool { return false })
track(r, "account-a")
clock.Advance(r.period)
require.Equal(t, []string{"account-a"}, dueAccounts(r), "the account is due")
track(r, "account-a")
r.finish("account-a", false)
assert.True(t, r.tracked("account-a"), "an account tracked again during its refresh stays tracked")
clock.Advance(r.period)
require.Equal(t, []string{"account-a"}, dueAccounts(r), "the account is due again")
r.finish("account-a", false)
assert.False(t, r.tracked("account-a"), "an account nobody tracked again is dropped once it no longer wants challenges")
}
// TestCertChallengeRefresher_RenewsOnlyThePeersStreamedHere: every instance runs its
// own refresher, and a push fans out to all of them, so an instance must hand out only
// the peers whose sync stream it holds. Otherwise each peer is pushed once per instance.
func TestCertChallengeRefresher_RenewsOnlyThePeersStreamedHere(t *testing.T) {
r, clock := scheduleRefresher(func(string) bool { return true })
r.Track(context.Background(), "account-a", "peer-1", streamStart)
r.Track(context.Background(), "account-a", "peer-2", streamStart)
clock.Advance(r.period)
due := r.takeDue()
require.Len(t, due, 1, "the account is due")
assert.ElementsMatch(t, []string{"peer-1", "peer-2"}, due[0].peerIDs, "only the peers tracked here are renewed")
}
// TestCertChallengeRefresher_DropsAnAccountWithItsLastPeer: an instance whose peers of an
// account have all disconnected has nobody left to renew, and must not keep sweeping it.
func TestCertChallengeRefresher_DropsAnAccountWithItsLastPeer(t *testing.T) {
r, _ := scheduleRefresher(func(string) bool { return true })
r.Track(context.Background(), "account-a", "peer-1", streamStart)
r.Track(context.Background(), "account-a", "peer-2", streamStart)
r.Untrack("account-a", "peer-1", streamStart)
assert.True(t, r.tracked("account-a"), "the account stays tracked while a peer is still streamed here")
r.Untrack("account-a", "peer-2", streamStart)
assert.False(t, r.tracked("account-a"), "the account is dropped with its last peer")
}
// TestCertChallengeRefresher_StaleStreamKeepsTheNewerOne: a peer that reconnects to the
// same instance opens its new stream before the old one is torn down. The old stream's
// teardown must not untrack the peer the new stream is renewing.
func TestCertChallengeRefresher_StaleStreamKeepsTheNewerOne(t *testing.T) {
r, _ := scheduleRefresher(func(string) bool { return true })
newer := streamStart.Add(time.Minute)
r.Track(context.Background(), "account-a", "peer-1", streamStart)
r.Track(context.Background(), "account-a", "peer-1", newer)
r.Untrack("account-a", "peer-1", streamStart)
assert.True(t, r.tracked("account-a"), "the stale stream's teardown must leave the newer stream tracked")
r.Untrack("account-a", "peer-1", newer)
assert.False(t, r.tracked("account-a"), "the newer stream's teardown untracks the peer")
}
// TestCertChallengeRefresher_LateStampFromAStaleStream: the old stream of a reconnected
// peer can still deliver a stamped update after the new stream started. Its Track must
// not take the peer over, or the old stream's teardown would untrack the live one.
func TestCertChallengeRefresher_LateStampFromAStaleStream(t *testing.T) {
r, _ := scheduleRefresher(func(string) bool { return true })
newer := streamStart.Add(time.Minute)
r.Track(context.Background(), "account-a", "peer-1", newer)
r.Track(context.Background(), "account-a", "peer-1", streamStart)
r.Untrack("account-a", "peer-1", streamStart)
assert.True(t, r.tracked("account-a"), "a late stamp from the stale stream must leave the newer stream tracked")
}
@@ -133,6 +133,7 @@ type MockAccountManager struct {
GetOrCreateAccountByPrivateDomainFunc func(ctx context.Context, initiatorId, domain string) (*types.Account, bool, error)
AllowSyncFunc func(string, uint64) bool
TrackCertificateChallengesFunc func(ctx context.Context, accountID, peerID string, streamStart time.Time)
UpdateAccountPeersFunc func(ctx context.Context, accountID string, reason types.UpdateReason)
ExpandAndUpdateAffectedFunc func(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change)
BufferUpdateAccountPeersFunc func(ctx context.Context, accountID string, reason types.UpdateReason)
@@ -206,6 +207,16 @@ func (am *MockAccountManager) UpdateGroups(ctx context.Context, accountID, userI
return status.Errorf(codes.Unimplemented, "method UpdateGroups is not implemented")
}
// TrackCertificateChallenges mocks TrackCertificateChallenges of the account manager.
func (am *MockAccountManager) TrackCertificateChallenges(ctx context.Context, accountID, peerID string, streamStart time.Time) {
if am.TrackCertificateChallengesFunc != nil {
am.TrackCertificateChallengesFunc(ctx, accountID, peerID, streamStart)
}
}
// UntrackCertificateChallenges mocks UntrackCertificateChallenges of the account manager.
func (am *MockAccountManager) UntrackCertificateChallenges(string, string, time.Time) {}
func (am *MockAccountManager) UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) {
if am.UpdateAccountPeersFunc != nil {
am.UpdateAccountPeersFunc(ctx, accountID, reason)
+6 -1
View File
@@ -174,6 +174,7 @@ type PeerSystemMeta struct { //nolint:revive
Environment Environment `gorm:"serializer:json"`
Flags Flags `gorm:"serializer:json"`
Files []File `gorm:"serializer:json"`
Certificates []string `gorm:"serializer:json"`
Capabilities []int32 `gorm:"serializer:json"`
SyncMessageVersion int
}
@@ -199,7 +200,8 @@ func (p PeerSystemMeta) isEmpty() bool {
p.SystemManufacturer == "" &&
p.Environment.Cloud == "" &&
p.Environment.Platform == "" &&
len(p.Files) == 0
len(p.Files) == 0 &&
len(p.Certificates) == 0
}
// AddedWithSSOLogin indicates whether this peer has been added with an SSO login by a user.
@@ -418,6 +420,9 @@ func diffMeta(oldMeta, newMeta PeerSystemMeta, oldLocation, newLocation Location
if !sameMultiset(oldMeta.Files, newMeta.Files) {
add("files", fmt.Sprintf("%v", oldMeta.Files), fmt.Sprintf("%v", newMeta.Files))
}
if !sameMultiset(oldMeta.Certificates, newMeta.Certificates) {
add("certificates", len(oldMeta.Certificates), len(newMeta.Certificates))
}
if oldMeta.SyncMessageVersion != newMeta.SyncMessageVersion {
add("sync_meta_version", fmt.Sprintf("%d", oldMeta.SyncMessageVersion), fmt.Sprintf("%d", newMeta.SyncMessageVersion))
}
+36
View File
@@ -0,0 +1,36 @@
package posture
import (
"context"
"fmt"
"time"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/shared/management/certposture"
)
// CertificateCheck passes when the peer holds a certificate, proven at meta ingestion,
// that chains to one of the configured PEM encoded CA certificates.
type CertificateCheck struct {
CACertificates []string
}
var _ Check = (*CertificateCheck)(nil)
func (c *CertificateCheck) Check(_ context.Context, peer nbpeer.Peer) (bool, error) {
return certposture.AnyChainMatchesCAs(peer.Meta.Certificates, c.CACertificates, time.Now()), nil
}
func (c *CertificateCheck) Name() string {
return CertificateCheckName
}
func (c *CertificateCheck) Validate() error {
if len(c.CACertificates) == 0 {
return fmt.Errorf("%s ca certificates shouldn't be empty", c.Name())
}
if _, err := certposture.ParseCAs(c.CACertificates); err != nil {
return fmt.Errorf("%s: %w", c.Name(), err)
}
return nil
}
@@ -0,0 +1,68 @@
package posture
import (
"context"
"crypto/x509"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
)
func TestCertificateCheck_Check(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
otherCA := certtest.NewCA(t, "other-root")
chain := certposture.EncodeChainPEM([]*x509.Certificate{ca.Issue(t, certtest.ECDSAKey(t), "device")})
tests := []struct {
name string
certificates []string
cas []string
want bool
}{
{"chains to configured CA", []string{chain}, []string{ca.PEM}, true},
{"one of several CAs matches", []string{chain}, []string{otherCA.PEM, ca.PEM}, true},
{"unrelated CA", []string{chain}, []string{otherCA.PEM}, false},
{"no certificates proven", nil, []string{ca.PEM}, false},
{"garbage entry does not hide a valid one", []string{"garbage", chain}, []string{ca.PEM}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := CertificateCheck{CACertificates: tt.cas}
got, err := check.Check(context.Background(), peer.Peer{Meta: peer.PeerSystemMeta{Certificates: tt.certificates}})
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestCertificateCheck_Validate(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
assert.Error(t, (&CertificateCheck{}).Validate())
assert.Error(t, (&CertificateCheck{CACertificates: []string{"not a pem"}}).Validate())
assert.NoError(t, (&CertificateCheck{CACertificates: []string{ca.PEM}}).Validate())
}
func TestChecks_CertificateCheckRegistered(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
checks := &Checks{Name: "cert", Checks: ChecksDefinition{CertificateCheck: &CertificateCheck{CACertificates: []string{ca.PEM}}}}
require.NoError(t, checks.Validate())
require.Len(t, checks.GetChecks(), 1)
assert.Equal(t, CertificateCheckName, checks.GetChecks()[0].Name())
copied := checks.Copy()
checks.Checks.CertificateCheck.CACertificates[0] = "mutated"
assert.Equal(t, ca.PEM, copied.Checks.CertificateCheck.CACertificates[0])
api := checks.ToAPIResponse()
require.NotNil(t, api.Checks.CertificateCheck)
roundTrip, err := NewChecksFromAPIPostureCheck(*api)
require.NoError(t, err)
assert.Equal(t, checks.Checks.CertificateCheck.CACertificates, roundTrip.Checks.CertificateCheck.CACertificates)
}
+19
View File
@@ -19,6 +19,7 @@ const (
GeoLocationCheckName = "GeoLocationCheck"
PeerNetworkRangeCheckName = "PeerNetworkRangeCheck"
ProcessCheckName = "ProcessCheck"
CertificateCheckName = "CertificateCheck"
CheckActionAllow string = "allow"
CheckActionDeny string = "deny"
@@ -61,6 +62,7 @@ type ChecksDefinition struct {
GeoLocationCheck *GeoLocationCheck `json:",omitempty"`
PeerNetworkRangeCheck *PeerNetworkRangeCheck `json:",omitempty"`
ProcessCheck *ProcessCheck `json:",omitempty"`
CertificateCheck *CertificateCheck `json:",omitempty"`
}
// Copy returns a copy of a checks definition.
@@ -113,6 +115,12 @@ func (cd ChecksDefinition) Copy() ChecksDefinition {
}
copy(cdCopy.ProcessCheck.Processes, processCheck.Processes)
}
if cd.CertificateCheck != nil {
cdCopy.CertificateCheck = &CertificateCheck{
CACertificates: make([]string, len(cd.CertificateCheck.CACertificates)),
}
copy(cdCopy.CertificateCheck.CACertificates, cd.CertificateCheck.CACertificates)
}
return cdCopy
}
@@ -157,6 +165,9 @@ func (pc *Checks) GetChecks() []Check {
if pc.Checks.ProcessCheck != nil {
checks = append(checks, pc.Checks.ProcessCheck)
}
if pc.Checks.CertificateCheck != nil {
checks = append(checks, pc.Checks.CertificateCheck)
}
return checks
}
@@ -212,6 +223,10 @@ func buildPostureCheck(postureChecksID string, name string, description string,
postureChecks.Checks.ProcessCheck = toProcessCheck(processCheck)
}
if certificateCheck := checks.CertificateCheck; certificateCheck != nil {
postureChecks.Checks.CertificateCheck = &CertificateCheck{CACertificates: certificateCheck.CaCertificates}
}
return &postureChecks, nil
}
@@ -246,6 +261,10 @@ func (pc *Checks) ToAPIResponse() *api.PostureCheck {
checks.ProcessCheck = toProcessCheckResponse(pc.Checks.ProcessCheck)
}
if pc.Checks.CertificateCheck != nil {
checks.CertificateCheck = &api.CertificateCheck{CaCertificates: pc.Checks.CertificateCheck.CACertificates}
}
return &api.PostureCheck{
Id: pc.ID,
Name: pc.Name,
+6 -3
View File
@@ -186,7 +186,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
inactivity_expiration_enabled, last_login, created_at, ephemeral, extra_dns_labels, allow_extra_dns_labels, meta_hostname,
meta_go_os, meta_kernel, meta_core, meta_platform, meta_os, meta_os_version, meta_wt_version, meta_ui_version,
meta_kernel_version, meta_network_addresses, meta_system_serial_number, meta_system_product_name, meta_system_manufacturer,
meta_environment, meta_flags, meta_files, meta_capabilities, peer_status_last_seen, peer_status_session_started_at,
meta_environment, meta_flags, meta_files, meta_certificates, meta_capabilities, peer_status_last_seen, peer_status_session_started_at,
peer_status_connected, peer_status_login_expired, peer_status_requires_approval, location_connection_ip,
location_country_code, location_city_name, location_geo_name_id, proxy_meta_embedded, proxy_meta_cluster, ipv6, meta_sync_message_version
FROM peers WHERE account_id = $1`
@@ -204,7 +204,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
peerStatusLastSeen sql.NullTime
peerStatusSessionStartedAt sql.NullInt64
peerStatusConnected, peerStatusLoginExpired, peerStatusRequiresApproval, proxyEmbedded sql.NullBool
ip, extraDNS, netAddr, env, flags, files, capabilities, connIP, ipv6 []byte
ip, extraDNS, netAddr, env, flags, files, certificates, capabilities, connIP, ipv6 []byte
metaHostname, metaGoOS, metaKernel, metaCore, metaPlatform sql.NullString
metaOS, metaOSVersion, metaWtVersion, metaUIVersion, metaKernelVersion sql.NullString
metaSystemSerialNumber, metaSystemProductName, metaSystemManufacturer sql.NullString
@@ -217,7 +217,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
&loginExpirationEnabled, &inactivityExpirationEnabled, &lastLogin, &createdAt, &ephemeral, &extraDNS,
&allowExtraDNSLabels, &metaHostname, &metaGoOS, &metaKernel, &metaCore, &metaPlatform,
&metaOS, &metaOSVersion, &metaWtVersion, &metaUIVersion, &metaKernelVersion, &netAddr,
&metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files, &capabilities,
&metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files, &certificates, &capabilities,
&peerStatusLastSeen, &peerStatusSessionStartedAt, &peerStatusConnected, &peerStatusLoginExpired,
&peerStatusRequiresApproval, &connIP, &locationCountryCode, &locationCityName, &locationGeoNameID,
&proxyEmbedded, &proxyCluster, &ipv6, &metaSyncMessageVersion)
@@ -334,6 +334,9 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
if files != nil {
_ = json.Unmarshal(files, &p.Meta.Files)
}
if certificates != nil {
_ = json.Unmarshal(certificates, &p.Meta.Certificates)
}
if capabilities != nil {
_ = json.Unmarshal(capabilities, &p.Meta.Capabilities)
}
@@ -54,7 +54,7 @@ func TestSqlStore_SavePeer(t *testing.T) {
numOfFields, err := populateFields.PopulateAll(reflectedMetadata)
assert.NoError(t, err)
assert.Equal(t, 33, numOfFields)
assert.Equal(t, 34, numOfFields)
// save status of non-existing peer
peer := &nbpeer.Peer{
@@ -198,6 +198,7 @@ func twinPeer(p *nbpeer.Peer) *nmdata.Peer {
KernelVersion: p.Meta.KernelVersion,
NetworkAddresses: networkAddresses,
Files: files,
Certificates: p.Meta.Certificates,
Capabilities: p.Meta.Capabilities,
SyncMessageVersion: p.Meta.SyncMessageVersion,
Flags: nmdata.Flags{
@@ -452,6 +453,9 @@ func TwinPostureChecks(pc *posture.Checks) *nmdata.PostureChecks {
}
out.Checks.ProcessCheck = &nmdata.ProcessCheck{Processes: procs}
}
if def.CertificateCheck != nil {
out.Checks.CertificateCheck = &nmdata.CertificateCheck{CACertificates: def.CertificateCheck.CACertificates}
}
return out
}
+3
View File
@@ -34,4 +34,7 @@ const (
UpdateOperationCreate UpdateOperation = "create"
UpdateOperationUpdate UpdateOperation = "update"
UpdateOperationDelete UpdateOperation = "delete"
// UpdateOperationRefresh is a periodic push that carries no change of its own, so
// it stays out of the counters that track what an administrator actually edited.
UpdateOperationRefresh UpdateOperation = "refresh"
)