Skip to content

Commit 7c07690

Browse files
committed
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
1 parent 02ce8b7 commit 7c07690

File tree

6 files changed

+119
-9
lines changed

6 files changed

+119
-9
lines changed

cmd/api/config/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ type Config struct {
103103
// Cloudflare configuration (if AcmeDnsProvider=cloudflare)
104104
CloudflareApiToken string // Cloudflare API token
105105

106+
// API ingress configuration - exposes Hypeman API via Caddy
107+
ApiHostname string // Hostname for API access (e.g., api.hostname.kernel.sh). Empty = disabled.
108+
ApiTLS bool // Enable TLS for API hostname
109+
ApiRedirectHTTP bool // Redirect HTTP to HTTPS for API hostname
110+
106111
// Build system configuration
107112
MaxConcurrentSourceBuilds int // Max concurrent source-to-image builds
108113
BuilderImage string // OCI image for builder VMs
@@ -192,6 +197,11 @@ func Load() *Config {
192197
// Cloudflare configuration
193198
CloudflareApiToken: getEnv("CLOUDFLARE_API_TOKEN", ""),
194199

200+
// API ingress configuration
201+
ApiHostname: getEnv("API_HOSTNAME", ""), // Empty = disabled
202+
ApiTLS: getEnvBool("API_TLS", true), // Default to TLS enabled
203+
ApiRedirectHTTP: getEnvBool("API_REDIRECT_HTTP", true),
204+
195205
// Build system configuration
196206
MaxConcurrentSourceBuilds: getEnvInt("MAX_CONCURRENT_SOURCE_BUILDS", 2),
197207
BuilderImage: getEnv("BUILDER_IMAGE", "hypeman/builder:latest"),

lib/ingress/config.go

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,24 +143,47 @@ func (c *ACMEConfig) IsTLSConfigured() bool {
143143
}
144144
}
145145

146+
// APIIngressConfig holds configuration for exposing the Hypeman API via Caddy.
147+
type APIIngressConfig struct {
148+
// Hostname is the hostname for API access (e.g., "api.hostname.kernel.sh").
149+
// Empty means API ingress is disabled.
150+
Hostname string
151+
152+
// Port is the local port where the Hypeman API is running.
153+
Port int
154+
155+
// TLS enables TLS for the API hostname.
156+
TLS bool
157+
158+
// RedirectHTTP enables HTTP to HTTPS redirect for the API hostname.
159+
RedirectHTTP bool
160+
}
161+
162+
// IsEnabled returns true if API ingress is configured.
163+
func (c *APIIngressConfig) IsEnabled() bool {
164+
return c.Hostname != ""
165+
}
166+
146167
// CaddyConfigGenerator generates Caddy configuration from ingress resources.
147168
type CaddyConfigGenerator struct {
148169
paths *paths.Paths
149170
listenAddress string
150171
adminAddress string
151172
adminPort int
152173
acme ACMEConfig
174+
apiIngress APIIngressConfig
153175
dnsResolverPort int
154176
}
155177

156178
// NewCaddyConfigGenerator creates a new Caddy config generator.
157-
func NewCaddyConfigGenerator(p *paths.Paths, listenAddress string, adminAddress string, adminPort int, acme ACMEConfig, dnsResolverPort int) *CaddyConfigGenerator {
179+
func NewCaddyConfigGenerator(p *paths.Paths, listenAddress string, adminAddress string, adminPort int, acme ACMEConfig, apiIngress APIIngressConfig, dnsResolverPort int) *CaddyConfigGenerator {
158180
return &CaddyConfigGenerator{
159181
paths: p,
160182
listenAddress: listenAddress,
161183
adminAddress: adminAddress,
162184
adminPort: adminPort,
163185
acme: acme,
186+
apiIngress: apiIngress,
164187
dnsResolverPort: dnsResolverPort,
165188
}
166189
}
@@ -274,6 +297,63 @@ func (g *CaddyConfigGenerator) buildConfig(ctx context.Context, ingresses []Ingr
274297
}
275298
}
276299

300+
// Add API ingress route if configured
301+
// This routes requests to the API hostname directly to localhost (Hypeman API)
302+
if g.apiIngress.IsEnabled() {
303+
log.InfoContext(ctx, "adding API ingress route", "hostname", g.apiIngress.Hostname, "port", g.apiIngress.Port)
304+
305+
// API reverse proxy to localhost
306+
apiReverseProxy := map[string]interface{}{
307+
"handler": "reverse_proxy",
308+
"upstreams": []map[string]interface{}{
309+
{"dial": fmt.Sprintf("127.0.0.1:%d", g.apiIngress.Port)},
310+
},
311+
}
312+
313+
apiRoute := map[string]interface{}{
314+
"match": []interface{}{
315+
map[string]interface{}{
316+
"host": []string{g.apiIngress.Hostname},
317+
},
318+
},
319+
"handle": []interface{}{apiReverseProxy},
320+
"terminal": true,
321+
}
322+
routes = append(routes, apiRoute)
323+
324+
// Add TLS configuration for API hostname
325+
if g.apiIngress.TLS {
326+
listenPorts[443] = true
327+
tlsHostnames = append(tlsHostnames, g.apiIngress.Hostname)
328+
329+
// Add HTTP to HTTPS redirect for API hostname
330+
if g.apiIngress.RedirectHTTP {
331+
listenPorts[80] = true
332+
apiRedirectRoute := map[string]interface{}{
333+
"match": []interface{}{
334+
map[string]interface{}{
335+
"host": []string{g.apiIngress.Hostname},
336+
"protocol": "http",
337+
},
338+
},
339+
"handle": []interface{}{
340+
map[string]interface{}{
341+
"handler": "static_response",
342+
"headers": map[string]interface{}{
343+
"Location": []string{"https://{http.request.host}{http.request.uri}"},
344+
},
345+
"status_code": 301,
346+
},
347+
},
348+
"terminal": true,
349+
}
350+
redirectRoutes = append(redirectRoutes, apiRedirectRoute)
351+
}
352+
} else {
353+
listenPorts[80] = true
354+
}
355+
}
356+
277357
// Build listen addresses (sorted for deterministic config output)
278358
ports := make([]int, 0, len(listenPorts))
279359
for port := range listenPorts {

lib/ingress/config_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func setupTestGenerator(t *testing.T) (*CaddyConfigGenerator, *paths.Paths, func
2727
// Empty ACMEConfig means TLS is not configured
2828
// Use DNS resolver port for dynamic upstreams
2929
dnsResolverPort := 5353
30-
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, ACMEConfig{}, dnsResolverPort)
30+
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, ACMEConfig{}, APIIngressConfig{}, dnsResolverPort)
3131

3232
cleanup := func() {
3333
os.RemoveAll(tmpDir)
@@ -81,7 +81,7 @@ func TestGenerateConfig_StoragePath(t *testing.T) {
8181
require.NoError(t, os.MkdirAll(p.CaddyDir(), 0755))
8282
require.NoError(t, os.MkdirAll(p.CaddyDataDir(), 0755))
8383

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

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

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

695695
ctx := context.Background()
696696
ingresses := []Ingress{
@@ -821,7 +821,7 @@ func TestGenerateConfig_DynamicUpstreams(t *testing.T) {
821821
require.NoError(t, os.MkdirAll(p.CaddyDataDir(), 0755))
822822

823823
dnsPort := 5353
824-
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, ACMEConfig{}, dnsPort)
824+
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, ACMEConfig{}, APIIngressConfig{}, dnsPort)
825825

826826
ctx := context.Background()
827827
ingresses := []Ingress{

lib/ingress/manager.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ type Config struct {
8686

8787
// ACME configuration for TLS certificates
8888
ACME ACMEConfig
89+
90+
// APIIngress configuration for exposing Hypeman API via Caddy
91+
APIIngress APIIngressConfig
8992
}
9093

9194
// DefaultConfig returns the default ingress configuration.
@@ -134,6 +137,7 @@ func NewManager(p *paths.Paths, config Config, instanceResolver InstanceResolver
134137
config.AdminAddress,
135138
config.AdminPort,
136139
config.ACME,
140+
config.APIIngress,
137141
dnsServer.Port(),
138142
)
139143

@@ -186,6 +190,7 @@ func (m *manager) Initialize(ctx context.Context) error {
186190
m.config.AdminAddress,
187191
adminPort,
188192
m.config.ACME,
193+
m.config.APIIngress,
189194
m.dnsServer.Port(),
190195
)
191196

lib/ingress/validation_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ func TestConfigGeneration(t *testing.T) {
196196

197197
// Create config generator with DNS-based dynamic upstream settings
198198
dnsResolverPort := 5353
199-
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", adminPort, ACMEConfig{}, dnsResolverPort)
199+
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", adminPort, ACMEConfig{}, APIIngressConfig{}, dnsResolverPort)
200200

201201
ctx := context.Background()
202202

@@ -367,7 +367,7 @@ func TestTLSConfigGeneration(t *testing.T) {
367367
DNSProvider: DNSProviderCloudflare,
368368
CloudflareAPIToken: "test-token",
369369
}
370-
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", adminPort, acmeConfig, dnsResolverPort)
370+
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", adminPort, acmeConfig, APIIngressConfig{}, dnsResolverPort)
371371

372372
ingresses := []Ingress{
373373
{
@@ -404,7 +404,7 @@ func TestTLSConfigGeneration(t *testing.T) {
404404

405405
t.Run("NoTLSAutomationWithoutConfig", func(t *testing.T) {
406406
// Empty ACME config
407-
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", adminPort, ACMEConfig{}, dnsResolverPort)
407+
generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", adminPort, ACMEConfig{}, APIIngressConfig{}, dnsResolverPort)
408408

409409
ingresses := []Ingress{
410410
{

lib/providers/providers.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"log/slog"
7+
"strconv"
78
"time"
89

910
"github.com/c2h5oh/datasize"
@@ -185,6 +186,14 @@ func ProvideIngressManager(p *paths.Paths, cfg *config.Config, instanceManager i
185186
internalDNSPort = ingress.DefaultDNSPort
186187
}
187188

189+
// Parse API port from config
190+
apiPort := 8080 // default
191+
if cfg.Port != "" {
192+
if p, err := strconv.Atoi(cfg.Port); err == nil {
193+
apiPort = p
194+
}
195+
}
196+
188197
ingressConfig := ingress.Config{
189198
ListenAddress: cfg.CaddyListenAddress,
190199
AdminAddress: cfg.CaddyAdminAddress,
@@ -200,6 +209,12 @@ func ProvideIngressManager(p *paths.Paths, cfg *config.Config, instanceManager i
200209
AllowedDomains: cfg.TlsAllowedDomains,
201210
CloudflareAPIToken: cfg.CloudflareApiToken,
202211
},
212+
APIIngress: ingress.APIIngressConfig{
213+
Hostname: cfg.ApiHostname,
214+
Port: apiPort,
215+
TLS: cfg.ApiTLS,
216+
RedirectHTTP: cfg.ApiRedirectHTTP,
217+
},
203218
}
204219

205220
// Create OTEL logger for Caddy log forwarding (if OTEL is enabled)

0 commit comments

Comments
 (0)