diff --git a/client/server/provision_identity_test.go b/client/server/provision_identity_test.go new file mode 100644 index 000000000..c944cdb1a --- /dev/null +++ b/client/server/provision_identity_test.go @@ -0,0 +1,59 @@ +package server + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +// The daemon provisions the peer's identity and persists it, because a key that +// stayed in memory would come back different on the next start and register a +// second peer. Provisioning is idempotent: a profile that already has an +// identity keeps the one on disk. +func TestProvisionProfileIdentity(t *testing.T) { + origDir := profilemanager.DefaultConfigPathDir + origPath := profilemanager.DefaultConfigPath + t.Cleanup(func() { + profilemanager.DefaultConfigPathDir = origDir + profilemanager.DefaultConfigPath = origPath + }) + + dir := t.TempDir() + profilemanager.DefaultConfigPathDir = dir + profilemanager.DefaultConfigPath = filepath.Join(dir, "default.json") + + activeProf := &profilemanager.ActiveProfileState{ID: "default"} + + t.Run("a profile with no file is provisioned and written", func(t *testing.T) { + _, err := os.Stat(profilemanager.DefaultConfigPath) + require.True(t, os.IsNotExist(err), "the fixture starts without a config file") + + config, existed, err := provisionProfileIdentity(activeProf) + require.NoError(t, err) + require.False(t, existed, "the file was reported as pre-existing") + require.NotEmpty(t, config.PrivateKey) + + stored, err := profilemanager.GetExistingConfig(profilemanager.DefaultConfigPath) + require.NoError(t, err, "provisioning did not write the config out") + require.Equal(t, config.PrivateKey, stored.PrivateKey, "the persisted identity is not the one returned") + require.NotEmpty(t, stored.SSHKey) + }) + + t.Run("a second call keeps the identity on disk", func(t *testing.T) { + before, err := profilemanager.GetExistingConfig(profilemanager.DefaultConfigPath) + require.NoError(t, err) + + config, existed, err := provisionProfileIdentity(activeProf) + require.NoError(t, err) + require.True(t, existed) + require.Equal(t, before.PrivateKey, config.PrivateKey, "provisioning minted a second identity") + + after, err := profilemanager.GetExistingConfig(profilemanager.DefaultConfigPath) + require.NoError(t, err) + require.Equal(t, before.PrivateKey, after.PrivateKey, "provisioning rewrote the stored identity") + }) +} diff --git a/client/server/server.go b/client/server/server.go index eb7d4aaa3..c57bbc6a3 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -1504,9 +1504,16 @@ func (s *Server) handleActiveProfileLogout(ctx context.Context) (*proto.LogoutRe return &proto.LogoutResponse{}, nil } -// getConfig resolves the active profile's config, provisions its identity and -// reports whether the config file already existed. -func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*profilemanager.Config, bool, error) { +// provisionProfileIdentity resolves the active profile's config and puts the +// keys that identify the peer on disk, reporting whether the config file +// already existed. +// +// This is the daemon's provisioning point: the config resolved here is the one +// the peer runs with, so it needs its identity, and that has to reach disk — a +// key that stays in memory would come back different on the next start and +// re-register the peer. Reads themselves are pure, so the write is here, in +// the open, instead of hiding inside the reader. +func provisionProfileIdentity(activeProf *profilemanager.ActiveProfileState) (*profilemanager.Config, bool, error) { cfgPath, err := activeProf.FilePath() if err != nil { return nil, false, fmt.Errorf("failed to get active profile file path: %w", err) @@ -1522,11 +1529,6 @@ func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*prof return nil, false, fmt.Errorf("failed to get config: %w", err) } - // This is the daemon's provisioning point: the config resolved here is the - // one the peer runs with, so it needs the keys that identify it, and those - // have to reach disk — a key that stays in memory would come back different - // on the next start and re-register the peer. Reads themselves are pure, so - // the write is here, in the open, instead of hiding inside ReadConfig. generated, err := config.EnsureIdentity() if err != nil { return nil, false, fmt.Errorf("ensure profile identity: %w", err) @@ -1538,6 +1540,17 @@ func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*prof } } + return config, configExisted, nil +} + +// getConfig resolves the active profile's config, provisions its identity and +// reports whether the config file already existed. +func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*profilemanager.Config, bool, error) { + config, configExisted, err := provisionProfileIdentity(activeProf) + if err != nil { + return nil, false, err + } + // Apply the daemon-owned MDM policy on top of the just-resolved Config. // profilemanager's apply() initialises the policy to empty — the Loader // lives outside Config, so this overlay step is driven externally here. @@ -2778,6 +2791,15 @@ func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto. return nil, nil, fmt.Errorf("persist login overrides: %w", err) } + // Provisioning under the same lock as the decision above, and next to the + // write it guards. getConfig would otherwise mint the identity and persist + // it once this returns: between its read and its write, a SetConfig that + // had already answered its caller would be overwritten by the config this + // login read before it landed. + if _, _, err := provisionProfileIdentity(activeProf); err != nil { + return nil, nil, err + } + return ctx, activeProf, nil }