Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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., 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
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
90 changes: 88 additions & 2 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., "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
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)
Copy link

Choose a reason for hiding this comment

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

API ingress TLS lacks ACME configuration validation

Medium Severity

When API_HOSTNAME is set, API_TLS defaults to true, causing the API hostname to be added to tlsHostnames and Caddy to listen on port 443. However, unlike instance ingress TLS (which validates that ACME is configured and returns a clear error), there's no validation for the API ingress. If ACME isn't configured, no TLS automation is generated (line 431 requires IsTLSConfigured()), so Caddy will attempt to serve HTTPS using its default ACME behavior (HTTP-01 challenge), which may fail silently in many environments.

Additional Locations (1)

Fix in Cursor Fix in Web


// 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
10 changes: 5 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 @@ -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{
Expand Down
15 changes: 15 additions & 0 deletions lib/ingress/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -134,6 +137,7 @@ func NewManager(p *paths.Paths, config Config, instanceResolver InstanceResolver
config.AdminAddress,
config.AdminPort,
config.ACME,
config.APIIngress,
dnsServer.Port(),
)

Expand Down Expand Up @@ -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(),
)

Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions lib/ingress/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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{
{
Expand Down Expand Up @@ -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{
{
Expand Down
15 changes: 15 additions & 0 deletions lib/providers/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"strconv"
"time"

"github.com/c2h5oh/datasize"
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down