diff --git a/deploy/upcloud/provision.go b/deploy/upcloud/provision.go index 83d54c0..9f204ef 100644 --- a/deploy/upcloud/provision.go +++ b/deploy/upcloud/provision.go @@ -256,6 +256,11 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string) error { saveState(state) } + // Always reconcile forwarded headers rule (handles existing LBs) + if err := ensureLBForwardedHeaders(ctx, svc, state.LB.UUID); err != nil { + return fmt.Errorf("LB forwarded headers: %w", err) + } + // Always reconcile TLS certs (handles partial failures and re-runs) tlsDomains := []string{cfg.BaseDomain} tlsDomains = append(tlsDomains, cfg.RegistryDomains...) @@ -566,6 +571,14 @@ func createLoadBalancer(ctx context.Context, svc *service.Service, cfg *InfraCon {Name: "public"}, }, Rules: []request.LoadBalancerFrontendRule{ + { + Name: "set-forwarded-headers", + Priority: 1, + Matchers: []upcloud.LoadBalancerMatcher{}, + Actions: []upcloud.LoadBalancerAction{ + request.NewLoadBalancerSetForwardedHeadersAction(), + }, + }, { Name: "route-hold", Priority: 10, @@ -720,6 +733,45 @@ func ensureLBCertificates(ctx context.Context, svc *service.Service, lbUUID stri return nil } +// ensureLBForwardedHeaders ensures the "https" frontend has a set_forwarded_headers rule. +// This makes the LB set X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Port headers, +// overwriting any pre-existing values (prevents spoofing). +func ensureLBForwardedHeaders(ctx context.Context, svc *service.Service, lbUUID string) error { + rules, err := svc.GetLoadBalancerFrontendRules(ctx, &request.GetLoadBalancerFrontendRulesRequest{ + ServiceUUID: lbUUID, + FrontendName: "https", + }) + if err != nil { + return fmt.Errorf("get frontend rules: %w", err) + } + + for _, r := range rules { + if r.Name == "set-forwarded-headers" { + fmt.Println(" Forwarded headers rule: exists") + return nil + } + } + + _, err = svc.CreateLoadBalancerFrontendRule(ctx, &request.CreateLoadBalancerFrontendRuleRequest{ + ServiceUUID: lbUUID, + FrontendName: "https", + Rule: request.LoadBalancerFrontendRule{ + Name: "set-forwarded-headers", + Priority: 1, + Matchers: []upcloud.LoadBalancerMatcher{}, + Actions: []upcloud.LoadBalancerAction{ + request.NewLoadBalancerSetForwardedHeadersAction(), + }, + }, + }) + if err != nil { + return fmt.Errorf("create forwarded headers rule: %w", err) + } + fmt.Println(" Forwarded headers rule: created") + + return nil +} + // lookupObjectStorage discovers details of an existing Managed Object Storage. func lookupObjectStorage(ctx context.Context, svc *service.Service, uuid string) (ObjectStorageState, error) { storage, err := svc.GetManagedObjectStorage(ctx, &request.GetManagedObjectStorageRequest{ diff --git a/pkg/appview/public/apple-touch-icon.png b/pkg/appview/public/apple-touch-icon.png index 97531ba..15e68ef 100644 Binary files a/pkg/appview/public/apple-touch-icon.png and b/pkg/appview/public/apple-touch-icon.png differ diff --git a/pkg/appview/public/atcr.png b/pkg/appview/public/atcr.png new file mode 100644 index 0000000..c4efe28 Binary files /dev/null and b/pkg/appview/public/atcr.png differ diff --git a/pkg/appview/public/favicon-96x96.png b/pkg/appview/public/favicon-96x96.png index 8b9ed2c..91a0aef 100644 Binary files a/pkg/appview/public/favicon-96x96.png and b/pkg/appview/public/favicon-96x96.png differ diff --git a/pkg/appview/public/favicon.ico b/pkg/appview/public/favicon.ico index a79a812..9e7c706 100644 Binary files a/pkg/appview/public/favicon.ico and b/pkg/appview/public/favicon.ico differ diff --git a/pkg/appview/public/favicon.svg b/pkg/appview/public/favicon.svg index 260e639..2083946 100644 --- a/pkg/appview/public/favicon.svg +++ b/pkg/appview/public/favicon.svg @@ -1,17 +1,78 @@ - - - - - - - - - - - - - @ + + + + + + + + + + + + + + - \ No newline at end of file + + diff --git a/pkg/appview/public/web-app-manifest-192x192.png b/pkg/appview/public/web-app-manifest-192x192.png index 51ff795..4497c0f 100644 Binary files a/pkg/appview/public/web-app-manifest-192x192.png and b/pkg/appview/public/web-app-manifest-192x192.png differ diff --git a/pkg/appview/public/web-app-manifest-512x512.png b/pkg/appview/public/web-app-manifest-512x512.png index 6dea5a7..f905866 100644 Binary files a/pkg/appview/public/web-app-manifest-512x512.png and b/pkg/appview/public/web-app-manifest-512x512.png differ diff --git a/pkg/appview/server.go b/pkg/appview/server.go index 6665d8b..c331c84 100644 --- a/pkg/appview/server.go +++ b/pkg/appview/server.go @@ -9,12 +9,14 @@ import ( "log/slog" "net" "net/http" + "net/url" "os" "os/signal" "strings" "syscall" "time" + "github.com/distribution/distribution/v3/registry/api/errcode" "github.com/distribution/distribution/v3/registry/handlers" "github.com/go-chi/chi/v5" chimiddleware "github.com/go-chi/chi/v5/middleware" @@ -239,10 +241,10 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, mainRouter.Use(chimiddleware.GetHead) mainRouter.Use(routes.CORSMiddleware()) - // Registry domain redirect middleware + // Domain routing middleware if len(cfg.Server.RegistryDomains) > 0 { - mainRouter.Use(RegistryDomainRedirect(cfg.Server.RegistryDomains, cfg.Server.BaseURL)) - slog.Info("Registry domain redirect enabled", + mainRouter.Use(DomainRoutingMiddleware(cfg.Server.RegistryDomains, cfg.Server.BaseURL)) + slog.Info("Domain routing middleware enabled", "registry_domains", cfg.Server.RegistryDomains, "ui_base_url", cfg.Server.BaseURL) } @@ -580,15 +582,29 @@ func (s *AppViewServer) createTokenIssuer() (*token.Issuer, error) { ) } -// RegistryDomainRedirect redirects all non-registry requests from registry -// domains to the UI domain. Only /v2 and /v2/* pass through for Docker clients. -// Uses 307 (Temporary Redirect) to preserve POST method/body. -func RegistryDomainRedirect(registryDomains []string, uiBaseURL string) func(http.Handler) http.Handler { - domains := make(map[string]bool, len(registryDomains)) +// DomainRoutingMiddleware enforces three-tier domain routing: +// +// 1. UI domain (BaseURL hostname): serves web UI, auth, and static assets. +// Blocks /v2/* with an OCI UNSUPPORTED error — registry API lives on +// the dedicated registry domain(s). +// 2. Registry domains: allows /v2/* for Docker clients. Redirects everything +// else to the UI domain with 307 Temporary Redirect. +// 3. Unknown domains (CDN origins, IPs, etc.): redirects all requests to the +// UI domain with 307, except /health for load balancer probes. +func DomainRoutingMiddleware(registryDomains []string, uiBaseURL string) func(http.Handler) http.Handler { + regDomains := make(map[string]bool, len(registryDomains)) for _, d := range registryDomains { - domains[d] = true + regDomains[d] = true } + // Extract UI hostname from BaseURL (e.g., "https://seamark.dev" -> "seamark.dev") + var uiHost string + if parsed, err := url.Parse(uiBaseURL); err == nil { + uiHost = parsed.Hostname() + } + + primaryReg := primaryRegistryDomain(registryDomains) + return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { host := r.Host @@ -596,19 +612,38 @@ func RegistryDomainRedirect(registryDomains []string, uiBaseURL string) func(htt host = host[:idx] } - if domains[host] { - path := r.URL.Path - if path == "/v2" || path == "/v2/" || strings.HasPrefix(path, "/v2/") { + path := r.URL.Path + isV2 := path == "/v2" || path == "/v2/" || strings.HasPrefix(path, "/v2/") + + switch { + case host == uiHost: + // UI domain: block /v2/*, serve everything else + if isV2 { + if err := errcode.ServeJSON(w, errcode.ErrorCodeUnsupported.WithMessage( + fmt.Sprintf("registry API is not available on this domain, use %s", primaryReg), + )); err != nil { + slog.Error("failed to write OCI error response", "error", err) + } + return + } + next.ServeHTTP(w, r) + + case regDomains[host]: + // Registry domain: allow /v2/*, redirect everything else + if isV2 { next.ServeHTTP(w, r) return } + http.Redirect(w, r, uiBaseURL+r.URL.RequestURI(), http.StatusTemporaryRedirect) - target := uiBaseURL + r.URL.RequestURI() - http.Redirect(w, r, target, http.StatusTemporaryRedirect) - return + default: + // Unknown domain: allow /health, redirect everything else + if path == "/health" { + next.ServeHTTP(w, r) + return + } + http.Redirect(w, r, uiBaseURL+r.URL.RequestURI(), http.StatusTemporaryRedirect) } - - next.ServeHTTP(w, r) }) } }