[management] Expire unvalidated custom domain registrations (#7497)

Prevent unvalidated registrations from reserving domain names indefinitely.

Give pending registrations a 48-hour validation window and clean up expired entries at startup and every 60 minutes. Emit CustomDomainValidationExpired for each deletion and preserve registrations referenced by services.

Reject validation after expiry and prevent concurrent validation from recreating deleted registrations. Normalize domain names with the shared parser before registration.

Migrate existing pending registrations to receive a fresh 48-hour validation window.
This commit is contained in:
Maycon Santos
2026-09-11 17:57:58 +02:00
committed by GitHub
parent ec0c36b0e7
commit b789ffbb9f
23 changed files with 941 additions and 60 deletions
+7 -3
View File
@@ -284,6 +284,9 @@ const (
// AgentNetworkSettingsDeleted indicates that a user deleted the Agent Network account settings, releasing the endpoint
AgentNetworkSettingsDeleted Activity = 142
// CustomDomainValidationExpired indicates that an unvalidated domain registration expired.
CustomDomainValidationExpired Activity = 143
AccountDeleted Activity = 99999
)
@@ -461,9 +464,10 @@ var activityMap = map[Activity]Code{
AccountMetricsPushEnabled: {"Account metrics push enabled", "account.setting.metrics.push.enable"},
AccountMetricsPushDisabled: {"Account metrics push disabled", "account.setting.metrics.push.disable"},
DomainAdded: {"Domain added", "domain.add"},
DomainDeleted: {"Domain deleted", "domain.delete"},
DomainValidated: {"Domain validated", "domain.validate"},
DomainAdded: {"Domain added", "domain.add"},
DomainDeleted: {"Domain deleted", "domain.delete"},
DomainValidated: {"Domain validated", "domain.validate"},
CustomDomainValidationExpired: {"Unvalidated domain registration expired", "domain.validation.expire"},
}
// StringCode returns a string code of the activity
@@ -165,16 +165,16 @@ func (store *Store) Get(ctx context.Context, accountID string, offset, limit int
return store.processResult(ctx, events)
}
// Save an event in the SQLite events table end encrypt the "email" element in meta map
func (store *Store) Save(_ context.Context, event *activity.Event) (*activity.Event, error) {
// Save persists an activity event and encrypts deleted user details using the caller's context.
func (store *Store) Save(ctx context.Context, event *activity.Event) (*activity.Event, error) {
eventCopy := event.Copy()
meta, err := store.saveDeletedUserEmailAndNameInEncrypted(eventCopy)
meta, err := store.saveDeletedUserEmailAndNameInEncrypted(ctx, eventCopy)
if err != nil {
return nil, err
}
eventCopy.Meta = meta
if err = store.db.Create(eventCopy).Error; err != nil {
if err = store.db.WithContext(ctx).Create(eventCopy).Error; err != nil {
return nil, err
}
@@ -183,7 +183,7 @@ func (store *Store) Save(_ context.Context, event *activity.Event) (*activity.Ev
// saveDeletedUserEmailAndNameInEncrypted if the meta contains email and name then store it in encrypted way and delete
// this item from meta map
func (store *Store) saveDeletedUserEmailAndNameInEncrypted(event *activity.Event) (map[string]any, error) {
func (store *Store) saveDeletedUserEmailAndNameInEncrypted(ctx context.Context, event *activity.Event) (map[string]any, error) {
email, ok := event.Meta["email"]
if !ok {
return event.Meta, nil
@@ -211,7 +211,7 @@ func (store *Store) saveDeletedUserEmailAndNameInEncrypted(event *activity.Event
}
deletedUser.Name = encryptedName
err = store.db.Clauses(clause.OnConflict{
err = store.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{"email", "name"}),
}).Create(deletedUser).Error
@@ -7,11 +7,49 @@ import (
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/util/crypt"
)
func TestSave_CancellationWhileWaitingForConnection(t *testing.T) {
t.Setenv(storeEngineEnv, "sqlite")
key, err := crypt.GenerateKey()
require.NoError(t, err)
store, err := NewSqlStore(context.Background(), t.TempDir(), key)
require.NoError(t, err)
t.Cleanup(func() { assert.NoError(t, store.Close(context.Background())) })
db, err := store.db.DB()
require.NoError(t, err)
conn, err := db.Conn(context.Background())
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
result := make(chan error, 1)
go func() {
_, err := store.Save(ctx, &activity.Event{
Timestamp: time.Now().UTC(), Activity: activity.CustomDomainValidationExpired,
AccountID: "account-id", TargetID: "domain-id", InitiatorID: activity.SystemInitiator,
})
result <- err
}()
select {
case err := <-result:
assert.ErrorIs(t, err, context.DeadlineExceeded)
require.NoError(t, conn.Close())
case <-time.After(time.Second):
// Release the connection so a regression cannot leave the writer running.
require.NoError(t, conn.Close())
assert.ErrorIs(t, <-result, context.DeadlineExceeded)
t.Error("activity writes must stop waiting when their deadline expires")
}
events, err := store.Get(context.Background(), "account-id", 0, 10, true)
require.NoError(t, err)
assert.Empty(t, events, "a timed-out write must not persist after the connection is released")
}
func TestNewSqlStore(t *testing.T) {
dataDir := t.TempDir()
key, _ := crypt.GenerateKey()