From 02ce8b7ffe39de58d09f87e3238aef8011bb1b37 Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Fri, 16 Jan 2026 14:12:15 -0500 Subject: [PATCH 1/5] fix: prevent HTTPS redirect loop in ingress routes When redirect_http is enabled for a TLS ingress, the generated Caddy redirect route was matching all requests (both HTTP and HTTPS) because it only checked the hostname. This caused HTTPS requests to be caught in an infinite redirect loop. Add "protocol": "http" matcher to redirect routes so they only match HTTP requests, allowing HTTPS requests to pass through to the reverse proxy route. --- lib/ingress/config.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/ingress/config.go b/lib/ingress/config.go index 5e9eb3c..bcde0c2 100644 --- a/lib/ingress/config.go +++ b/lib/ingress/config.go @@ -247,12 +247,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{}{ From 7c07690e5747ce7fcc2bdbb05cbac59f6e0fc00d Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Fri, 16 Jan 2026 14:37:25 -0500 Subject: [PATCH 2/5] feat: add API_HOSTNAME config to expose Hypeman API via Caddy Add configuration options to expose the Hypeman API through Caddy's ingress routing, separate from the instance ingress system. Configuration: - API_HOSTNAME: hostname for API access (e.g., api.hostname.kernel.sh) - API_TLS: enable TLS for API hostname (default: true) - API_REDIRECT_HTTP: redirect HTTP to HTTPS (default: true) When API_HOSTNAME is set, Caddy automatically adds a route that proxies requests to localhost:PORT (the Hypeman API). This allows the API to be accessed via HTTPS without requiring a separate ingress rule. Example: API_HOSTNAME=api.dev-yul-hypeman-0.kernel.sh API_TLS=true Results in: https://api.dev-yul-hypeman-0.kernel.sh/ -> localhost:8080 --- cmd/api/config/config.go | 10 +++++ lib/ingress/config.go | 82 +++++++++++++++++++++++++++++++++- lib/ingress/config_test.go | 10 ++--- lib/ingress/manager.go | 5 +++ lib/ingress/validation_test.go | 6 +-- lib/providers/providers.go | 15 +++++++ 6 files changed, 119 insertions(+), 9 deletions(-) diff --git a/cmd/api/config/config.go b/cmd/api/config/config.go index d56c802..922845a 100644 --- a/cmd/api/config/config.go +++ b/cmd/api/config/config.go @@ -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., api.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 @@ -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"), diff --git a/lib/ingress/config.go b/lib/ingress/config.go index bcde0c2..f80e635 100644 --- a/lib/ingress/config.go +++ b/lib/ingress/config.go @@ -143,6 +143,27 @@ 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., "api.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 @@ -150,17 +171,19 @@ type CaddyConfigGenerator struct { 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, } } @@ -274,6 +297,63 @@ 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) + 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, + } + routes = append(routes, apiRoute) + + // 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 + 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(redirectRoutes, apiRedirectRoute) + } + } else { + listenPorts[80] = true + } + } + // Build listen addresses (sorted for deterministic config output) ports := make([]int, 0, len(listenPorts)) for port := range listenPorts { diff --git a/lib/ingress/config_test.go b/lib/ingress/config_test.go index f0c884c..89daa14 100644 --- a/lib/ingress/config_test.go +++ b/lib/ingress/config_test.go @@ -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) @@ -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{}) @@ -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{ @@ -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{ @@ -821,7 +821,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{ diff --git a/lib/ingress/manager.go b/lib/ingress/manager.go index 46d72c6..e735d03 100644 --- a/lib/ingress/manager.go +++ b/lib/ingress/manager.go @@ -86,6 +86,9 @@ type Config struct { // ACME configuration for TLS certificates ACME ACMEConfig + + // APIIngress configuration for exposing Hypeman API via Caddy + APIIngress APIIngressConfig } // DefaultConfig returns the default ingress configuration. @@ -134,6 +137,7 @@ func NewManager(p *paths.Paths, config Config, instanceResolver InstanceResolver config.AdminAddress, config.AdminPort, config.ACME, + config.APIIngress, dnsServer.Port(), ) @@ -186,6 +190,7 @@ func (m *manager) Initialize(ctx context.Context) error { m.config.AdminAddress, adminPort, m.config.ACME, + m.config.APIIngress, m.dnsServer.Port(), ) diff --git a/lib/ingress/validation_test.go b/lib/ingress/validation_test.go index 9e1c767..26fa20c 100644 --- a/lib/ingress/validation_test.go +++ b/lib/ingress/validation_test.go @@ -196,7 +196,7 @@ func TestConfigGeneration(t *testing.T) { // Create config generator with DNS-based dynamic upstream settings dnsResolverPort := 5353 - generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", adminPort, ACMEConfig{}, dnsResolverPort) + generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", adminPort, ACMEConfig{}, APIIngressConfig{}, dnsResolverPort) ctx := context.Background() @@ -367,7 +367,7 @@ func TestTLSConfigGeneration(t *testing.T) { DNSProvider: DNSProviderCloudflare, CloudflareAPIToken: "test-token", } - generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", adminPort, acmeConfig, dnsResolverPort) + generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", adminPort, acmeConfig, APIIngressConfig{}, dnsResolverPort) ingresses := []Ingress{ { @@ -404,7 +404,7 @@ func TestTLSConfigGeneration(t *testing.T) { t.Run("NoTLSAutomationWithoutConfig", func(t *testing.T) { // Empty ACME config - generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", adminPort, ACMEConfig{}, dnsResolverPort) + generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", adminPort, ACMEConfig{}, APIIngressConfig{}, dnsResolverPort) ingresses := []Ingress{ { diff --git a/lib/providers/providers.go b/lib/providers/providers.go index 9f2d157..09d571c 100644 --- a/lib/providers/providers.go +++ b/lib/providers/providers.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "strconv" "time" "github.com/c2h5oh/datasize" @@ -185,6 +186,14 @@ func ProvideIngressManager(p *paths.Paths, cfg *config.Config, instanceManager i internalDNSPort = ingress.DefaultDNSPort } + // Parse API port from config + apiPort := 8080 // default + if cfg.Port != "" { + if p, err := strconv.Atoi(cfg.Port); err == nil { + apiPort = p + } + } + ingressConfig := ingress.Config{ ListenAddress: cfg.CaddyListenAddress, AdminAddress: cfg.CaddyAdminAddress, @@ -200,6 +209,12 @@ func ProvideIngressManager(p *paths.Paths, cfg *config.Config, instanceManager i AllowedDomains: cfg.TlsAllowedDomains, CloudflareAPIToken: cfg.CloudflareApiToken, }, + APIIngress: ingress.APIIngressConfig{ + Hostname: cfg.ApiHostname, + Port: apiPort, + TLS: cfg.ApiTLS, + RedirectHTTP: cfg.ApiRedirectHTTP, + }, } // Create OTEL logger for Caddy log forwarding (if OTEL is enabled) From fbfc60e186efd02c1c02ff27fa0e64b881de7490 Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Fri, 16 Jan 2026 15:02:51 -0500 Subject: [PATCH 3/5] fix: prevent user ingresses from shadowing API hostname Add validation to reject ingress creation when the hostname matches the configured API_HOSTNAME. This prevents users from hijacking API traffic by creating an ingress with the same hostname. --- lib/ingress/manager.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/ingress/manager.go b/lib/ingress/manager.go index e735d03..1118f58 100644 --- a/lib/ingress/manager.go +++ b/lib/ingress/manager.go @@ -323,6 +323,12 @@ func (m *manager) Create(ctx context.Context, req CreateIngressRequest) (*Ingres for _, rule := range req.Rules { newPort := rule.Match.GetPort() + + // Check if hostname conflicts with API hostname (reserved for Hypeman API) + if m.config.APIIngress.IsEnabled() && rule.Match.Hostname == m.config.APIIngress.Hostname { + return nil, fmt.Errorf("%w: hostname %q is reserved for the Hypeman API", ErrHostnameInUse, rule.Match.Hostname) + } + for _, existing := range existingIngresses { for _, existingRule := range existing.Rules { existingPort := existingRule.Match.GetPort() From 80cd4ae008faa1fa54480eddd4a9ae53197d2966 Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Fri, 16 Jan 2026 15:12:02 -0500 Subject: [PATCH 4/5] fix: prepend API routes to take precedence over wildcards API routes must come before wildcard routes in Caddy config, otherwise wildcards like *.example.com will match api.example.com and try to resolve "api" as a VM instance (resulting in 503). --- lib/ingress/config.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/ingress/config.go b/lib/ingress/config.go index f80e635..7b31e49 100644 --- a/lib/ingress/config.go +++ b/lib/ingress/config.go @@ -299,6 +299,8 @@ 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) @@ -319,7 +321,8 @@ func (g *CaddyConfigGenerator) buildConfig(ctx context.Context, ingresses []Ingr "handle": []interface{}{apiReverseProxy}, "terminal": true, } - routes = append(routes, apiRoute) + // Prepend API route so it takes precedence over wildcards + routes = append([]interface{}{apiRoute}, routes...) // Add TLS configuration for API hostname if g.apiIngress.TLS { @@ -327,6 +330,7 @@ func (g *CaddyConfigGenerator) buildConfig(ctx context.Context, ingresses []Ingr 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{}{ @@ -347,7 +351,7 @@ func (g *CaddyConfigGenerator) buildConfig(ctx context.Context, ingresses []Ingr }, "terminal": true, } - redirectRoutes = append(redirectRoutes, apiRedirectRoute) + redirectRoutes = append([]interface{}{apiRedirectRoute}, redirectRoutes...) } } else { listenPorts[80] = true From 081cb53bb32f6863db279135d6dc974de8e1e246 Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Fri, 16 Jan 2026 15:20:46 -0500 Subject: [PATCH 5/5] fix: check API hostname conflict before instance validation Move the API hostname conflict check earlier in the validation flow so users get a clear "reserved for API" error instead of a confusing "instance not found" error. --- lib/ingress/manager.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/ingress/manager.go b/lib/ingress/manager.go index 1118f58..ac05b20 100644 --- a/lib/ingress/manager.go +++ b/lib/ingress/manager.go @@ -297,6 +297,16 @@ func (m *manager) Create(ctx context.Context, req CreateIngressRequest) (*Ingres } } + // Check if any hostname conflicts with API hostname (reserved for Hypeman API) + // This check must happen before instance validation to give a clear error message + if m.config.APIIngress.IsEnabled() { + for _, rule := range req.Rules { + if rule.Match.Hostname == m.config.APIIngress.Hostname { + return nil, fmt.Errorf("%w: hostname %q is reserved for the Hypeman API", ErrHostnameInUse, rule.Match.Hostname) + } + } + } + // Validate that all target instances exist and resolve their names (only for literal hostnames) // Pattern hostnames have dynamic target instances that can't be validated at creation time var resolvedInstanceIDs []string // Track IDs for logging (used for hypeman.log routing) @@ -323,12 +333,6 @@ func (m *manager) Create(ctx context.Context, req CreateIngressRequest) (*Ingres for _, rule := range req.Rules { newPort := rule.Match.GetPort() - - // Check if hostname conflicts with API hostname (reserved for Hypeman API) - if m.config.APIIngress.IsEnabled() && rule.Match.Hostname == m.config.APIIngress.Hostname { - return nil, fmt.Errorf("%w: hostname %q is reserved for the Hypeman API", ErrHostnameInUse, rule.Match.Hostname) - } - for _, existing := range existingIngresses { for _, existingRule := range existing.Rules { existingPort := existingRule.Match.GetPort()