Skip to content

Commit d8d9a51

Browse files
authored
Merge branch 'main' into fix/build-registry-auth-and-oci-compat
2 parents bd895b7 + 2434228 commit d8d9a51

File tree

7 files changed

+309
-11
lines changed

7 files changed

+309
-11
lines changed

cmd/api/config/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ type Config struct {
103103
// Cloudflare configuration (if AcmeDnsProvider=cloudflare)
104104
CloudflareApiToken string // Cloudflare API token
105105

106+
// API ingress configuration - exposes Hypeman API via Caddy
107+
ApiHostname string // Hostname for API access (e.g., hypeman.hostname.kernel.sh). Empty = disabled.
108+
ApiTLS bool // Enable TLS for API hostname
109+
ApiRedirectHTTP bool // Redirect HTTP to HTTPS for API hostname
110+
106111
// Build system configuration
107112
MaxConcurrentSourceBuilds int // Max concurrent source-to-image builds
108113
BuilderImage string // OCI image for builder VMs
@@ -192,6 +197,11 @@ func Load() *Config {
192197
// Cloudflare configuration
193198
CloudflareApiToken: getEnv("CLOUDFLARE_API_TOKEN", ""),
194199

200+
// API ingress configuration
201+
ApiHostname: getEnv("API_HOSTNAME", ""), // Empty = disabled
202+
ApiTLS: getEnvBool("API_TLS", true), // Default to TLS enabled
203+
ApiRedirectHTTP: getEnvBool("API_REDIRECT_HTTP", true),
204+
195205
// Build system configuration
196206
MaxConcurrentSourceBuilds: getEnvInt("MAX_CONCURRENT_SOURCE_BUILDS", 2),
197207
BuilderImage: getEnv("BUILDER_IMAGE", "hypeman/builder:latest"),

lib/ingress/config.go

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,24 +143,47 @@ func (c *ACMEConfig) IsTLSConfigured() bool {
143143
}
144144
}
145145

146+
// APIIngressConfig holds configuration for exposing the Hypeman API via Caddy.
147+
type APIIngressConfig struct {
148+
// Hostname is the hostname for API access (e.g., "hypeman.hostname.kernel.sh").
149+
// Empty means API ingress is disabled.
150+
Hostname string
151+
152+
// Port is the local port where the Hypeman API is running.
153+
Port int
154+
155+
// TLS enables TLS for the API hostname.
156+
TLS bool
157+
158+
// RedirectHTTP enables HTTP to HTTPS redirect for the API hostname.
159+
RedirectHTTP bool
160+
}
161+
162+
// IsEnabled returns true if API ingress is configured.
163+
func (c *APIIngressConfig) IsEnabled() bool {
164+
return c.Hostname != ""
165+
}
166+
146167
// CaddyConfigGenerator generates Caddy configuration from ingress resources.
147168
type CaddyConfigGenerator struct {
148169
paths *paths.Paths
149170
listenAddress string
150171
adminAddress string
151172
adminPort int
152173
acme ACMEConfig
174+
apiIngress APIIngressConfig
153175
dnsResolverPort int
154176
}
155177

156178
// NewCaddyConfigGenerator creates a new Caddy config generator.
157-
func NewCaddyConfigGenerator(p *paths.Paths, listenAddress string, adminAddress string, adminPort int, acme ACMEConfig, dnsResolverPort int) *CaddyConfigGenerator {
179+
func NewCaddyConfigGenerator(p *paths.Paths, listenAddress string, adminAddress string, adminPort int, acme ACMEConfig, apiIngress APIIngressConfig, dnsResolverPort int) *CaddyConfigGenerator {
158180
return &CaddyConfigGenerator{
159181
paths: p,
160182
listenAddress: listenAddress,
161183
adminAddress: adminAddress,
162184
adminPort: adminPort,
163185
acme: acme,
186+
apiIngress: apiIngress,
164187
dnsResolverPort: dnsResolverPort,
165188
}
166189
}
@@ -247,12 +270,14 @@ func (g *CaddyConfigGenerator) buildConfig(ctx context.Context, ingresses []Ingr
247270
tlsHostnames = append(tlsHostnames, hostnameMatch)
248271

249272
// Add HTTP redirect route if requested
273+
// Uses protocol matcher to only redirect HTTP, not HTTPS (which would cause redirect loop)
250274
if rule.RedirectHTTP {
251275
listenPorts[80] = true
252276
redirectRoute := map[string]interface{}{
253277
"match": []interface{}{
254278
map[string]interface{}{
255-
"host": []string{hostnameMatch},
279+
"host": []string{hostnameMatch},
280+
"protocol": "http",
256281
},
257282
},
258283
"handle": []interface{}{
@@ -272,6 +297,67 @@ func (g *CaddyConfigGenerator) buildConfig(ctx context.Context, ingresses []Ingr
272297
}
273298
}
274299

300+
// Add API ingress route if configured
301+
// This routes requests to the API hostname directly to localhost (Hypeman API)
302+
// IMPORTANT: API route must be prepended to routes so it takes precedence over
303+
// wildcard patterns that might otherwise match the API hostname
304+
if g.apiIngress.IsEnabled() {
305+
log.InfoContext(ctx, "adding API ingress route", "hostname", g.apiIngress.Hostname, "port", g.apiIngress.Port)
306+
307+
// API reverse proxy to localhost
308+
apiReverseProxy := map[string]interface{}{
309+
"handler": "reverse_proxy",
310+
"upstreams": []map[string]interface{}{
311+
{"dial": fmt.Sprintf("127.0.0.1:%d", g.apiIngress.Port)},
312+
},
313+
}
314+
315+
apiRoute := map[string]interface{}{
316+
"match": []interface{}{
317+
map[string]interface{}{
318+
"host": []string{g.apiIngress.Hostname},
319+
},
320+
},
321+
"handle": []interface{}{apiReverseProxy},
322+
"terminal": true,
323+
}
324+
// Prepend API route so it takes precedence over wildcards
325+
routes = append([]interface{}{apiRoute}, routes...)
326+
327+
// Add TLS configuration for API hostname
328+
if g.apiIngress.TLS {
329+
listenPorts[443] = true
330+
tlsHostnames = append(tlsHostnames, g.apiIngress.Hostname)
331+
332+
// Add HTTP to HTTPS redirect for API hostname
333+
// Prepend so it takes precedence over wildcard redirects
334+
if g.apiIngress.RedirectHTTP {
335+
listenPorts[80] = true
336+
apiRedirectRoute := map[string]interface{}{
337+
"match": []interface{}{
338+
map[string]interface{}{
339+
"host": []string{g.apiIngress.Hostname},
340+
"protocol": "http",
341+
},
342+
},
343+
"handle": []interface{}{
344+
map[string]interface{}{
345+
"handler": "static_response",
346+
"headers": map[string]interface{}{
347+
"Location": []string{"https://{http.request.host}{http.request.uri}"},
348+
},
349+
"status_code": 301,
350+
},
351+
},
352+
"terminal": true,
353+
}
354+
redirectRoutes = append([]interface{}{apiRedirectRoute}, redirectRoutes...)
355+
}
356+
} else {
357+
listenPorts[80] = true
358+
}
359+
}
360+
275361
// Build listen addresses (sorted for deterministic config output)
276362
ports := make([]int, 0, len(listenPorts))
277363
for port := range listenPorts {
@@ -346,11 +432,14 @@ func (g *CaddyConfigGenerator) buildConfig(ctx context.Context, ingresses []Ingr
346432
}
347433

348434
// 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
349437
if len(tlsHostnames) > 0 && g.acme.IsTLSConfigured() {
438+
uniqueTLSHostnames := deduplicateStrings(tlsHostnames)
350439
if config["apps"] == nil {
351440
config["apps"] = map[string]interface{}{}
352441
}
353-
config["apps"].(map[string]interface{})["tls"] = g.buildTLSConfig(tlsHostnames)
442+
config["apps"].(map[string]interface{})["tls"] = g.buildTLSConfig(uniqueTLSHostnames)
354443
}
355444

356445
// Configure Caddy storage paths
@@ -493,3 +582,17 @@ func HasTLSRules(ingresses []Ingress) bool {
493582
}
494583
return false
495584
}
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: 134 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func setupTestGenerator(t *testing.T) (*CaddyConfigGenerator, *paths.Paths, func
2727
// Empty ACMEConfig means TLS is not configured
2828
// Use DNS resolver port for dynamic upstreams
2929
dnsResolverPort := 5353
30-
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, ACMEConfig{}, dnsResolverPort)
30+
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, ACMEConfig{}, APIIngressConfig{}, dnsResolverPort)
3131

3232
cleanup := func() {
3333
os.RemoveAll(tmpDir)
@@ -81,7 +81,7 @@ func TestGenerateConfig_StoragePath(t *testing.T) {
8181
require.NoError(t, os.MkdirAll(p.CaddyDir(), 0755))
8282
require.NoError(t, os.MkdirAll(p.CaddyDataDir(), 0755))
8383

84-
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, ACMEConfig{}, 5353)
84+
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, ACMEConfig{}, APIIngressConfig{}, 5353)
8585

8686
ctx := context.Background()
8787
data, err := generator.GenerateConfig(ctx, []Ingress{})
@@ -405,7 +405,7 @@ func TestGenerateConfig_WithTLS(t *testing.T) {
405405
DNSProvider: DNSProviderCloudflare,
406406
CloudflareAPIToken: "test-token",
407407
}
408-
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, acmeConfig, 5353)
408+
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, acmeConfig, APIIngressConfig{}, 5353)
409409

410410
ctx := context.Background()
411411
ingresses := []Ingress{
@@ -690,7 +690,7 @@ func TestGenerateConfig_MixedTLSAndNonTLS(t *testing.T) {
690690
DNSProvider: DNSProviderCloudflare,
691691
CloudflareAPIToken: "test-token",
692692
}
693-
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, acmeConfig, 5353)
693+
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, acmeConfig, APIIngressConfig{}, 5353)
694694

695695
ctx := context.Background()
696696
ingresses := []Ingress{
@@ -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+
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-*")
@@ -821,7 +950,7 @@ func TestGenerateConfig_DynamicUpstreams(t *testing.T) {
821950
require.NoError(t, os.MkdirAll(p.CaddyDataDir(), 0755))
822951

823952
dnsPort := 5353
824-
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, ACMEConfig{}, dnsPort)
953+
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, ACMEConfig{}, APIIngressConfig{}, dnsPort)
825954

826955
ctx := context.Background()
827956
ingresses := []Ingress{

0 commit comments

Comments
 (0)