mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-03 12:09:09 +02:00
Seven Config fields mean "the effective default" when they hold no value: the five SSH toggles, the SSH JWT cache TTL, and the network monitor. Every consumer already reads a nil as that default, but apply() diffed them by presence — `config.X == nil || *input.X != *config.X` — so an input restating the default counted as a change. That made the update-settings gate refuse `netbird up` outright. The CLI sends every flag whose value came from an environment variable (SetFlagsFromEnvVars goes through pflag's FlagSet.Set, which marks the flag Changed), and the config a plain login writes leaves all seven unset, so a container configured with, say, NB_ENABLE_SSH_ROOT=false restated a default the file held as null on every start and was answered with FailedPrecondition. apply() now resolves the seven up front, the way it already did for ServerSSHAllowed and RemoteJobsAllowed, which also repairs such a profile on its next write. With the values named, the comparisons below diff values instead of presence, so their nil branches are gone. The network monitor keeps its platform default — on for windows and darwin — and naming it as false elsewhere is what createEngineConfig already read a nil to be. getJWTCacheTTL reaches the same 0 through its own default, and Android's GetEnableSSH* getters already answered nil with false.
461 lines
17 KiB
Go
461 lines
17 KiB
Go
package profilemanager
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/client/iface"
|
|
"github.com/netbirdio/netbird/shared/management/domain"
|
|
)
|
|
|
|
func seededConfig(t *testing.T) *Config {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(t.TempDir(), "seeded.json")
|
|
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
|
ConfigPath: path,
|
|
ManagementURL: "https://api.netbird.io:443",
|
|
PreSharedKey: strPointer("stored-key"),
|
|
})
|
|
require.NoError(t, err)
|
|
return cfg
|
|
}
|
|
|
|
func strPointer(s string) *string { return &s }
|
|
|
|
func intPtr(i int) *int { return &i }
|
|
|
|
func TestWouldChange(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input ConfigInput
|
|
want bool
|
|
}{
|
|
{name: "empty input", input: ConfigInput{}, want: false},
|
|
{name: "same management URL", input: ConfigInput{ManagementURL: "https://api.netbird.io:443"}, want: false},
|
|
{name: "management URL without its default port", input: ConfigInput{ManagementURL: "https://api.netbird.io"}, want: false},
|
|
{name: "different management URL", input: ConfigInput{ManagementURL: "https://other.example:443"}, want: true},
|
|
{name: "same pre-shared key", input: ConfigInput{PreSharedKey: strPointer("stored-key")}, want: false},
|
|
{name: "redacted pre-shared key", input: ConfigInput{PreSharedKey: strPointer("**********")}, want: false},
|
|
{name: "different pre-shared key", input: ConfigInput{PreSharedKey: strPointer("other-key")}, want: true},
|
|
{name: "new interface blacklist entry", input: ConfigInput{ExtraIFaceBlackList: []string{"nb-probe0"}}, want: true},
|
|
{name: "blacklist entry already present", input: ConfigInput{ExtraIFaceBlackList: []string{"lo"}}, want: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := seededConfig(t)
|
|
|
|
changed, err := cfg.WouldChange(tt.input)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.want, changed)
|
|
})
|
|
}
|
|
}
|
|
|
|
// The dry run must not be observable on the config it is run against: it
|
|
// decides whether a write is allowed, it does not perform one.
|
|
func TestWouldChangeLeavesTheConfigAlone(t *testing.T) {
|
|
cfg := seededConfig(t)
|
|
blacklist := len(cfg.IFaceBlackList)
|
|
|
|
changed, err := cfg.WouldChange(ConfigInput{
|
|
ManagementURL: "https://other.example:443",
|
|
PreSharedKey: strPointer("other-key"),
|
|
ExtraIFaceBlackList: []string{"nb-probe0"},
|
|
DNSLabels: domain.FromPunycodeList([]string{"probe"}),
|
|
NATExternalIPs: []string{"1.2.3.4"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, changed)
|
|
|
|
require.Equal(t, "https://api.netbird.io:443", cfg.ManagementURL.String())
|
|
require.Equal(t, "stored-key", cfg.PreSharedKey)
|
|
require.Len(t, cfg.IFaceBlackList, blacklist)
|
|
require.Empty(t, cfg.DNSLabels)
|
|
require.Empty(t, cfg.NATExternalIPs)
|
|
}
|
|
|
|
// A nil config means the profile holds nothing yet, so the baseline is what
|
|
// the daemon would create for it.
|
|
func TestWouldChangeWithoutAStoredConfig(t *testing.T) {
|
|
var cfg *Config
|
|
|
|
changed, err := cfg.WouldChange(ConfigInput{})
|
|
require.NoError(t, err)
|
|
require.False(t, changed, "a request carrying nothing cannot change anything")
|
|
|
|
changed, err = cfg.WouldChange(ConfigInput{ManagementURL: DefaultManagementURL})
|
|
require.NoError(t, err)
|
|
require.False(t, changed, "the default management URL is what would be written anyway")
|
|
|
|
changed, err = cfg.WouldChange(ConfigInput{ManagementURL: "https://other.example:443"})
|
|
require.NoError(t, err)
|
|
require.True(t, changed)
|
|
}
|
|
|
|
func TestWouldChangeReportsAnInvalidInput(t *testing.T) {
|
|
cfg := seededConfig(t)
|
|
|
|
_, err := cfg.WouldChange(ConfigInput{ManagementURL: "not-a-url"})
|
|
require.Error(t, err)
|
|
}
|
|
|
|
// Reads must not write. A config file missing a field apply() fills in (MTU,
|
|
// here) is what used to trigger the write-back.
|
|
func TestReadsDoNotWriteTheConfigBack(t *testing.T) {
|
|
denormalized := []byte(`{"WgIface":"wt0"}`)
|
|
|
|
for name, read := range map[string]func(string) (*Config, error){
|
|
"GetExistingConfig": GetExistingConfig,
|
|
"ReadOrGenerateConfig": ReadOrGenerateConfig,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "profile.json")
|
|
require.NoError(t, os.WriteFile(path, denormalized, 0o600))
|
|
|
|
cfg, err := read(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, uint16(iface.DefaultMTU), cfg.MTU, "the returned config is still normalized in memory")
|
|
require.Empty(t, cfg.PrivateKey, "a read must not mint an identity either")
|
|
|
|
after, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, string(denormalized), string(after), "%s rewrote the config file", name)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ReadConfig resolves a default config for a profile that has no file yet, and
|
|
// that must not create the file either.
|
|
func TestReadConfigDoesNotCreateTheFile(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "absent.json")
|
|
|
|
cfg, err := ReadOrGenerateConfig(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
|
|
|
|
_, err = os.Stat(path)
|
|
require.True(t, os.IsNotExist(err), "ReadConfig created the config file")
|
|
}
|
|
|
|
// The identity is the one thing a read cannot recompute, so it is provisioned
|
|
// on request and its caller persists it.
|
|
func TestEnsureIdentity(t *testing.T) {
|
|
cfg := newConfigSkeleton()
|
|
|
|
generated, err := cfg.EnsureIdentity()
|
|
require.NoError(t, err)
|
|
require.True(t, generated)
|
|
require.NotEmpty(t, cfg.PrivateKey)
|
|
require.NotEmpty(t, cfg.SSHKey)
|
|
|
|
key := cfg.PrivateKey
|
|
generated, err = cfg.EnsureIdentity()
|
|
require.NoError(t, err)
|
|
require.False(t, generated, "a config that already has an identity keeps it")
|
|
require.Equal(t, key, cfg.PrivateKey)
|
|
}
|
|
|
|
// One endpoint written several ways is one endpoint. A gate that compared
|
|
// spellings refused a client restating its own management URL with a trailing
|
|
// slash, which is a normal way to write it.
|
|
func TestSameServiceURL(t *testing.T) {
|
|
tests := []struct {
|
|
a, b string
|
|
want bool
|
|
}{
|
|
{a: "https://mgmt.example.com", b: "https://mgmt.example.com:443", want: true},
|
|
{a: "https://mgmt.example.com", b: "https://mgmt.example.com/", want: true},
|
|
{a: "https://mgmt.example.com/", b: "https://mgmt.example.com:443/", want: true},
|
|
{a: "https://MGMT.example.com", b: "https://mgmt.example.com", want: true},
|
|
{a: "http://mgmt.example.com", b: "http://mgmt.example.com:80", want: true},
|
|
{a: "https://mgmt.example.com", b: "http://mgmt.example.com", want: false},
|
|
{a: "https://mgmt.example.com", b: "https://mgmt.example.com:8443", want: false},
|
|
{a: "https://mgmt.example.com", b: "https://other.example.com", want: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.a+" vs "+tt.b, func(t *testing.T) {
|
|
a, err := ParseServiceURL("a", tt.a)
|
|
require.NoError(t, err)
|
|
b, err := ParseServiceURL("b", tt.b)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, tt.want, SameServiceURL(a, b))
|
|
require.Equal(t, tt.want, SameServiceURL(b, a), "the comparison must be symmetric")
|
|
})
|
|
}
|
|
}
|
|
|
|
// The same spellings, through the dry run the update-settings gate uses.
|
|
func TestWouldChangeIgnoresURLSpelling(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "seeded.json")
|
|
_, err := UpdateOrCreateConfig(ConfigInput{
|
|
ConfigPath: path,
|
|
ManagementURL: "https://mgmt.example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
cfg, err := GetExistingConfig(path)
|
|
require.NoError(t, err)
|
|
|
|
for _, spelling := range []string{
|
|
"https://mgmt.example.com",
|
|
"https://mgmt.example.com/",
|
|
"https://mgmt.example.com:443",
|
|
"https://mgmt.example.com:443/",
|
|
"https://MGMT.example.com",
|
|
} {
|
|
changed, err := cfg.WouldChange(ConfigInput{ManagementURL: spelling})
|
|
require.NoError(t, err)
|
|
require.False(t, changed, "%q is the stored endpoint written differently", spelling)
|
|
}
|
|
|
|
changed, err := cfg.WouldChange(ConfigInput{ManagementURL: "https://mgmt.example.com:8443"})
|
|
require.NoError(t, err)
|
|
require.True(t, changed, "a different port is a different endpoint")
|
|
}
|
|
|
|
// The dry-run baseline exists to be compared against and discarded, so it must
|
|
// not mint keys — the CLI's login backoff loop would otherwise log a fresh
|
|
// "generated new Wireguard key" on every attempt.
|
|
func TestDryRunBaselineDoesNotGenerateKeys(t *testing.T) {
|
|
baseline, err := newDryRunBaseline(filepath.Join(t.TempDir(), "absent.json"))
|
|
require.NoError(t, err)
|
|
|
|
require.Empty(t, baseline.PrivateKey, "generated a WireGuard key for a throwaway config")
|
|
require.Empty(t, baseline.SSHKey, "generated an SSH key for a throwaway config")
|
|
|
|
// Everything the comparison actually looks at is still the default config.
|
|
require.Equal(t, DefaultManagementURL, baseline.ManagementURL.String())
|
|
require.Equal(t, uint16(iface.DefaultMTU), baseline.MTU)
|
|
require.Equal(t, iface.DefaultWgPort, baseline.WgPort)
|
|
}
|
|
|
|
// A stored profile can carry no identity — a mobile logout clears the keys in
|
|
// place — so the next config write has to mint one, which is what keeps the
|
|
// following login from dialing management with an empty key.
|
|
func TestUpdateConfigProvisionsAMissingIdentity(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "logged-out.json")
|
|
_, err := UpdateOrCreateConfig(ConfigInput{
|
|
ConfigPath: path,
|
|
ManagementURL: "https://api.netbird.io:443",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Stand in for the logout, which zeroes the keys and writes the config out.
|
|
loggedOut, err := GetExistingConfig(path)
|
|
require.NoError(t, err)
|
|
loggedOut.PrivateKey = ""
|
|
loggedOut.SSHKey = ""
|
|
require.NoError(t, WriteOutConfig(path, loggedOut))
|
|
|
|
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, cfg.PrivateKey, "the write path did not provision an identity")
|
|
require.NotEmpty(t, cfg.SSHKey)
|
|
|
|
persisted, err := GetExistingConfig(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, cfg.PrivateKey, persisted.PrivateKey, "the provisioned identity was not persisted")
|
|
}
|
|
|
|
// A config that carries no sync message version must not make the dry run
|
|
// panic: the gate runs inside a request handler, where failing closed is the
|
|
// worst acceptable outcome.
|
|
func TestWouldChangeWithoutAStoredSyncMessageVersion(t *testing.T) {
|
|
cfg := seededConfig(t)
|
|
require.Nil(t, cfg.SyncMessageVersion, "the fixture is only useful while the field starts out unset")
|
|
|
|
version := 2
|
|
changed, err := cfg.WouldChange(ConfigInput{SyncMessageVersion: &version})
|
|
require.NoError(t, err)
|
|
require.True(t, changed)
|
|
require.Nil(t, cfg.SyncMessageVersion, "the dry run set the version on the stored config")
|
|
}
|
|
|
|
// Restating the certificate paths a config already holds is not a change, for
|
|
// the same reason restating any other value is not.
|
|
func TestWouldChangeIgnoresRestatedCertificatePaths(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "mtls.json")
|
|
_, err := UpdateOrCreateConfig(ConfigInput{
|
|
ConfigPath: path,
|
|
ManagementURL: "https://api.netbird.io:443",
|
|
ClientCertPath: "/etc/netbird/client.crt",
|
|
ClientCertKeyPath: "/etc/netbird/client.key",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
cfg, err := GetExistingConfig(path)
|
|
require.NoError(t, err)
|
|
|
|
changed, err := cfg.WouldChange(ConfigInput{
|
|
ClientCertPath: "/etc/netbird/client.crt",
|
|
ClientCertKeyPath: "/etc/netbird/client.key",
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, changed, "the stored certificate paths were restated")
|
|
|
|
changed, err = cfg.WouldChange(ConfigInput{ClientCertPath: "/etc/netbird/other.crt"})
|
|
require.NoError(t, err)
|
|
require.True(t, changed, "a different certificate path is a change")
|
|
}
|
|
|
|
// A read that lands on a missing file must not hand back keys: nothing would
|
|
// write them down, so the caller would connect with an identity that changes on
|
|
// the next run and registers a second peer.
|
|
func TestReadOrGenerateConfigCarriesNoIdentity(t *testing.T) {
|
|
cfg, err := ReadOrGenerateConfig(filepath.Join(t.TempDir(), "absent.json"))
|
|
require.NoError(t, err)
|
|
|
|
require.Empty(t, cfg.PrivateKey, "a read minted a WireGuard key")
|
|
require.Empty(t, cfg.SSHKey, "a read minted an SSH key")
|
|
|
|
// So the caller's own EnsureIdentity is the one that reports the work, and
|
|
// therefore the one that triggers the write.
|
|
generated, err := cfg.EnsureIdentity()
|
|
require.NoError(t, err)
|
|
require.True(t, generated, "the provisioning caller could not tell it had to persist the identity")
|
|
}
|
|
|
|
// CreateInMemoryConfig is the opposite contract: its callers connect with what
|
|
// they get back, so it does carry an identity.
|
|
func TestCreateInMemoryConfigCarriesAnIdentity(t *testing.T) {
|
|
cfg, err := CreateInMemoryConfig(ConfigInput{ManagementURL: "https://api.netbird.io:443"})
|
|
require.NoError(t, err)
|
|
|
|
require.NotEmpty(t, cfg.PrivateKey)
|
|
require.NotEmpty(t, cfg.SSHKey)
|
|
}
|
|
|
|
// The admin panel is opened, not dialed, so its path identifies it. Comparing
|
|
// it as a bare endpoint left a custom panel URL unable to change.
|
|
func TestAdminURLPathIsPartOfTheIdentity(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "panel.json")
|
|
_, err := UpdateOrCreateConfig(ConfigInput{
|
|
ConfigPath: path,
|
|
AdminURL: "https://app.example.com/netbird",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
cfg, err := GetExistingConfig(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "https://app.example.com:443/netbird", cfg.AdminURL.String())
|
|
|
|
// Equivalent spellings of the same panel are still not a change.
|
|
for _, same := range []string{
|
|
"https://app.example.com/netbird",
|
|
"https://app.example.com:443/netbird",
|
|
"https://app.example.com/netbird/",
|
|
"https://APP.example.com/netbird",
|
|
} {
|
|
changed, err := cfg.WouldChange(ConfigInput{AdminURL: same})
|
|
require.NoError(t, err)
|
|
require.False(t, changed, "%q is the stored panel written differently", same)
|
|
}
|
|
|
|
// A different path is a different panel, and it must be persisted.
|
|
changed, err := cfg.WouldChange(ConfigInput{AdminURL: "https://app.example.com/other"})
|
|
require.NoError(t, err)
|
|
require.True(t, changed, "a different panel path is a change")
|
|
|
|
updated, err := UpdateConfig(ConfigInput{ConfigPath: path, AdminURL: "https://app.example.com/other"})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "https://app.example.com:443/other", updated.AdminURL.String(), "the new panel path was not persisted")
|
|
}
|
|
|
|
// unsetOnDisk rewrites the stored config so the named fields carry a JSON null,
|
|
// which is how a profile that was never asked about them looks on disk.
|
|
func unsetOnDisk(t *testing.T, path string, fields ...string) {
|
|
t.Helper()
|
|
|
|
raw, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
|
|
var stored map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(raw, &stored))
|
|
|
|
for _, field := range fields {
|
|
_, present := stored[field]
|
|
require.True(t, present, "%s is not a field of the stored config", field)
|
|
stored[field] = json.RawMessage("null")
|
|
}
|
|
|
|
rewritten, err := json.Marshal(stored)
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(path, rewritten, 0600))
|
|
}
|
|
|
|
// Seven fields mean "the effective default" when they hold no value, and the
|
|
// config a plain login writes leaves every one of them unset. Restating that
|
|
// default is asking for no change — and the CLI restates it on every `netbird
|
|
// up`, because a flag set through an environment variable is a flag pflag
|
|
// reports as Changed. Judging those restatements as changes made the
|
|
// update-settings gate refuse `netbird up` outright for a client configured
|
|
// through the environment, which is the shape of a Kubernetes deployment.
|
|
func TestWouldChangeIgnoresRestatedDefaultsOfUnsetFields(t *testing.T) {
|
|
networkMonitorDefault := runtime.GOOS == "windows" || runtime.GOOS == "darwin"
|
|
|
|
tests := []struct {
|
|
field string
|
|
theDefault ConfigInput
|
|
theOtherWay ConfigInput
|
|
}{
|
|
{"EnableSSHRoot",
|
|
ConfigInput{EnableSSHRoot: boolPtr(false)}, ConfigInput{EnableSSHRoot: boolPtr(true)}},
|
|
{"EnableSSHSFTP",
|
|
ConfigInput{EnableSSHSFTP: boolPtr(false)}, ConfigInput{EnableSSHSFTP: boolPtr(true)}},
|
|
{"EnableSSHLocalPortForwarding",
|
|
ConfigInput{EnableSSHLocalPortForwarding: boolPtr(false)}, ConfigInput{EnableSSHLocalPortForwarding: boolPtr(true)}},
|
|
{"EnableSSHRemotePortForwarding",
|
|
ConfigInput{EnableSSHRemotePortForwarding: boolPtr(false)}, ConfigInput{EnableSSHRemotePortForwarding: boolPtr(true)}},
|
|
{"DisableSSHAuth",
|
|
ConfigInput{DisableSSHAuth: boolPtr(false)}, ConfigInput{DisableSSHAuth: boolPtr(true)}},
|
|
{"SSHJWTCacheTTL",
|
|
ConfigInput{SSHJWTCacheTTL: intPtr(0)}, ConfigInput{SSHJWTCacheTTL: intPtr(300)}},
|
|
{"NetworkMonitor",
|
|
ConfigInput{NetworkMonitor: boolPtr(networkMonitorDefault)}, ConfigInput{NetworkMonitor: boolPtr(!networkMonitorDefault)}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.field, func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "unset.json")
|
|
_, err := UpdateOrCreateConfig(ConfigInput{
|
|
ConfigPath: path,
|
|
ManagementURL: "https://api.netbird.io:443",
|
|
})
|
|
require.NoError(t, err)
|
|
unsetOnDisk(t, path, tt.field)
|
|
|
|
cfg, err := GetExistingConfig(path)
|
|
require.NoError(t, err)
|
|
|
|
changed, err := cfg.WouldChange(tt.theDefault)
|
|
require.NoError(t, err)
|
|
require.False(t, changed, "restating the default of an unset %s was judged a change", tt.field)
|
|
|
|
// The gate still has to refuse a request that does ask for something.
|
|
changed, err = cfg.WouldChange(tt.theOtherWay)
|
|
require.NoError(t, err)
|
|
require.True(t, changed, "asking for a non-default %s is a change", tt.field)
|
|
})
|
|
}
|
|
}
|
|
|
|
// A zero-padded port addresses the same port.
|
|
func TestServiceURLPortIsNormalizedNumerically(t *testing.T) {
|
|
padded, err := ParseServiceURL("padded", "https://mgmt.example.com:0443")
|
|
require.NoError(t, err)
|
|
plain, err := ParseServiceURL("plain", "https://mgmt.example.com:443")
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, "443", ServiceURLPort(padded))
|
|
require.True(t, SameServiceURL(padded, plain))
|
|
}
|