From 6a96fb785dc20b06a016f502532128829cf9c432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 24 Aug 2026 08:40:34 -0600 Subject: [PATCH 1/2] feat[backend](tenant): source default tenant domain from UTMSTACK_DEFAULT_DOMAIN --- backend/main.go | 4 +++- backend/modules/tenant/connectors/usecase.go | 2 +- backend/modules/tenant/usecase/bootstrap.go | 15 +++++++++------ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/backend/main.go b/backend/main.go index 350b45bed..8c35afcbe 100644 --- a/backend/main.go +++ b/backend/main.go @@ -40,7 +40,9 @@ func main() { adminEmail := env.String("UTMSTACK_ADMIN_EMAIL", "admin", false) created, err := modules.tenant.GetBootstrapUsecase().EnsureDefaultTenant( - appCtx, adminEmail, env.String("UTMSTACK_ADMIN_PASSWORD", "", false)) + appCtx, adminEmail, + env.String("UTMSTACK_ADMIN_PASSWORD", "", false), + env.String("UTMSTACK_DEFAULT_DOMAIN", "", false)) if err != nil { _ = catcher.Error("failed to create the default tenant", err, nil) panic(err) diff --git a/backend/modules/tenant/connectors/usecase.go b/backend/modules/tenant/connectors/usecase.go index a3466c70d..cac25f6f9 100644 --- a/backend/modules/tenant/connectors/usecase.go +++ b/backend/modules/tenant/connectors/usecase.go @@ -25,7 +25,7 @@ type UserProvisioner interface { } type BootstrapUsecase interface { - EnsureDefaultTenant(ctx context.Context, adminEmail, adminPassword string) (created bool, err error) + EnsureDefaultTenant(ctx context.Context, adminEmail, adminPassword, domain string) (created bool, err error) } type TenantUsecase interface { diff --git a/backend/modules/tenant/usecase/bootstrap.go b/backend/modules/tenant/usecase/bootstrap.go index 8975c5754..b995cb104 100644 --- a/backend/modules/tenant/usecase/bootstrap.go +++ b/backend/modules/tenant/usecase/bootstrap.go @@ -17,14 +17,14 @@ import ( var defaultTenantID = uuid.MustParse(authz.DefaultTenantID) -const ( - defaultTenantName = "UTMStack" - defaultTenantDomain = "localhost" -) +const defaultTenantName = "UTMStack" var ErrBootstrapPasswordRequired = errors.New( "UTMSTACK_ADMIN_PASSWORD is required to create the initial administrator") +var ErrBootstrapDomainRequired = errors.New( + "UTMSTACK_DEFAULT_DOMAIN is required to create the default tenant") + type bootstrapUsecase struct { repo connectors.TenantRepository admin connectors.UserProvisioner @@ -34,7 +34,7 @@ func NewBootstrapUsecase(repo connectors.TenantRepository, admin connectors.User return &bootstrapUsecase{repo: repo, admin: admin} } -func (u *bootstrapUsecase) EnsureDefaultTenant(ctx context.Context, adminEmail, adminPassword string) (bool, error) { +func (u *bootstrapUsecase) EnsureDefaultTenant(ctx context.Context, adminEmail, adminPassword, tenantDomain string) (bool, error) { // Only the tenant table is read and written across tenants. The // administrator is created on the plain context so the tenancy callback // still stamps it: a context that spans every tenant belongs to none, and @@ -52,11 +52,14 @@ func (u *bootstrapUsecase) EnsureDefaultTenant(ctx context.Context, adminEmail, if adminPassword == "" { return false, ErrBootstrapPasswordRequired } + if tenantDomain == "" { + return false, ErrBootstrapDomainRequired + } t := &domain.Tenant{ ID: defaultTenantID, Name: defaultTenantName, - Domain: defaultTenantDomain, + Domain: tenantDomain, Status: domain.StatusActive, } if err := u.repo.Create(all, t); err != nil { From 5276ccd0d7bbe867e496e36c970ddb10dbe40038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 24 Aug 2026 09:00:07 -0600 Subject: [PATCH 2/2] feat[backend](tenant): self-heal default tenant domain from first request host --- backend/modules/tenant/connectors/usecase.go | 1 + backend/modules/tenant/usecase/bootstrap.go | 62 ++++++++++++++++--- .../modules/tenant/usecase/bootstrap_test.go | 19 ++++++ .../pkg/http/middleware/self_heal_domain.go | 24 +++++++ backend/server.go | 2 + 5 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 backend/modules/tenant/usecase/bootstrap_test.go create mode 100644 backend/pkg/http/middleware/self_heal_domain.go diff --git a/backend/modules/tenant/connectors/usecase.go b/backend/modules/tenant/connectors/usecase.go index cac25f6f9..c4ff8c494 100644 --- a/backend/modules/tenant/connectors/usecase.go +++ b/backend/modules/tenant/connectors/usecase.go @@ -26,6 +26,7 @@ type UserProvisioner interface { type BootstrapUsecase interface { EnsureDefaultTenant(ctx context.Context, adminEmail, adminPassword, domain string) (created bool, err error) + TryHealDefaultDomain(ctx context.Context, host string) error } type TenantUsecase interface { diff --git a/backend/modules/tenant/usecase/bootstrap.go b/backend/modules/tenant/usecase/bootstrap.go index b995cb104..9a9c5f763 100644 --- a/backend/modules/tenant/usecase/bootstrap.go +++ b/backend/modules/tenant/usecase/bootstrap.go @@ -4,6 +4,9 @@ import ( "context" "errors" "fmt" + "net" + "strings" + "sync/atomic" "github.com/google/uuid" @@ -22,12 +25,10 @@ const defaultTenantName = "UTMStack" var ErrBootstrapPasswordRequired = errors.New( "UTMSTACK_ADMIN_PASSWORD is required to create the initial administrator") -var ErrBootstrapDomainRequired = errors.New( - "UTMSTACK_DEFAULT_DOMAIN is required to create the default tenant") - type bootstrapUsecase struct { - repo connectors.TenantRepository - admin connectors.UserProvisioner + repo connectors.TenantRepository + admin connectors.UserProvisioner + healed atomic.Bool } func NewBootstrapUsecase(repo connectors.TenantRepository, admin connectors.UserProvisioner) connectors.BootstrapUsecase { @@ -46,15 +47,15 @@ func (u *bootstrapUsecase) EnsureDefaultTenant(ctx context.Context, adminEmail, return false, fmt.Errorf("looking up the default tenant: %w", err) } if existing != nil { + if existing.Domain != "" { + u.healed.Store(true) + } return false, nil } if adminPassword == "" { return false, ErrBootstrapPasswordRequired } - if tenantDomain == "" { - return false, ErrBootstrapDomainRequired - } t := &domain.Tenant{ ID: defaultTenantID, @@ -65,6 +66,9 @@ func (u *bootstrapUsecase) EnsureDefaultTenant(ctx context.Context, adminEmail, if err := u.repo.Create(all, t); err != nil { return false, fmt.Errorf("creating the default tenant: %w", err) } + if tenantDomain != "" { + u.healed.Store(true) + } if err := provisionAdmin(ctx, u.admin, t.ID, adminEmail, adminPassword, false); err != nil { if delErr := u.repo.Delete(all, t.ID); delErr != nil { @@ -75,6 +79,48 @@ func (u *bootstrapUsecase) EnsureDefaultTenant(ctx context.Context, adminEmail, return true, nil } +// TryHealDefaultDomain stamps the default tenant's Domain from the first +// request's Host when UTMSTACK_DEFAULT_DOMAIN was not set at install time. +// Runs at most once per process (atomic fast path), and no-ops once the row +// already has a domain. +func (u *bootstrapUsecase) TryHealDefaultDomain(ctx context.Context, host string) error { + if u.healed.Load() { + return nil + } + host = normalizeHealHost(host) + if host == "" { + return nil + } + all := tenancy.WithAllTenants(ctx) + t, err := u.repo.FindByID(all, defaultTenantID) + if err != nil || t == nil { + return err + } + if t.Domain != "" { + u.healed.Store(true) + return nil + } + t.Domain = host + if err := u.repo.Update(all, t); err != nil { + return err + } + u.healed.Store(true) + return nil +} + +func normalizeHealHost(raw string) string { + // X-Forwarded-Host may carry a comma-separated chain; the first hop is the + // original client-facing hostname. + if i := strings.IndexByte(raw, ','); i >= 0 { + raw = raw[:i] + } + raw = strings.TrimSpace(raw) + if h, _, err := net.SplitHostPort(raw); err == nil { + raw = h + } + return strings.ToLower(raw) +} + func provisionAdmin(ctx context.Context, admin connectors.UserProvisioner, tenantID uuid.UUID, email, password string, invite bool) error { if admin == nil { return nil diff --git a/backend/modules/tenant/usecase/bootstrap_test.go b/backend/modules/tenant/usecase/bootstrap_test.go new file mode 100644 index 000000000..328d81a84 --- /dev/null +++ b/backend/modules/tenant/usecase/bootstrap_test.go @@ -0,0 +1,19 @@ +package usecase + +import "testing" + +func TestNormalizeHealHost(t *testing.T) { + cases := map[string]string{ + "UTM.example.com": "utm.example.com", + "utm.example.com:8443": "utm.example.com", + "utm.customer.com, proxy.internal": "utm.customer.com", + " utm.example.com ": "utm.example.com", + "": "", + "[2001:db8::1]:443": "2001:db8::1", + } + for in, want := range cases { + if got := normalizeHealHost(in); got != want { + t.Errorf("normalizeHealHost(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/backend/pkg/http/middleware/self_heal_domain.go b/backend/pkg/http/middleware/self_heal_domain.go new file mode 100644 index 000000000..56301c7fd --- /dev/null +++ b/backend/pkg/http/middleware/self_heal_domain.go @@ -0,0 +1,24 @@ +package middleware + +import ( + "context" + + "github.com/gin-gonic/gin" +) + +// SelfHealDefaultDomain fills the default tenant's Domain from the first +// request that reaches the API when it was left blank at install time. +// The heal callback owns fast-path skipping — this middleware just hands it +// the best hostname it can see (proxy-forwarded first, direct Host next). +func SelfHealDefaultDomain(heal func(ctx context.Context, host string) error) gin.HandlerFunc { + return func(c *gin.Context) { + if heal != nil { + host := c.Request.Header.Get("X-Forwarded-Host") + if host == "" { + host = c.Request.Host + } + _ = heal(c.Request.Context(), host) + } + c.Next() + } +} diff --git a/backend/server.go b/backend/server.go index c75ca1a28..5242d7e27 100644 --- a/backend/server.go +++ b/backend/server.go @@ -150,6 +150,8 @@ func registerRoutes(engine *gin.Engine, m *modules, cfg *config) { }) platform := middleware.RequirePlatform() + api.Use(middleware.SelfHealDefaultDomain(m.tenant.GetBootstrapUsecase().TryHealDefaultDomain)) + api.Use(middleware.ResolveTenant( func() bool { return m.billing.License().Current().IsMSSP() }, cfg.internalKey,