mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-21 06:09:07 +02:00
[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:
@@ -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()
|
||||
|
||||
+26
-15
@@ -61,23 +61,34 @@ func (am *DefaultAccountManager) GetEvents(ctx context.Context, accountID, userI
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// StoreEvent records an activity, waiting for expiration events before cleanup can stop.
|
||||
func (am *DefaultAccountManager) StoreEvent(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
|
||||
if isEnabled() {
|
||||
go func() {
|
||||
_, err := am.eventStore.Save(ctx, &activity.Event{
|
||||
Timestamp: time.Now().UTC(),
|
||||
Activity: activityID.(activity.Activity),
|
||||
InitiatorID: initiatorID,
|
||||
TargetID: targetID,
|
||||
AccountID: accountID,
|
||||
Meta: meta,
|
||||
})
|
||||
if err != nil {
|
||||
// todo add metric
|
||||
log.WithContext(ctx).Errorf("received an error while storing an activity event, error: %s", err)
|
||||
}
|
||||
}()
|
||||
if !isEnabled() {
|
||||
return
|
||||
}
|
||||
eventStore := am.eventStore
|
||||
save := func(ctx context.Context) {
|
||||
_, err := eventStore.Save(ctx, &activity.Event{
|
||||
Timestamp: time.Now().UTC(),
|
||||
Activity: activityID.(activity.Activity),
|
||||
InitiatorID: initiatorID,
|
||||
TargetID: targetID,
|
||||
AccountID: accountID,
|
||||
Meta: meta,
|
||||
})
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("received an error while storing an activity event, error: %s", err)
|
||||
}
|
||||
}
|
||||
if activityID == activity.CustomDomainValidationExpired {
|
||||
// The domain is already deleted; shutdown must allow its audit write to finish.
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cancel()
|
||||
save(ctx)
|
||||
return
|
||||
}
|
||||
// Request cancellation must not discard the audit record of a completed operation.
|
||||
go save(context.WithoutCancel(ctx))
|
||||
}
|
||||
|
||||
type eventUserInfo struct {
|
||||
|
||||
@@ -6,10 +6,52 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
activitystore "github.com/netbirdio/netbird/management/server/activity/store"
|
||||
"github.com/netbirdio/netbird/util/crypt"
|
||||
)
|
||||
|
||||
func TestStoreEvent_CanceledContext(t *testing.T) {
|
||||
t.Setenv("NB_EVENT_ACTIVITY_LOG_ENABLED", "true")
|
||||
t.Setenv("NB_ACTIVITY_EVENT_STORE_ENGINE", "sqlite")
|
||||
for _, code := range []activity.Activity{activity.CustomDomainValidationExpired, activity.DomainAdded} {
|
||||
t.Run(code.StringCode(), func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
key, err := crypt.GenerateKey()
|
||||
require.NoError(t, err)
|
||||
eventStore, err := activitystore.NewSqlStore(context.Background(), dir, key)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { assert.NoError(t, eventStore.Close(context.Background())) })
|
||||
manager := &DefaultAccountManager{eventStore: eventStore}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
// The operation already succeeded when shutdown or the request cancels its context.
|
||||
manager.StoreEvent(ctx, activity.SystemInitiator, "domain-id", "account-id",
|
||||
code, map[string]any{"domain": "expired.example.com"})
|
||||
if code != activity.CustomDomainValidationExpired {
|
||||
require.Eventually(t, func() bool {
|
||||
events, err := eventStore.Get(context.Background(), "account-id", 0, 10, true)
|
||||
return err == nil && len(events) == 1
|
||||
}, time.Second, time.Millisecond, "asynchronous events must survive request cancellation")
|
||||
}
|
||||
require.NoError(t, eventStore.Close(context.Background()))
|
||||
|
||||
reopened, err := activitystore.NewSqlStore(context.Background(), dir, key)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { assert.NoError(t, reopened.Close(context.Background())) })
|
||||
events, err := reopened.Get(context.Background(), "account-id", 0, 10, true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, events, 1, "the event must be persisted before shutdown closes the store")
|
||||
assert.Equal(t, code, events[0].Activity, "persist the requested activity")
|
||||
assert.Equal(t, "domain-id", events[0].TargetID, "retain the registration ID")
|
||||
assert.Equal(t, "expired.example.com", events[0].Meta["domain"], "retain the domain name")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func generateAndStoreEvents(t *testing.T, manager *DefaultAccountManager, typ activity.Activity, initiatorID, targetID,
|
||||
accountID string, count int) {
|
||||
t.Helper()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/http/testing/testing_tools"
|
||||
"github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel"
|
||||
@@ -34,7 +36,7 @@ func Test_Events_GetAll(t *testing.T) {
|
||||
|
||||
for _, user := range users {
|
||||
t.Run(user.name+" - Get all events", func(t *testing.T) {
|
||||
apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/events.sql", nil, false)
|
||||
apiHandler, accountManager, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/events.sql", nil, false)
|
||||
|
||||
// First, perform a mutation to generate an event (create a group as admin)
|
||||
groupBody, err := json.Marshal(&api.GroupRequest{Name: "eventTestGroup"})
|
||||
@@ -44,7 +46,14 @@ func Test_Events_GetAll(t *testing.T) {
|
||||
createReq := testing_tools.BuildRequest(t, groupBody, http.MethodPost, "/api/groups", testing_tools.TestAdminId)
|
||||
createRecorder := httptest.NewRecorder()
|
||||
apiHandler.ServeHTTP(createRecorder, createReq)
|
||||
assert.Equal(t, http.StatusOK, createRecorder.Code, "Failed to create group to generate event")
|
||||
require.Equal(t, http.StatusOK, createRecorder.Code, "Failed to create group to generate event")
|
||||
|
||||
// Group creation returns before its asynchronous audit write finishes.
|
||||
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
events, err := accountManager.GetEvents(context.Background(), testing_tools.TestAccountId, testing_tools.TestAdminId)
|
||||
assert.NoError(c, err)
|
||||
assert.NotEmpty(c, events, "wait for the group creation event before checking permissions")
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
// Now query events
|
||||
req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/events", user.userId)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
)
|
||||
|
||||
// MigrateCustomDomainValidationExpiry gives existing pending registrations a validation window.
|
||||
func MigrateCustomDomainValidationExpiry(ctx context.Context, db *gorm.DB) error {
|
||||
result := db.WithContext(ctx).Model(&domain.Domain{}).
|
||||
Where("validated = ? AND validation_expires_at IS NULL", false).
|
||||
Update("validation_expires_at", time.Now().UTC().Add(domain.ValidationTTL))
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("backfill custom domain validation expiry: %w", result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package migration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
"github.com/netbirdio/netbird/management/server/migration"
|
||||
)
|
||||
|
||||
func TestMigrateCustomDomainValidationExpiry(t *testing.T) {
|
||||
db := setupDatabase(t)
|
||||
require.NoError(t, db.AutoMigrate(&domain.Domain{}))
|
||||
t.Cleanup(func() { require.NoError(t, db.Migrator().DropTable(&domain.Domain{})) })
|
||||
ctx := context.Background()
|
||||
existingDeadline := time.Now().UTC().Add(time.Hour).Truncate(time.Second)
|
||||
rows := []domain.Domain{
|
||||
{ID: "legacy", Domain: "legacy.example.com"},
|
||||
{ID: "validated", Domain: "validated.example.com", Validated: true},
|
||||
{ID: "pending", Domain: "pending.example.com", ValidationExpiresAt: &existingDeadline},
|
||||
}
|
||||
require.NoError(t, db.Create(&rows).Error)
|
||||
before := time.Now().UTC()
|
||||
require.NoError(t, migration.MigrateCustomDomainValidationExpiry(ctx, db))
|
||||
after := time.Now().UTC()
|
||||
var migrated domain.Domain
|
||||
require.NoError(t, db.First(&migrated, "id = ?", "legacy").Error)
|
||||
require.NotNil(t, migrated.ValidationExpiresAt)
|
||||
assert.WithinRange(t, *migrated.ValidationExpiresAt, before.Truncate(time.Millisecond).Add(48*time.Hour), after.Add(48*time.Hour+time.Millisecond), "legacy pending registrations get a full window")
|
||||
deadline := *migrated.ValidationExpiresAt
|
||||
require.NoError(t, migration.MigrateCustomDomainValidationExpiry(ctx, db))
|
||||
require.NoError(t, db.First(&migrated, "id = ?", "legacy").Error)
|
||||
assert.Equal(t, deadline, *migrated.ValidationExpiresAt, "repeated migration must not extend the deadline")
|
||||
var validated, pending domain.Domain
|
||||
require.NoError(t, db.First(&validated, "id = ?", "validated").Error)
|
||||
require.NoError(t, db.First(&pending, "id = ?", "pending").Error)
|
||||
assert.Nil(t, validated.ValidationExpiresAt, "validated domains do not acquire an expiry")
|
||||
require.NotNil(t, pending.ValidationExpiresAt)
|
||||
assert.WithinDuration(t, existingDeadline, *pending.ValidationExpiresAt, 0, "existing deadlines must be preserved")
|
||||
}
|
||||
@@ -5712,6 +5712,10 @@ func (s *SqlStore) CreateCustomDomain(ctx context.Context, accountID string, dom
|
||||
Type: domain.TypeCustom,
|
||||
Validated: validated,
|
||||
}
|
||||
if !validated {
|
||||
expiresAt := time.Now().UTC().Add(domain.ValidationTTL)
|
||||
newDomain.ValidationExpiresAt = &expiresAt
|
||||
}
|
||||
result := s.db.Create(newDomain)
|
||||
if result.Error != nil {
|
||||
// The unique index is the last guard when two requests clear the
|
||||
@@ -5733,12 +5737,21 @@ func (s *SqlStore) CreateCustomDomain(ctx context.Context, accountID string, dom
|
||||
return newDomain, nil
|
||||
}
|
||||
|
||||
// UpdateCustomDomain completes validation only while the original registration is pending.
|
||||
func (s *SqlStore) UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error) {
|
||||
d.AccountID = accountID
|
||||
result := s.db.Select("*").Save(d)
|
||||
if !d.Validated {
|
||||
return nil, status.Errorf(status.InvalidArgument, "custom domain update must complete validation")
|
||||
}
|
||||
result := s.db.WithContext(ctx).Model(&domain.Domain{}).
|
||||
Where(accountAndIDQueryCondition, accountID, d.ID).
|
||||
Where("domain = ? AND target_cluster = ?", d.Domain, d.TargetCluster).
|
||||
Where("validated = ? AND validation_expires_at > ?", false, time.Now().UTC()).
|
||||
Update("validated", true)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to update reverse proxy custom domain to store: %v", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to update reverse proxy custom domain to store")
|
||||
return nil, fmt.Errorf("validate custom domain in store: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil, status.Errorf(status.PreconditionFailed, "custom domain registration is no longer pending validation")
|
||||
}
|
||||
|
||||
return d, nil
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// GetExpiredCustomDomains lists pending registrations in stable batches across accounts.
|
||||
func (s *SqlStore) GetExpiredCustomDomains(ctx context.Context, now time.Time, afterID domain.ID, limit int) ([]*domain.Domain, error) {
|
||||
var domains []*domain.Domain
|
||||
result := s.db.WithContext(ctx).
|
||||
Where("validated = ? AND validation_expires_at <= ? AND id > ?", false, now, string(afterID)).
|
||||
Order("id").Limit(limit).Find(&domains)
|
||||
if result.Error != nil {
|
||||
return nil, fmt.Errorf("list expired custom domains: %w", result.Error)
|
||||
}
|
||||
return domains, nil
|
||||
}
|
||||
|
||||
// DeleteExpiredCustomDomain deletes an expired registration only if no service uses its namespace.
|
||||
func (s *SqlStore) DeleteExpiredCustomDomain(ctx context.Context, d *domain.Domain, now time.Time) (bool, error) {
|
||||
db := s.db.WithContext(ctx)
|
||||
services := customDomainServices(db, d)
|
||||
result := db.Where(accountAndIDQueryCondition, d.AccountID, d.ID).
|
||||
Where("domain = ? AND validated = ? AND validation_expires_at <= ?", d.Domain, false, now).
|
||||
Where("NOT EXISTS (?)", services.Select("1")).Delete(&domain.Domain{})
|
||||
if result.Error != nil {
|
||||
return false, fmt.Errorf("delete expired custom domain: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
return true, nil
|
||||
}
|
||||
var count int64
|
||||
if err := customDomainServices(db, d).Count(&count).Error; err != nil {
|
||||
return false, fmt.Errorf("check expired custom domain services: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return false, status.Errorf(status.PreconditionFailed, "expired custom domain still has dependent services")
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func customDomainServices(db *gorm.DB, d *domain.Domain) *gorm.DB {
|
||||
name := strings.ToLower(strings.TrimSuffix(d.Domain, "."))
|
||||
// Shared domain validation permits underscores, and older rows may contain
|
||||
// other LIKE metacharacters.
|
||||
escaped := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_").Replace(name)
|
||||
return db.Model(&rpservice.Service{}).Where(
|
||||
"LOWER(domain) IN ? OR LOWER(domain) LIKE ? ESCAPE '!' OR LOWER(domain) LIKE ? ESCAPE '!'",
|
||||
[]string{name, name + "."}, "%."+escaped, "%."+escaped+".",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
)
|
||||
|
||||
func TestDeleteExpiredCustomDomain_ServiceDependencies(t *testing.T) {
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
db := store.(*SqlStore).db
|
||||
require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, "owner", "admin", "")))
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
domainName string
|
||||
serviceHost string
|
||||
protected bool
|
||||
}{
|
||||
{"exact", "example.com", "example.com", true},
|
||||
{"subdomain", "example.com", "deep.app.example.com", true},
|
||||
{"case", "example.com", "APP.EXAMPLE.COM.", true},
|
||||
{"suffix-boundary", "example.com", "notexample.com", false},
|
||||
{"literal underscore", "a_b.example.com", "app.a_b.example.com", true},
|
||||
{"underscore wildcard", "a_b.example.com", "app.axb.example.com", false},
|
||||
{"legacy percent wildcard", "a%b.example.com", "app.axxb.example.com", false},
|
||||
{"legacy escape character", "a!b.example.com", "app.ab.example.com", false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
d, err := store.CreateCustomDomain(ctx, "owner", tt.domainName, "cluster", false)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Model(d).Update("validation_expires_at", now.Add(-time.Hour)).Error)
|
||||
svc := &rpservice.Service{ID: "legacy", AccountID: "owner", Domain: tt.serviceHost}
|
||||
require.NoError(t, store.CreateService(ctx, svc))
|
||||
deleted, err := store.DeleteExpiredCustomDomain(ctx, d, now)
|
||||
if tt.protected {
|
||||
require.Error(t, err)
|
||||
assert.False(t, deleted, "service namespaces must remain reserved")
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.True(t, deleted, "a hostname outside the namespace must not prevent cleanup")
|
||||
}
|
||||
require.NoError(t, db.Delete(svc).Error)
|
||||
require.NoError(t, db.Delete(d).Error)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteExpiredCustomDomain_RechecksValidation(t *testing.T) {
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
ctx := context.Background()
|
||||
require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, "owner", "admin", "")))
|
||||
d, err := store.CreateCustomDomain(ctx, "owner", "validated.example.com", "cluster", false)
|
||||
require.NoError(t, err)
|
||||
d, err = store.GetCustomDomain(ctx, "owner", d.ID)
|
||||
require.NoError(t, err)
|
||||
stale := d.Copy()
|
||||
d.Validated = true
|
||||
_, err = store.UpdateCustomDomain(ctx, "owner", d)
|
||||
require.NoError(t, err)
|
||||
deleted, err := store.DeleteExpiredCustomDomain(ctx, stale, time.Now().Add(domain.ValidationTTL))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, deleted, "a stale cleanup candidate must not delete a validated registration")
|
||||
stored, err := store.GetCustomDomain(ctx, "owner", d.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, stored.Validated, "the validated registration must remain usable")
|
||||
require.NotNil(t, stored.ValidationExpiresAt)
|
||||
assert.Equal(t, stale.ValidationExpiresAt, stored.ValidationExpiresAt, "validation must preserve the original deadline")
|
||||
})
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func assertGetAccountLoadsCustomDomains(t *testing.T, store Store) {
|
||||
|
||||
_, err := store.CreateCustomDomain(ctx, accountID, "example.com", "eu.proxy.netbird.io", true)
|
||||
require.NoError(t, err, "creating the first custom domain must succeed")
|
||||
_, err = store.CreateCustomDomain(ctx, accountID, "apps.acme.io", "us.proxy.netbird.io", false)
|
||||
pending, err := store.CreateCustomDomain(ctx, accountID, "apps.acme.io", "us.proxy.netbird.io", false)
|
||||
require.NoError(t, err, "creating the second custom domain must succeed")
|
||||
|
||||
account, err := store.GetAccount(ctx, accountID)
|
||||
@@ -75,6 +75,10 @@ func assertGetAccountLoadsCustomDomains(t *testing.T, store Store) {
|
||||
for _, d := range account.Domains {
|
||||
require.NotNil(t, d)
|
||||
byDomain[d.Domain] = d.TargetCluster
|
||||
if d.ID == pending.ID {
|
||||
require.NotNil(t, d.ValidationExpiresAt)
|
||||
assert.WithinDuration(t, *pending.ValidationExpiresAt, *d.ValidationExpiresAt, time.Millisecond, "both account loaders must preserve the validation deadline")
|
||||
}
|
||||
}
|
||||
assert.Equal(t, "eu.proxy.netbird.io", byDomain["example.com"], "custom domain must carry its target cluster")
|
||||
assert.Equal(t, "us.proxy.netbird.io", byDomain["apps.acme.io"], "custom domain must carry its target cluster")
|
||||
|
||||
@@ -305,6 +305,8 @@ type Store interface {
|
||||
GetCustomDomainByName(ctx context.Context, domainName string) (*domain.Domain, error)
|
||||
CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error)
|
||||
UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error)
|
||||
GetExpiredCustomDomains(ctx context.Context, now time.Time, afterID domain.ID, limit int) ([]*domain.Domain, error)
|
||||
DeleteExpiredCustomDomain(ctx context.Context, d *domain.Domain, now time.Time) (bool, error)
|
||||
DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error
|
||||
|
||||
CreateAccessLog(ctx context.Context, log *accesslogs.AccessLogEntry) error
|
||||
@@ -642,6 +644,9 @@ func migratePostAuto(ctx context.Context, db *gorm.DB) error {
|
||||
|
||||
func getMigrationsPostAuto(ctx context.Context) []migrationFunc {
|
||||
return []migrationFunc{
|
||||
func(db *gorm.DB) error {
|
||||
return migration.MigrateCustomDomainValidationExpiry(ctx, db)
|
||||
},
|
||||
func(db *gorm.DB) error {
|
||||
return migration.CreateIndexIfNotExists[nbpeer.Peer](ctx, db, "idx_account_ip", "account_id", "ip")
|
||||
},
|
||||
|
||||
@@ -555,6 +555,21 @@ func (mr *MockStoreMockRecorder) DeleteDNSRecord(ctx, accountID, zoneID, recordI
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteDNSRecord", reflect.TypeOf((*MockStore)(nil).DeleteDNSRecord), ctx, accountID, zoneID, recordID)
|
||||
}
|
||||
|
||||
// DeleteExpiredCustomDomain mocks base method.
|
||||
func (m *MockStore) DeleteExpiredCustomDomain(ctx context.Context, d *domain.Domain, now time.Time) (bool, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteExpiredCustomDomain", ctx, d, now)
|
||||
ret0, _ := ret[0].(bool)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// DeleteExpiredCustomDomain indicates an expected call of DeleteExpiredCustomDomain.
|
||||
func (mr *MockStoreMockRecorder) DeleteExpiredCustomDomain(ctx, d, now any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteExpiredCustomDomain", reflect.TypeOf((*MockStore)(nil).DeleteExpiredCustomDomain), ctx, d, now)
|
||||
}
|
||||
|
||||
// DeleteGroup mocks base method.
|
||||
func (m *MockStore) DeleteGroup(ctx context.Context, accountID, groupID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -2002,6 +2017,21 @@ func (mr *MockStoreMockRecorder) GetEmbeddedProxyPeerIDsByCluster(ctx, accountID
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedProxyPeerIDsByCluster", reflect.TypeOf((*MockStore)(nil).GetEmbeddedProxyPeerIDsByCluster), ctx, accountID)
|
||||
}
|
||||
|
||||
// GetExpiredCustomDomains mocks base method.
|
||||
func (m *MockStore) GetExpiredCustomDomains(ctx context.Context, now time.Time, afterID domain.ID, limit int) ([]*domain.Domain, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetExpiredCustomDomains", ctx, now, afterID, limit)
|
||||
ret0, _ := ret[0].([]*domain.Domain)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetExpiredCustomDomains indicates an expected call of GetExpiredCustomDomains.
|
||||
func (mr *MockStoreMockRecorder) GetExpiredCustomDomains(ctx, now, afterID, limit any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExpiredCustomDomains", reflect.TypeOf((*MockStore)(nil).GetExpiredCustomDomains), ctx, now, afterID, limit)
|
||||
}
|
||||
|
||||
// GetExpiredEphemeralServices mocks base method.
|
||||
func (m *MockStore) GetExpiredEphemeralServices(ctx context.Context, ttl time.Duration, limit int) ([]*service.Service, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Reference in New Issue
Block a user