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 {