Skip to content
10 changes: 10 additions & 0 deletions cmd/api/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ type Config struct {
// Cloudflare configuration (if AcmeDnsProvider=cloudflare)
CloudflareApiToken string // Cloudflare API token

// API ingress configuration - exposes Hypeman API via Caddy
ApiHostname string // Hostname for API access (e.g., hypeman.hostname.kernel.sh). Empty = disabled.
ApiTLS bool // Enable TLS for API hostname
ApiRedirectHTTP bool // Redirect HTTP to HTTPS for API hostname

// Build system configuration
MaxConcurrentSourceBuilds int // Max concurrent source-to-image builds
BuilderImage string // OCI image for builder VMs
Expand Down Expand Up @@ -192,6 +197,11 @@ func Load() *Config {
// Cloudflare configuration
CloudflareApiToken: getEnv("CLOUDFLARE_API_TOKEN", ""),

// API ingress configuration
ApiHostname: getEnv("API_HOSTNAME", ""), // Empty = disabled
ApiTLS: getEnvBool("API_TLS", true), // Default to TLS enabled
ApiRedirectHTTP: getEnvBool("API_REDIRECT_HTTP", true),

// Build system configuration
MaxConcurrentSourceBuilds: getEnvInt("MAX_CONCURRENT_SOURCE_BUILDS", 2),
BuilderImage: getEnv("BUILDER_IMAGE", "hypeman/builder:latest"),
Expand Down
109 changes: 106 additions & 3 deletions lib/ingress/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,24 +143,47 @@ func (c *ACMEConfig) IsTLSConfigured() bool {
}
}

// APIIngressConfig holds configuration for exposing the Hypeman API via Caddy.
type APIIngressConfig struct {
// Hostname is the hostname for API access (e.g., "hypeman.hostname.kernel.sh").
// Empty means API ingress is disabled.
Hostname string

// Port is the local port where the Hypeman API is running.
Port int

// TLS enables TLS for the API hostname.
TLS bool

// RedirectHTTP enables HTTP to HTTPS redirect for the API hostname.
RedirectHTTP bool
}

// IsEnabled returns true if API ingress is configured.
func (c *APIIngressConfig) IsEnabled() bool {
return c.Hostname != ""
}

// CaddyConfigGenerator generates Caddy configuration from ingress resources.
type CaddyConfigGenerator struct {
paths *paths.Paths
listenAddress string
adminAddress string
adminPort int
acme ACMEConfig
apiIngress APIIngressConfig
dnsResolverPort int
}

// NewCaddyConfigGenerator creates a new Caddy config generator.
func NewCaddyConfigGenerator(p *paths.Paths, listenAddress string, adminAddress string, adminPort int, acme ACMEConfig, dnsResolverPort int) *CaddyConfigGenerator {
func NewCaddyConfigGenerator(p *paths.Paths, listenAddress string, adminAddress string, adminPort int, acme ACMEConfig, apiIngress APIIngressConfig, dnsResolverPort int) *CaddyConfigGenerator {
return &CaddyConfigGenerator{
paths: p,
listenAddress: listenAddress,
adminAddress: adminAddress,
adminPort: adminPort,
acme: acme,
apiIngress: apiIngress,
dnsResolverPort: dnsResolverPort,
}
}
Expand Down Expand Up @@ -247,12 +270,14 @@ func (g *CaddyConfigGenerator) buildConfig(ctx context.Context, ingresses []Ingr
tlsHostnames = append(tlsHostnames, hostnameMatch)

// Add HTTP redirect route if requested
// Uses protocol matcher to only redirect HTTP, not HTTPS (which would cause redirect loop)
if rule.RedirectHTTP {
listenPorts[80] = true
redirectRoute := map[string]interface{}{
"match": []interface{}{
map[string]interface{}{
"host": []string{hostnameMatch},
"host": []string{hostnameMatch},
"protocol": "http",
},
},
"handle": []interface{}{
Expand All @@ -272,6 +297,67 @@ func (g *CaddyConfigGenerator) buildConfig(ctx context.Context, ingresses []Ingr
}
}

// Add API ingress route if configured
// This routes requests to the API hostname directly to localhost (Hypeman API)
// IMPORTANT: API route must be prepended to routes so it takes precedence over
// wildcard patterns that might otherwise match the API hostname
if g.apiIngress.IsEnabled() {
log.InfoContext(ctx, "adding API ingress route", "hostname", g.apiIngress.Hostname, "port", g.apiIngress.Port)

// API reverse proxy to localhost
apiReverseProxy := map[string]interface{}{
"handler": "reverse_proxy",
"upstreams": []map[string]interface{}{
{"dial": fmt.Sprintf("127.0.0.1:%d", g.apiIngress.Port)},
},
}

apiRoute := map[string]interface{}{
"match": []interface{}{
map[string]interface{}{
"host": []string{g.apiIngress.Hostname},
},
},
"handle": []interface{}{apiReverseProxy},
"terminal": true,
}
// Prepend API route so it takes precedence over wildcards
routes = append([]interface{}{apiRoute}, routes...)

// Add TLS configuration for API hostname
if g.apiIngress.TLS {
listenPorts[443] = true
tlsHostnames = append(tlsHostnames, g.apiIngress.Hostname)

// Add HTTP to HTTPS redirect for API hostname
// Prepend so it takes precedence over wildcard redirects
if g.apiIngress.RedirectHTTP {
listenPorts[80] = true
apiRedirectRoute := map[string]interface{}{
"match": []interface{}{
map[string]interface{}{
"host": []string{g.apiIngress.Hostname},
"protocol": "http",
},
},
"handle": []interface{}{
map[string]interface{}{
"handler": "static_response",
"headers": map[string]interface{}{
"Location": []string{"https://{http.request.host}{http.request.uri}"},
},
"status_code": 301,
},
},
"terminal": true,
}
redirectRoutes = append([]interface{}{apiRedirectRoute}, redirectRoutes...)
}
} else {
listenPorts[80] = true
}
}

// Build listen addresses (sorted for deterministic config output)
ports := make([]int, 0, len(listenPorts))
for port := range listenPorts {
Expand Down Expand Up @@ -346,11 +432,14 @@ func (g *CaddyConfigGenerator) buildConfig(ctx context.Context, ingresses []Ingr
}

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

// Configure Caddy storage paths
Expand Down Expand Up @@ -493,3 +582,17 @@ func HasTLSRules(ingresses []Ingress) bool {
}
return false
}

// deduplicateStrings returns a new slice with duplicate strings removed.
// Order is preserved (first occurrence is kept).
func deduplicateStrings(s []string) []string {
seen := make(map[string]bool)
result := make([]string, 0, len(s))
for _, v := range s {
if !seen[v] {
seen[v] = true
result = append(result, v)
}
}
return result
}
139 changes: 134 additions & 5 deletions lib/ingress/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func setupTestGenerator(t *testing.T) (*CaddyConfigGenerator, *paths.Paths, func
// Empty ACMEConfig means TLS is not configured
// Use DNS resolver port for dynamic upstreams
dnsResolverPort := 5353
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, ACMEConfig{}, dnsResolverPort)
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, ACMEConfig{}, APIIngressConfig{}, dnsResolverPort)

cleanup := func() {
os.RemoveAll(tmpDir)
Expand Down Expand Up @@ -81,7 +81,7 @@ func TestGenerateConfig_StoragePath(t *testing.T) {
require.NoError(t, os.MkdirAll(p.CaddyDir(), 0755))
require.NoError(t, os.MkdirAll(p.CaddyDataDir(), 0755))

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

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

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

ctx := context.Background()
ingresses := []Ingress{
Expand Down Expand Up @@ -810,6 +810,135 @@ func TestGenerateConfig_PatternHostname(t *testing.T) {
assert.Contains(t, configStr, "http.request.host.labels")
}

func TestGenerateConfig_TLSHostnameDeduplication(t *testing.T) {
// Test that duplicate TLS hostnames (same hostname on different ports) don't cause
// Caddy to fail with "cannot apply more than one automation policy to host"
tmpDir, err := os.MkdirTemp("", "ingress-config-tls-dedup-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

p := paths.New(tmpDir)
require.NoError(t, os.MkdirAll(p.CaddyDir(), 0755))
require.NoError(t, os.MkdirAll(p.CaddyDataDir(), 0755))

// Create generator with ACME configured
acmeConfig := ACMEConfig{
Email: "admin@example.com",
DNSProvider: DNSProviderCloudflare,
CloudflareAPIToken: "test-token",
AllowedDomains: "*.example.com",
}
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, acmeConfig, APIIngressConfig{}, 5353)

ctx := context.Background()
// Create two ingresses with the same wildcard hostname pattern on different ports
ingresses := []Ingress{
{
ID: "ing-port-443",
Name: "wildcard-443",
Rules: []IngressRule{
{
Match: IngressMatch{Hostname: "{instance}.example.com", Port: 443},
Target: IngressTarget{Instance: "{instance}", Port: 80},
TLS: true,
},
},
},
{
ID: "ing-port-3000",
Name: "wildcard-3000",
Rules: []IngressRule{
{
Match: IngressMatch{Hostname: "{instance}.example.com", Port: 3000},
Target: IngressTarget{Instance: "{instance}", Port: 3000},
TLS: true,
},
},
},
{
ID: "ing-port-8080",
Name: "wildcard-8080",
Rules: []IngressRule{
{
Match: IngressMatch{Hostname: "{instance}.example.com", Port: 8080},
Target: IngressTarget{Instance: "{instance}", Port: 8080},
TLS: true,
},
},
},
}

data, err := generator.GenerateConfig(ctx, ingresses)
require.NoError(t, err)

// Parse the config to verify TLS subjects are deduplicated
var config map[string]interface{}
err = json.Unmarshal(data, &config)
require.NoError(t, err)

// Navigate to TLS automation policies
apps := config["apps"].(map[string]interface{})
tlsApp := apps["tls"].(map[string]interface{})
automation := tlsApp["automation"].(map[string]interface{})
policies := automation["policies"].([]interface{})

require.Len(t, policies, 1, "should have exactly one policy")

policy := policies[0].(map[string]interface{})
subjects := policy["subjects"].([]interface{})

// Should have only ONE entry for *.example.com (deduplicated)
assert.Len(t, subjects, 1, "TLS subjects should be deduplicated")
assert.Equal(t, "*.example.com", subjects[0].(string))

// Verify all three ports are in listen addresses
configStr := string(data)
assert.Contains(t, configStr, ":443")
assert.Contains(t, configStr, ":3000")
assert.Contains(t, configStr, ":8080")
Comment on lines +894 to +898
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

}

func TestDeduplicateStrings(t *testing.T) {
tests := []struct {
name string
input []string
expected []string
}{
{
name: "empty",
input: []string{},
expected: []string{},
},
{
name: "no duplicates",
input: []string{"a", "b", "c"},
expected: []string{"a", "b", "c"},
},
{
name: "with duplicates",
input: []string{"a", "b", "a", "c", "b"},
expected: []string{"a", "b", "c"},
},
{
name: "all same",
input: []string{"x", "x", "x"},
expected: []string{"x"},
},
{
name: "preserves order",
input: []string{"c", "a", "b", "a", "c"},
expected: []string{"c", "a", "b"},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := deduplicateStrings(tc.input)
assert.Equal(t, tc.expected, result)
})
}
}

func TestGenerateConfig_DynamicUpstreams(t *testing.T) {
// Create temp dir
tmpDir, err := os.MkdirTemp("", "ingress-config-dynamic-test-*")
Expand All @@ -821,7 +950,7 @@ func TestGenerateConfig_DynamicUpstreams(t *testing.T) {
require.NoError(t, os.MkdirAll(p.CaddyDataDir(), 0755))

dnsPort := 5353
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, ACMEConfig{}, dnsPort)
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, ACMEConfig{}, APIIngressConfig{}, dnsPort)

ctx := context.Background()
ingresses := []Ingress{
Expand Down
Loading