mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-10 15:39:07 +02:00
225 lines
9.6 KiB
Go
225 lines
9.6 KiB
Go
//go:build !windows && !darwin
|
|
|
|
package internal
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/certproof"
|
|
"github.com/netbirdio/netbird/client/internal/peer"
|
|
cProto "github.com/netbirdio/netbird/client/proto"
|
|
"github.com/netbirdio/netbird/client/system"
|
|
"github.com/netbirdio/netbird/shared/management/certposture"
|
|
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
|
|
mgmt "github.com/netbirdio/netbird/shared/management/client"
|
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
|
)
|
|
|
|
func TestCertPostureState_Record(t *testing.T) {
|
|
now := time.Now()
|
|
one := []certposture.Proof{{}}
|
|
|
|
var proven certPostureState
|
|
assert.False(t, proven.record("k", "", one, now), "a first collection that proves something is not news")
|
|
|
|
var unproven certPostureState
|
|
assert.True(t, unproven.record("k", "", nil, now), "a first collection that proves nothing is reported")
|
|
assert.False(t, unproven.record("k", "", nil, now), "the same outcome again is not reported twice")
|
|
assert.True(t, unproven.record("k", "", one, now), "proving again is reported")
|
|
assert.True(t, unproven.record("k", "", nil, now), "losing the proof again is reported")
|
|
}
|
|
|
|
// TestCertPostureState_ReportsStuckOnce: a store that stopped answering is reported to
|
|
// the user once when it wedges the collector, not on every collection that follows, and
|
|
// again if it wedges anew after recovering.
|
|
func TestCertPostureState_ReportsStuckOnce(t *testing.T) {
|
|
var s certPostureState
|
|
assert.True(t, s.setStuck(true), "becoming stuck is reported")
|
|
assert.False(t, s.setStuck(true), "staying stuck is not reported again")
|
|
assert.False(t, s.setStuck(false), "recovering is not a stuck report")
|
|
assert.True(t, s.setStuck(true), "getting stuck again is reported again")
|
|
}
|
|
|
|
func TestCertPostureState_NeedsCollection(t *testing.T) {
|
|
now := time.Now()
|
|
one := []certposture.Proof{{}}
|
|
var s certPostureState
|
|
|
|
assert.True(t, s.needsCollection("k", "", now), "nothing collected yet")
|
|
|
|
s.record("k", "501:alice", one, now)
|
|
assert.False(t, s.needsCollection("k", "501:alice", now.Add(time.Hour)), "a proven collection for the same challenges and user stays fresh")
|
|
assert.True(t, s.needsCollection("other", "501:alice", now), "new challenges, such as a rotated nonce, are answered again")
|
|
assert.True(t, s.needsCollection("k", "", now), "the user logging out changes what can be proven")
|
|
assert.True(t, s.needsCollection("k", "502:bob", now), "another owner session changes what can be proven")
|
|
|
|
s.record("k", "", nil, now)
|
|
assert.False(t, s.needsCollection("k", "", now.Add(certRetryInterval-time.Second)), "an unproven collection is not retried before the interval")
|
|
assert.True(t, s.needsCollection("k", "", now.Add(certRetryInterval)), "an unproven collection is retried once the interval passed")
|
|
|
|
s.record("k", "501:alice", one, now)
|
|
s.undelivered()
|
|
assert.True(t, s.needsCollection("k", "501:alice", now), "a proof management never received is collected and sent again")
|
|
}
|
|
|
|
// TestCertPostureState_CachedProofsAcrossNonceRotation: management rotates the nonce every
|
|
// window and accepts the previous one, so a cached proof keeps being attached across one
|
|
// rotation, while the watcher signs the new nonce, and not across two.
|
|
func TestCertPostureState_CachedProofsAcrossNonceRotation(t *testing.T) {
|
|
challenger := certposture.NewChallenger([]byte("secret"))
|
|
peerKey := []byte("peer-public-key-aaaaaaaaaaaaaaaa")
|
|
now := time.Now()
|
|
checksAt := func(at time.Time) []*mgmProto.Checks {
|
|
return []*mgmProto.Checks{{CertificateChallenge: &mgmProto.CertificateChallenge{Nonce: challenger.Nonce(peerKey, at)}}}
|
|
}
|
|
|
|
var s certPostureState
|
|
current := checksAt(now)
|
|
s.record(challengesKey(current), "", []certposture.Proof{{Nonce: current[0].CertificateChallenge.Nonce}}, now)
|
|
|
|
assert.Len(t, s.cachedProofs(current), 1, "the proof for the current nonce is attached")
|
|
assert.Len(t, s.cachedProofs(checksAt(now.Add(certposture.Window))), 1, "after one rotation the proof is still accepted and attached")
|
|
assert.Empty(t, s.cachedProofs(checksAt(now.Add(2*certposture.Window))), "after two rotations management would reject it")
|
|
assert.Empty(t, s.cachedProofs([]*mgmProto.Checks{{Files: []string{"/bin/agent"}}}), "no challenge, no proof")
|
|
}
|
|
|
|
// newCertPostureEngine is an engine whose certificate store is a real PEM directory and
|
|
// whose management client records the proofs of every meta sync.
|
|
func newCertPostureEngine(t *testing.T, recorder *peer.Status, syncMeta func(*system.Info) error) (*Engine, string, *certtest.CA) {
|
|
t.Helper()
|
|
// The store refuses a group-writable directory, which t.TempDir yields under a
|
|
// user-private-group umask.
|
|
dir := t.TempDir()
|
|
require.NoError(t, os.Chmod(dir, 0o700))
|
|
t.Setenv(certproof.StoreDirEnv, dir)
|
|
key, err := wgtypes.GeneratePrivateKey()
|
|
require.NoError(t, err)
|
|
|
|
e := &Engine{
|
|
ctx: context.Background(),
|
|
syncMsgMux: &sync.Mutex{},
|
|
config: &EngineConfig{WgPrivateKey: key},
|
|
statusRecorder: recorder,
|
|
mgmClient: &mgmt.MockClient{SyncMetaFunc: syncMeta},
|
|
}
|
|
ca := certtest.NewCA(t, "corp-root")
|
|
peerKey := key.PublicKey()
|
|
nonce := certposture.NewChallenger([]byte("secret")).Nonce(peerKey[:], time.Now())
|
|
e.checks = []*mgmProto.Checks{{CertificateChallenge: &mgmProto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}}
|
|
return e, dir, ca
|
|
}
|
|
|
|
func writeDeviceCert(t *testing.T, dir string, ca *certtest.CA) string {
|
|
t.Helper()
|
|
deviceKey := certtest.ECDSAKey(t)
|
|
pem := certtest.CertPEM(ca.Issue(t, deviceKey, "device")) + certtest.KeyPEM(t, deviceKey)
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "device.pem"), []byte(pem), 0o600))
|
|
return pem
|
|
}
|
|
|
|
// TestEngine_AttachCertificateProofsNeverCollects: the sync path only attaches what the
|
|
// watcher collected, so a certificate that appears in the store is not proven on the sync
|
|
// path until the watcher ran.
|
|
func TestEngine_AttachCertificateProofsNeverCollects(t *testing.T) {
|
|
e, dir, ca := newCertPostureEngine(t, nil, func(*system.Info) error { return nil })
|
|
writeDeviceCert(t, dir, ca)
|
|
|
|
info := &system.Info{}
|
|
e.attachCertificateProofs(info, e.checks)
|
|
assert.Empty(t, info.CertificateProofs, "the sync path does not collect")
|
|
|
|
require.NoError(t, e.refreshCertificateProofs())
|
|
e.attachCertificateProofs(info, e.checks)
|
|
assert.Len(t, info.CertificateProofs, 1, "the sync path attaches what the watcher collected")
|
|
}
|
|
|
|
// TestEngine_RefreshReportsLostAndRegainedProofs: a device that cannot prove its
|
|
// certificate gets one warning, and one notice when it can again.
|
|
func TestEngine_RefreshReportsLostAndRegainedProofs(t *testing.T) {
|
|
recorder := peer.NewRecorder("")
|
|
e, dir, ca := newCertPostureEngine(t, recorder, func(*system.Info) error { return nil })
|
|
warnings := func() int {
|
|
n := 0
|
|
for _, ev := range recorder.GetEventHistory() {
|
|
if ev.Severity == cProto.SystemEvent_WARNING && ev.Category == cProto.SystemEvent_SYSTEM {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
expire := func() { e.certState.attemptedAt = time.Now().Add(-certRetryInterval) }
|
|
|
|
require.NoError(t, e.refreshCertificateProofs())
|
|
assert.Equal(t, 1, warnings(), "the user is told the device proves no certificate")
|
|
|
|
expire()
|
|
require.NoError(t, e.refreshCertificateProofs())
|
|
assert.Equal(t, 1, warnings(), "an unchanged outcome is not reported again")
|
|
|
|
writeDeviceCert(t, dir, ca)
|
|
expire()
|
|
require.NoError(t, e.refreshCertificateProofs())
|
|
events := recorder.GetEventHistory()
|
|
require.NotEmpty(t, events)
|
|
assert.Equal(t, cProto.SystemEvent_INFO, events[len(events)-1].Severity, "regaining the proof is reported as good news")
|
|
}
|
|
|
|
// TestEngine_RefreshSendsOnlyChangedProofs: every meta sync makes management recompute
|
|
// and push network maps, so a collection that proves the same chains as before must not
|
|
// send, while a new certificate or a failed delivery must.
|
|
func TestEngine_RefreshSendsOnlyChangedProofs(t *testing.T) {
|
|
var sent [][]certposture.Proof
|
|
failSync := false
|
|
e, dir, ca := newCertPostureEngine(t, nil, func(info *system.Info) error {
|
|
if failSync {
|
|
return errors.New("management unavailable")
|
|
}
|
|
sent = append(sent, info.CertificateProofs)
|
|
return nil
|
|
})
|
|
// expire makes the last collection due for the unproven retry.
|
|
expire := func() { e.certState.attemptedAt = time.Now().Add(-certRetryInterval) }
|
|
// switchUser makes the last collection stale as if another user signed in.
|
|
switchUser := func() { e.certState.userContext = "someone else" }
|
|
|
|
require.NoError(t, e.refreshCertificateProofs())
|
|
require.Len(t, sent, 1, "the first collection is sent")
|
|
assert.Empty(t, sent[0], "nothing to prove yet")
|
|
|
|
expire()
|
|
require.NoError(t, e.refreshCertificateProofs())
|
|
assert.Len(t, sent, 1, "still proving nothing is not sent again")
|
|
|
|
pem := writeDeviceCert(t, dir, ca)
|
|
expire()
|
|
require.NoError(t, e.refreshCertificateProofs())
|
|
require.Len(t, sent, 2, "a newly proven certificate is sent")
|
|
assert.Len(t, sent[1], 1, "the new proof is attached")
|
|
|
|
switchUser()
|
|
require.NoError(t, e.refreshCertificateProofs())
|
|
assert.Len(t, sent, 2, "the same chain proven again, with a fresh signature, is not sent")
|
|
|
|
failSync = true
|
|
require.NoError(t, os.Remove(filepath.Join(dir, "device.pem")))
|
|
switchUser()
|
|
require.Error(t, e.refreshCertificateProofs())
|
|
failSync = false
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "device.pem"), []byte(pem), 0o600))
|
|
|
|
// Management missed the empty proof set, so it still holds the chain the device proves
|
|
// again now; after a failed delivery the state is unknown, and it is sent regardless.
|
|
require.NoError(t, e.refreshCertificateProofs())
|
|
assert.Len(t, sent, 3, "after a failed delivery the next collection is sent")
|
|
}
|