Skip to content

Commit 6c4018a

Browse files
committed
fix(ingress): deduplicate TLS hostnames to allow same hostname on multiple ports
When multiple ingress rules use the same hostname pattern (e.g., wildcard `*.example.com`) on different ports, Caddy would fail with: "cannot apply more than one automation policy to host: *.example.com" This happened because each TLS rule added the hostname to the automation policy subjects, causing duplicates. The fix deduplicates TLS hostnames before generating the Caddy config, allowing use cases like: - `{instance}.host.kernel.sh:443` → instance:80 - `{instance}.host.kernel.sh:3000` → instance:3000 - `{instance}.host.kernel.sh:8080` → instance:8080 All sharing the same wildcard TLS certificate.
1 parent 0444b86 commit 6c4018a

File tree

2 files changed

+147
-1
lines changed

2 files changed

+147
-1
lines changed

lib/ingress/config.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,11 +432,14 @@ func (g *CaddyConfigGenerator) buildConfig(ctx context.Context, ingresses []Ingr
432432
}
433433

434434
// Add TLS automation if we have TLS hostnames
435+
// Deduplicate hostnames to avoid "cannot apply more than one automation policy to host" error
436+
// This can happen when multiple ingress rules use the same hostname pattern on different ports
435437
if len(tlsHostnames) > 0 && g.acme.IsTLSConfigured() {
438+
uniqueTLSHostnames := deduplicateStrings(tlsHostnames)
436439
if config["apps"] == nil {
437440
config["apps"] = map[string]interface{}{}
438441
}
439-
config["apps"].(map[string]interface{})["tls"] = g.buildTLSConfig(tlsHostnames)
442+
config["apps"].(map[string]interface{})["tls"] = g.buildTLSConfig(uniqueTLSHostnames)
440443
}
441444

442445
// Configure Caddy storage paths
@@ -579,3 +582,17 @@ func HasTLSRules(ingresses []Ingress) bool {
579582
}
580583
return false
581584
}
585+
586+
// deduplicateStrings returns a new slice with duplicate strings removed.
587+
// Order is preserved (first occurrence is kept).
588+
func deduplicateStrings(s []string) []string {
589+
seen := make(map[string]bool)
590+
result := make([]string, 0, len(s))
591+
for _, v := range s {
592+
if !seen[v] {
593+
seen[v] = true
594+
result = append(result, v)
595+
}
596+
}
597+
return result
598+
}

lib/ingress/config_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,135 @@ func TestGenerateConfig_PatternHostname(t *testing.T) {
810810
assert.Contains(t, configStr, "http.request.host.labels")
811811
}
812812

813+
func TestGenerateConfig_TLSHostnameDeduplication(t *testing.T) {
814+
// Test that duplicate TLS hostnames (same hostname on different ports) don't cause
815+
// Caddy to fail with "cannot apply more than one automation policy to host"
816+
tmpDir, err := os.MkdirTemp("", "ingress-config-tls-dedup-test-*")
817+
require.NoError(t, err)
818+
defer os.RemoveAll(tmpDir)
819+
820+
p := paths.New(tmpDir)
821+
require.NoError(t, os.MkdirAll(p.CaddyDir(), 0755))
822+
require.NoError(t, os.MkdirAll(p.CaddyDataDir(), 0755))
823+
824+
// Create generator with ACME configured
825+
acmeConfig := ACMEConfig{
826+
Email: "admin@example.com",
827+
DNSProvider: DNSProviderCloudflare,
828+
CloudflareAPIToken: "test-token",
829+
AllowedDomains: "*.example.com",
830+
}
831+
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, acmeConfig, APIIngressConfig{}, 5353)
832+
833+
ctx := context.Background()
834+
// Create two ingresses with the same wildcard hostname pattern on different ports
835+
ingresses := []Ingress{
836+
{
837+
ID: "ing-port-443",
838+
Name: "wildcard-443",
839+
Rules: []IngressRule{
840+
{
841+
Match: IngressMatch{Hostname: "{instance}.example.com", Port: 443},
842+
Target: IngressTarget{Instance: "{instance}", Port: 80},
843+
TLS: true,
844+
},
845+
},
846+
},
847+
{
848+
ID: "ing-port-3000",
849+
Name: "wildcard-3000",
850+
Rules: []IngressRule{
851+
{
852+
Match: IngressMatch{Hostname: "{instance}.example.com", Port: 3000},
853+
Target: IngressTarget{Instance: "{instance}", Port: 3000},
854+
TLS: true,
855+
},
856+
},
857+
},
858+
{
859+
ID: "ing-port-8080",
860+
Name: "wildcard-8080",
861+
Rules: []IngressRule{
862+
{
863+
Match: IngressMatch{Hostname: "{instance}.example.com", Port: 8080},
864+
Target: IngressTarget{Instance: "{instance}", Port: 8080},
865+
TLS: true,
866+
},
867+
},
868+
},
869+
}
870+
871+
data, err := generator.GenerateConfig(ctx, ingresses)
872+
require.NoError(t, err)
873+
874+
// Parse the config to verify TLS subjects are deduplicated
875+
var config map[string]interface{}
876+
err = json.Unmarshal(data, &config)
877+
require.NoError(t, err)
878+
879+
// Navigate to TLS automation policies
880+
apps := config["apps"].(map[string]interface{})
881+
tlsApp := apps["tls"].(map[string]interface{})
882+
automation := tlsApp["automation"].(map[string]interface{})
883+
policies := automation["policies"].([]interface{})
884+
885+
require.Len(t, policies, 1, "should have exactly one policy")
886+
887+
policy := policies[0].(map[string]interface{})
888+
subjects := policy["subjects"].([]interface{})
889+
890+
// Should have only ONE entry for *.example.com (deduplicated)
891+
assert.Len(t, subjects, 1, "TLS subjects should be deduplicated")
892+
assert.Equal(t, "*.example.com", subjects[0].(string))
893+
894+
// Verify all three ports are in listen addresses
895+
configStr := string(data)
896+
assert.Contains(t, configStr, ":443")
897+
assert.Contains(t, configStr, ":3000")
898+
assert.Contains(t, configStr, ":8080")
899+
}
900+
901+
func TestDeduplicateStrings(t *testing.T) {
902+
tests := []struct {
903+
name string
904+
input []string
905+
expected []string
906+
}{
907+
{
908+
name: "empty",
909+
input: []string{},
910+
expected: []string{},
911+
},
912+
{
913+
name: "no duplicates",
914+
input: []string{"a", "b", "c"},
915+
expected: []string{"a", "b", "c"},
916+
},
917+
{
918+
name: "with duplicates",
919+
input: []string{"a", "b", "a", "c", "b"},
920+
expected: []string{"a", "b", "c"},
921+
},
922+
{
923+
name: "all same",
924+
input: []string{"x", "x", "x"},
925+
expected: []string{"x"},
926+
},
927+
{
928+
name: "preserves order",
929+
input: []string{"c", "a", "b", "a", "c"},
930+
expected: []string{"c", "a", "b"},
931+
},
932+
}
933+
934+
for _, tc := range tests {
935+
t.Run(tc.name, func(t *testing.T) {
936+
result := deduplicateStrings(tc.input)
937+
assert.Equal(t, tc.expected, result)
938+
})
939+
}
940+
}
941+
813942
func TestGenerateConfig_DynamicUpstreams(t *testing.T) {
814943
// Create temp dir
815944
tmpDir, err := os.MkdirTemp("", "ingress-config-dynamic-test-*")

0 commit comments

Comments
 (0)