Skip to content

Commit 5276ccd

Browse files
feat[backend](tenant): self-heal default tenant domain from first request host
1 parent 6a96fb7 commit 5276ccd

5 files changed

Lines changed: 100 additions & 8 deletions

File tree

‎backend/modules/tenant/connectors/usecase.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type UserProvisioner interface {
2626

2727
type BootstrapUsecase interface {
2828
EnsureDefaultTenant(ctx context.Context, adminEmail, adminPassword, domain string) (created bool, err error)
29+
TryHealDefaultDomain(ctx context.Context, host string) error
2930
}
3031

3132
type TenantUsecase interface {

‎backend/modules/tenant/usecase/bootstrap.go‎

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"net"
8+
"strings"
9+
"sync/atomic"
710

811
"github.com/google/uuid"
912

@@ -22,12 +25,10 @@ const defaultTenantName = "UTMStack"
2225
var ErrBootstrapPasswordRequired = errors.New(
2326
"UTMSTACK_ADMIN_PASSWORD is required to create the initial administrator")
2427

25-
var ErrBootstrapDomainRequired = errors.New(
26-
"UTMSTACK_DEFAULT_DOMAIN is required to create the default tenant")
27-
2828
type bootstrapUsecase struct {
29-
repo connectors.TenantRepository
30-
admin connectors.UserProvisioner
29+
repo connectors.TenantRepository
30+
admin connectors.UserProvisioner
31+
healed atomic.Bool
3132
}
3233

3334
func NewBootstrapUsecase(repo connectors.TenantRepository, admin connectors.UserProvisioner) connectors.BootstrapUsecase {
@@ -46,15 +47,15 @@ func (u *bootstrapUsecase) EnsureDefaultTenant(ctx context.Context, adminEmail,
4647
return false, fmt.Errorf("looking up the default tenant: %w", err)
4748
}
4849
if existing != nil {
50+
if existing.Domain != "" {
51+
u.healed.Store(true)
52+
}
4953
return false, nil
5054
}
5155

5256
if adminPassword == "" {
5357
return false, ErrBootstrapPasswordRequired
5458
}
55-
if tenantDomain == "" {
56-
return false, ErrBootstrapDomainRequired
57-
}
5859

5960
t := &domain.Tenant{
6061
ID: defaultTenantID,
@@ -65,6 +66,9 @@ func (u *bootstrapUsecase) EnsureDefaultTenant(ctx context.Context, adminEmail,
6566
if err := u.repo.Create(all, t); err != nil {
6667
return false, fmt.Errorf("creating the default tenant: %w", err)
6768
}
69+
if tenantDomain != "" {
70+
u.healed.Store(true)
71+
}
6872

6973
if err := provisionAdmin(ctx, u.admin, t.ID, adminEmail, adminPassword, false); err != nil {
7074
if delErr := u.repo.Delete(all, t.ID); delErr != nil {
@@ -75,6 +79,48 @@ func (u *bootstrapUsecase) EnsureDefaultTenant(ctx context.Context, adminEmail,
7579
return true, nil
7680
}
7781

82+
// TryHealDefaultDomain stamps the default tenant's Domain from the first
83+
// request's Host when UTMSTACK_DEFAULT_DOMAIN was not set at install time.
84+
// Runs at most once per process (atomic fast path), and no-ops once the row
85+
// already has a domain.
86+
func (u *bootstrapUsecase) TryHealDefaultDomain(ctx context.Context, host string) error {
87+
if u.healed.Load() {
88+
return nil
89+
}
90+
host = normalizeHealHost(host)
91+
if host == "" {
92+
return nil
93+
}
94+
all := tenancy.WithAllTenants(ctx)
95+
t, err := u.repo.FindByID(all, defaultTenantID)
96+
if err != nil || t == nil {
97+
return err
98+
}
99+
if t.Domain != "" {
100+
u.healed.Store(true)
101+
return nil
102+
}
103+
t.Domain = host
104+
if err := u.repo.Update(all, t); err != nil {
105+
return err
106+
}
107+
u.healed.Store(true)
108+
return nil
109+
}
110+
111+
func normalizeHealHost(raw string) string {
112+
// X-Forwarded-Host may carry a comma-separated chain; the first hop is the
113+
// original client-facing hostname.
114+
if i := strings.IndexByte(raw, ','); i >= 0 {
115+
raw = raw[:i]
116+
}
117+
raw = strings.TrimSpace(raw)
118+
if h, _, err := net.SplitHostPort(raw); err == nil {
119+
raw = h
120+
}
121+
return strings.ToLower(raw)
122+
}
123+
78124
func provisionAdmin(ctx context.Context, admin connectors.UserProvisioner, tenantID uuid.UUID, email, password string, invite bool) error {
79125
if admin == nil {
80126
return nil
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package usecase
2+
3+
import "testing"
4+
5+
func TestNormalizeHealHost(t *testing.T) {
6+
cases := map[string]string{
7+
"UTM.example.com": "utm.example.com",
8+
"utm.example.com:8443": "utm.example.com",
9+
"utm.customer.com, proxy.internal": "utm.customer.com",
10+
" utm.example.com ": "utm.example.com",
11+
"": "",
12+
"[2001:db8::1]:443": "2001:db8::1",
13+
}
14+
for in, want := range cases {
15+
if got := normalizeHealHost(in); got != want {
16+
t.Errorf("normalizeHealHost(%q) = %q, want %q", in, got, want)
17+
}
18+
}
19+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package middleware
2+
3+
import (
4+
"context"
5+
6+
"github.com/gin-gonic/gin"
7+
)
8+
9+
// SelfHealDefaultDomain fills the default tenant's Domain from the first
10+
// request that reaches the API when it was left blank at install time.
11+
// The heal callback owns fast-path skipping — this middleware just hands it
12+
// the best hostname it can see (proxy-forwarded first, direct Host next).
13+
func SelfHealDefaultDomain(heal func(ctx context.Context, host string) error) gin.HandlerFunc {
14+
return func(c *gin.Context) {
15+
if heal != nil {
16+
host := c.Request.Header.Get("X-Forwarded-Host")
17+
if host == "" {
18+
host = c.Request.Host
19+
}
20+
_ = heal(c.Request.Context(), host)
21+
}
22+
c.Next()
23+
}
24+
}

‎backend/server.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ func registerRoutes(engine *gin.Engine, m *modules, cfg *config) {
150150
})
151151
platform := middleware.RequirePlatform()
152152

153+
api.Use(middleware.SelfHealDefaultDomain(m.tenant.GetBootstrapUsecase().TryHealDefaultDomain))
154+
153155
api.Use(middleware.ResolveTenant(
154156
func() bool { return m.billing.License().Current().IsMSSP() },
155157
cfg.internalKey,

0 commit comments

Comments
 (0)