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 5e9eb3c..7b31e49 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, } } @@ -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{}{ @@ -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 { 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..ac05b20 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(), ) @@ -292,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) 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)