mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-12 17:59:06 +02:00
Three places in one request path each had their own notion of "same management URL": the config layer compared the parsed URLs as strings, the privileged-change gate compared scheme + host + effective port, and the MDM conflict check compared strings after filling in the default port. Only the middle one was right. A string comparison answers the wrong question. "https://api.netbird.io", "https://api.netbird.io/" and "https://API.netbird.io:443" are one endpoint written three ways, so a client restating its own management URL with a trailing slash — a normal way to write it — was still read as a client asking to be repointed, and the update-settings gate refused it. The MDM check had the same flaw against the enforced value. profilemanager.SameServiceURL is now the single comparison: same scheme, same host case-insensitively as DNS names are, same effective port. The config layer, the privileged-change gate and the MDM conflict check all defer to it, so there is one answer to "did this URL change?" instead of three.
271 lines
10 KiB
Go
271 lines
10 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc/codes"
|
|
gstatus "google.golang.org/grpc/status"
|
|
|
|
"github.com/netbirdio/netbird/client/internal"
|
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
"github.com/netbirdio/netbird/client/proto"
|
|
)
|
|
|
|
// The seeded profile of setupServerWithProfile is created with this management
|
|
// URL, so a request carrying it restates what the profile already holds.
|
|
const storedManagementURL = "https://api.netbird.io:443"
|
|
|
|
// A client configured by environment re-sends its whole configuration on every
|
|
// `netbird up`: the CLI fills the request from its flags and env regardless of
|
|
// what changed. With the update-settings kill switch on, such a request must
|
|
// pass — nothing about the configuration moves.
|
|
func TestSetConfig_RestatingTheStoredConfigPassesTheGate(t *testing.T) {
|
|
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
|
s.updateSettingsDisabled = true
|
|
|
|
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
|
ProfileName: profName,
|
|
Username: username,
|
|
ManagementUrl: storedManagementURL,
|
|
})
|
|
require.NoError(t, err, "restating the stored management URL is not a settings change")
|
|
}
|
|
|
|
// The same endpoint written without its default port is the same endpoint. A
|
|
// gate that compared raw strings refused NB_MANAGEMENT_URL=https://host, which
|
|
// is how the URL is normally spelled.
|
|
func TestSetConfig_EquivalentManagementURLPassesTheGate(t *testing.T) {
|
|
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
|
s.updateSettingsDisabled = true
|
|
|
|
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
|
ProfileName: profName,
|
|
Username: username,
|
|
ManagementUrl: "https://api.netbird.io",
|
|
})
|
|
require.NoError(t, err, "an implicit :443 is the same management URL")
|
|
}
|
|
|
|
// The kill switch still has to do its job: a request that moves a setting is
|
|
// refused, and the profile keeps the value it had.
|
|
func TestSetConfig_ChangingASettingIsRefused(t *testing.T) {
|
|
s, ctx, profName, username, cfgPath := setupServerWithProfile(t)
|
|
s.updateSettingsDisabled = true
|
|
|
|
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
|
ProfileName: profName,
|
|
Username: username,
|
|
ManagementUrl: "https://mgmt.elsewhere.example:443",
|
|
})
|
|
require.Error(t, err, "moving the management URL is a settings change")
|
|
require.Equal(t, codes.Unavailable, gstatus.Code(err), "want the update-settings refusal, got %v", err)
|
|
|
|
cfg, err := profilemanager.GetConfig(cfgPath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, storedManagementURL, cfg.ManagementURL.String(), "the refused request changed the config anyway")
|
|
}
|
|
|
|
// A field whose requested value differs from the stored one is a change even
|
|
// when the rest of the request restates the configuration.
|
|
func TestSetConfig_SingleDivergingFieldIsRefused(t *testing.T) {
|
|
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
|
s.updateSettingsDisabled = true
|
|
|
|
rosenpass := true
|
|
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
|
ProfileName: profName,
|
|
Username: username,
|
|
ManagementUrl: storedManagementURL,
|
|
RosenpassEnabled: &rosenpass,
|
|
})
|
|
require.Error(t, err, "enabling Rosenpass is a settings change")
|
|
require.Equal(t, codes.Unavailable, gstatus.Code(err), "want the update-settings refusal, got %v", err)
|
|
}
|
|
|
|
// With the switch off, the same diverging request goes through: the gate must
|
|
// not leak into a daemon that never enabled it.
|
|
func TestSetConfig_ChangeAllowedWhenTheSwitchIsOff(t *testing.T) {
|
|
s, ctx, profName, username, cfgPath := setupServerWithProfile(t)
|
|
|
|
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
|
ProfileName: profName,
|
|
Username: username,
|
|
ManagementUrl: "https://mgmt.elsewhere.example:443",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
cfg, err := profilemanager.GetConfig(cfgPath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "https://mgmt.elsewhere.example:443", cfg.ManagementURL.String())
|
|
}
|
|
|
|
// Login carries the same config surface as SetConfig, so it is gated the same
|
|
// way: a login that would move a protected setting is refused before it can
|
|
// touch daemon state.
|
|
func TestLogin_ChangingTheManagementURLIsRefused(t *testing.T) {
|
|
s, _, _, username, _ := setupServerWithProfile(t)
|
|
s.updateSettingsDisabled = true
|
|
s.rootCtx = internal.CtxInitState(context.Background())
|
|
|
|
_, err := s.Login(userCtx(), &proto.LoginRequest{
|
|
Username: &username,
|
|
ManagementUrl: "https://mgmt.elsewhere.example:443",
|
|
})
|
|
require.Error(t, err, "moving the management URL through Login is a settings change")
|
|
require.Equal(t, codes.Unavailable, gstatus.Code(err), "want the update-settings refusal, got %v", err)
|
|
}
|
|
|
|
// seedProfileConfig writes a profile config carrying the given management URL
|
|
// and pre-shared key into a temp dir, and returns its path.
|
|
func seedProfileConfig(t *testing.T, managementURL, preSharedKey string) string {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(t.TempDir(), "seeded.json")
|
|
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
|
ConfigPath: path,
|
|
ManagementURL: managementURL,
|
|
PreSharedKey: &preSharedKey,
|
|
})
|
|
require.NoError(t, err, "seed profile config")
|
|
return path
|
|
}
|
|
|
|
// The decision procedure itself, over the fields a login actually persists.
|
|
// A login that restates the stored values must not be refused: that is what
|
|
// keeps a re-login, or a container restart carrying NB_MANAGEMENT_URL, working
|
|
// with the kill switch on.
|
|
func TestLoginGateDecision(t *testing.T) {
|
|
stored, err := profilemanager.GetConfig(seedProfileConfig(t, storedManagementURL, "stored-key"))
|
|
require.NoError(t, err)
|
|
|
|
redacted := preSharedKeyRedactedSentinel
|
|
empty := ""
|
|
sameKey := "stored-key"
|
|
otherKey := "other-key"
|
|
|
|
tests := []struct {
|
|
name string
|
|
msg *proto.LoginRequest
|
|
wantChanged bool
|
|
}{
|
|
{
|
|
name: "pure auth carries no config",
|
|
msg: &proto.LoginRequest{SetupKey: "ABC"},
|
|
wantChanged: false,
|
|
},
|
|
{
|
|
name: "stored management URL restated",
|
|
msg: &proto.LoginRequest{ManagementUrl: storedManagementURL},
|
|
wantChanged: false,
|
|
},
|
|
{
|
|
name: "stored management URL without its default port",
|
|
msg: &proto.LoginRequest{ManagementUrl: "https://api.netbird.io"},
|
|
wantChanged: false,
|
|
},
|
|
{
|
|
name: "different management URL",
|
|
msg: &proto.LoginRequest{ManagementUrl: "https://mgmt.elsewhere.example:443"},
|
|
wantChanged: true,
|
|
},
|
|
{
|
|
name: "stored pre-shared key restated",
|
|
msg: &proto.LoginRequest{OptionalPreSharedKey: &sameKey},
|
|
wantChanged: false,
|
|
},
|
|
{
|
|
name: "redacted pre-shared key echoed back",
|
|
msg: &proto.LoginRequest{OptionalPreSharedKey: &redacted},
|
|
wantChanged: false,
|
|
},
|
|
{
|
|
name: "empty pre-shared key is not a request to clear it",
|
|
msg: &proto.LoginRequest{OptionalPreSharedKey: &empty},
|
|
wantChanged: false,
|
|
},
|
|
{
|
|
name: "different pre-shared key",
|
|
msg: &proto.LoginRequest{OptionalPreSharedKey: &otherKey},
|
|
wantChanged: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
require.Equal(t, tt.wantChanged, configChangeRequested(stored, loginOverridesInput(tt.msg)))
|
|
})
|
|
}
|
|
}
|
|
|
|
// A profile with no config on disk yet is judged against the config the daemon
|
|
// would create for it, so a first login that asks for the defaults is not a
|
|
// change while one that asks for a different management URL is.
|
|
func TestGateDecisionWithoutStoredConfig(t *testing.T) {
|
|
require.False(t, configChangeRequested(nil, profilemanager.ConfigInput{}),
|
|
"a request carrying nothing cannot change anything")
|
|
require.False(t, configChangeRequested(nil, profilemanager.ConfigInput{ManagementURL: profilemanager.DefaultManagementURL}),
|
|
"asking for the default management URL is what the daemon would write anyway")
|
|
require.True(t, configChangeRequested(nil, profilemanager.ConfigInput{ManagementURL: "https://mgmt.elsewhere.example:443"}),
|
|
"asking for a non-default management URL is a change")
|
|
}
|
|
|
|
// A dry run that cannot be evaluated must fail closed, or a malformed field
|
|
// would open the gate.
|
|
func TestGateDecisionFailsClosedOnAnInvalidRequest(t *testing.T) {
|
|
require.True(t, configChangeRequested(nil, profilemanager.ConfigInput{ManagementURL: "not-a-url"}),
|
|
"an unevaluable request must count as a change")
|
|
}
|
|
|
|
// The gate reads the stored config to decide, and reading it must not write it:
|
|
// a refused request has to leave the profile file byte-for-byte as it was.
|
|
// A config file missing a field the config layer fills in (MTU, here) is what
|
|
// makes the normalization write fire.
|
|
func TestSetConfig_RefusedRequestLeavesTheConfigFileUntouched(t *testing.T) {
|
|
s, ctx, profName, username, cfgPath := setupServerWithProfile(t)
|
|
s.updateSettingsDisabled = true
|
|
|
|
require.NoError(t, os.WriteFile(cfgPath, []byte(`{"WgIface":"wt0"}`), 0o600))
|
|
before, err := os.ReadFile(cfgPath)
|
|
require.NoError(t, err)
|
|
|
|
_, err = s.SetConfig(ctx, &proto.SetConfigRequest{
|
|
ProfileName: profName,
|
|
Username: username,
|
|
ManagementUrl: "https://mgmt.elsewhere.example:443",
|
|
})
|
|
require.Error(t, err)
|
|
require.Equal(t, codes.Unavailable, gstatus.Code(err), "want the update-settings refusal, got %v", err)
|
|
|
|
after, err := os.ReadFile(cfgPath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, string(before), string(after), "the refused request rewrote the profile config")
|
|
}
|
|
|
|
// The container case that the string comparison still broke: the management URL
|
|
// supplied through the environment is the stored one, written with a trailing
|
|
// slash.
|
|
func TestSetConfig_ManagementURLSpellingsPassTheGate(t *testing.T) {
|
|
for _, spelling := range []string{
|
|
"https://api.netbird.io",
|
|
"https://api.netbird.io/",
|
|
"https://api.netbird.io:443/",
|
|
"https://API.netbird.io:443",
|
|
} {
|
|
t.Run(spelling, func(t *testing.T) {
|
|
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
|
s.updateSettingsDisabled = true
|
|
|
|
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
|
ProfileName: profName,
|
|
Username: username,
|
|
ManagementUrl: spelling,
|
|
})
|
|
require.NoError(t, err, "%q is the stored management URL written differently", spelling)
|
|
})
|
|
}
|
|
}
|