Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion backend/modules/tenant/connectors/usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ 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)
TryHealDefaultDomain(ctx context.Context, host string) error
}

type TenantUsecase interface {
Expand Down
65 changes: 57 additions & 8 deletions backend/modules/tenant/usecase/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import (
"context"
"errors"
"fmt"
"net"
"strings"
"sync/atomic"

"github.com/google/uuid"

Expand All @@ -17,24 +20,22 @@ 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")

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 {
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
Expand All @@ -46,6 +47,9 @@ 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
}

Expand All @@ -56,12 +60,15 @@ func (u *bootstrapUsecase) EnsureDefaultTenant(ctx context.Context, adminEmail,
t := &domain.Tenant{
ID: defaultTenantID,
Name: defaultTenantName,
Domain: defaultTenantDomain,
Domain: tenantDomain,
Status: domain.StatusActive,
}
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 {
Expand All @@ -72,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
Expand Down
19 changes: 19 additions & 0 deletions backend/modules/tenant/usecase/bootstrap_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
24 changes: 24 additions & 0 deletions backend/pkg/http/middleware/self_heal_domain.go
Original file line number Diff line number Diff line change
@@ -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()
}
}
2 changes: 2 additions & 0 deletions backend/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading