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., 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
86 changes: 84 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,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 {
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
5 changes: 5 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
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