From 610ec68299a245c83e552fde1d7a26c5f59adab3 Mon Sep 17 00:00:00 2001 From: nfebe Date: Thu, 16 Jul 2026 23:58:06 +0100 Subject: [PATCH] feat(nginx): Route hostnames whose TLS is terminated upstream A hostname fronted by an external proxy such as Cloudflare could not reach the deployment behind it. The proxied request arrives with that hostname as its Host, which matched no configured server name and fell to the unknown-domain catch-all. Adding it as an alias did route it, but aliases are also submitted for certificate issuance, which fails because the hostname's DNS points at the external proxy rather than at FlatRun, so there was no way to accept a hostname for routing while its certificate is handled elsewhere. A hostname can now be marked routing-only. It routes to the deployment and shares the primary domain's certificate over the external proxy's SNI, and it is kept out of certificate issuance and renewal so a request it cannot satisfy is never made. Its traffic counts as a known domain rather than unknown. --- internal/api/traffic_handlers.go | 7 +++++++ internal/nginx/manager.go | 7 +++++++ internal/nginx/manager_test.go | 28 +++++++++++++++++++++++++ internal/ssl/manager_test.go | 36 ++++++++++++++++++++++++++++++++ pkg/models/deployment.go | 9 ++++++++ 5 files changed, 87 insertions(+) diff --git a/internal/api/traffic_handlers.go b/internal/api/traffic_handlers.go index 8af5c30..f5699fd 100644 --- a/internal/api/traffic_handlers.go +++ b/internal/api/traffic_handlers.go @@ -159,9 +159,16 @@ func (s *Server) getUnknownDomainStats(c *gin.Context) { return } + // Traffic is logged under the request's Host, so a configured hostname (a + // domain, an alias, or a routing-only alias) is "known" even though it is not + // the deployment name. Without this, routing-only hostnames and aliases would + // be reported as unknown-domain traffic. var knownDeployments []string for _, d := range deployments { knownDeployments = append(knownDeployments, d.Name) + if d.Metadata != nil { + knownDeployments = append(knownDeployments, d.Metadata.GetUniqueDomainNames()...) + } } stats, err := s.trafficManager.GetUnknownDomainStats(knownDeployments, since) diff --git a/internal/nginx/manager.go b/internal/nginx/manager.go index ee60f4c..e994fb4 100644 --- a/internal/nginx/manager.go +++ b/internal/nginx/manager.go @@ -783,6 +783,13 @@ func (m *Manager) groupDomainsByHost(domains []models.DomainConfig, deploymentNa serverAliases = append(serverAliases, alias) } } + // Routing-only hostnames share the primary's server block and cert; they + // route by Host but are never issued a certificate of their own. + for _, alias := range d.RouteOnlyAliases { + if alias != host { + serverAliases = append(serverAliases, alias) + } + } } servers = append(servers, serverData{ diff --git a/internal/nginx/manager_test.go b/internal/nginx/manager_test.go index a324f70..ce97f16 100644 --- a/internal/nginx/manager_test.go +++ b/internal/nginx/manager_test.go @@ -2260,6 +2260,34 @@ services: } } +// A routing-only alias must appear in server_name so requests for it route to +// the deployment; it shares the primary domain's server block and certificate. +func TestRouteOnlyAliasEmittedIntoServerName(t *testing.T) { + m := NewManager(&config.NginxConfig{ContainerWebrootPath: "/var/www/html"}, "/deployments", "") + deployment := &models.Deployment{ + Name: "shop", + Metadata: &models.ServiceMetadata{ + Domains: []models.DomainConfig{ + { + ID: "d1", + Service: "web", + ContainerPort: 80, + Domain: "api.example.com", + RouteOnlyAliases: []string{"dashboard.example.com"}, + }, + }, + }, + } + + config, err := m.generateMultiDomainConfig(deployment) + if err != nil { + t.Fatalf("generateMultiDomainConfig failed: %v", err) + } + if !strings.Contains(config, "dashboard.example.com") { + t.Errorf("routing-only alias must be emitted into server_name, got:\n%s", config) + } +} + func newManagerWithDeployment(t *testing.T, domains []models.DomainConfig, compose string) (*Manager, *models.Deployment) { t.Helper() dir := t.TempDir() diff --git a/internal/ssl/manager_test.go b/internal/ssl/manager_test.go index dcbc2b7..9d69cd9 100644 --- a/internal/ssl/manager_test.go +++ b/internal/ssl/manager_test.go @@ -173,6 +173,42 @@ func TestGetDomainsNeedingCertificates_AutoCertWithDisabledSSL(t *testing.T) { } } +func TestGetDomainsNeedingCertificates_ExcludesRouteOnlyAliases(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "ssl-cert-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + m := NewManager(&config.CertbotConfig{CertsPath: tmpDir}, tmpDir, nil) + + domains := []models.DomainConfig{ + { + Domain: "api.example.com", + SSL: models.SSLConfig{Enabled: true, AutoCert: true}, + Aliases: []string{"www.example.com"}, + RouteOnlyAliases: []string{"dashboard.example.com"}, + }, + } + + got := m.GetDomainsNeedingCertificates(domains) + has := func(name string) bool { + for _, d := range got { + if d == name { + return true + } + } + return false + } + + if !has("api.example.com") || !has("www.example.com") { + t.Errorf("expected the domain and its cert-bearing alias, got %v", got) + } + if has("dashboard.example.com") { + t.Errorf("routing-only alias must be excluded from certificate issuance, got %v", got) + } +} + func TestGetDomainsNeedingCertificates_SkipsWithoutAutoCert(t *testing.T) { tmpDir, err := os.MkdirTemp("", "ssl-cert-test-*") if err != nil { diff --git a/pkg/models/deployment.go b/pkg/models/deployment.go index c937ec4..f41f467 100644 --- a/pkg/models/deployment.go +++ b/pkg/models/deployment.go @@ -54,6 +54,12 @@ type DomainConfig struct { StripPrefix bool `yaml:"strip_prefix,omitempty" json:"strip_prefix,omitempty"` SSL SSLConfig `yaml:"ssl" json:"ssl"` Aliases []string `yaml:"aliases,omitempty" json:"aliases,omitempty"` + // RouteOnlyAliases are extra hostnames routed to this deployment but excluded + // from certificate issuance and renewal, for a hostname whose TLS is + // terminated by an external proxy (e.g. Cloudflare) whose DNS does not point + // at FlatRun. They share the primary domain's certificate over the proxy's + // SNI; issuing a certificate for them would fail ACME validation. + RouteOnlyAliases []string `yaml:"route_only_aliases,omitempty" json:"route_only_aliases,omitempty"` // ProxyTimeout is the proxy read/send timeout in seconds. Defaults to 60 // when unset; raise it for domains that proxy long-lived WebSocket // connections so idle sockets are not closed mid-connection. @@ -115,6 +121,9 @@ func (m *ServiceMetadata) GetUniqueDomainNames() []string { for _, alias := range d.Aliases { domainSet[alias] = struct{}{} } + for _, alias := range d.RouteOnlyAliases { + domainSet[alias] = struct{}{} + } } result := make([]string, 0, len(domainSet)) for name := range domainSet {