ingress

package
v0.0.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jan 16, 2026 License: MIT Imports: 28 Imported by: 0

README

Ingress Manager

Manages external traffic routing to VM instances using Caddy as a reverse proxy with automatic TLS via ACME.

Architecture

External Request         Caddy (daemon)        DNS Server        VM
    |                         |                    |               |
    | Host:api.example.com    |                    |               |
    +------------------------>| route match        |               |
                              | lookup my-api      |               |
                              +------------------->|               |
                              | A: 10.100.x.y      |               |
                              |<-------------------+               |
                              | proxy to 10.100.x.y:8080           |
                              +----------------------------------->|

How It Works

Caddy Daemon
  • Caddy binary is embedded in hypeman (like Cloud Hypervisor)
  • Extracted to /var/lib/hypeman/system/binaries/caddy/{version}/{arch}/caddy on first use
  • Runs as a daemon process that survives hypeman restarts
  • Listens on configured ports (default: 80, 443)
  • Admin API on 127.0.0.1:2019 (configurable via CADDY_ADMIN_ADDRESS and CADDY_ADMIN_PORT)
Ingress Resource

An Ingress is a configuration object that defines how external traffic should be routed:

{
  "name": "my-api-ingress",
  "rules": [
    {
      "match": {
        "hostname": "api.example.com",
        "port": 443
      },
      "target": {
        "instance": "my-api",
        "port": 8080
      },
      "tls": true,
      "redirect_http": true
    }
  ]
}

Pattern hostnames enable convention-based routing where the subdomain maps to an instance name:

{
  "name": "wildcard-ingress",
  "rules": [
    {
      "match": { "hostname": "{instance}.dev.example.com" },
      "target": { "instance": "{instance}", "port": 8080 },
      "tls": true
    }
  ]
}

This routes foobar.dev.example.com → instance foobar, myapp.dev.example.com → instance myapp, etc.

Configuration Flow
  1. User creates an ingress via API
  2. Manager validates the ingress (name, instance exists, hostname unique)
  3. Generates Caddy JSON config from all ingresses
  4. Validates config via Caddy's admin API
  5. If valid, persists ingress to /var/lib/hypeman/ingresses/{id}.json
  6. Applies config via Caddy's admin API (live reload, no restart needed)
TLS / HTTPS

When tls: true is set on a rule:

  • Caddy automatically issues a certificate via ACME (Let's Encrypt)
  • DNS-01 challenge is used (requires DNS provider configuration)
  • Certificates are stored in /var/lib/hypeman/caddy/data/
  • Automatic renewal ~30 days before expiry

When redirect_http: true is also set:

  • An automatic HTTP → HTTPS redirect is created for the hostname
TLS Requirements

To use TLS on any ingress rule, you must configure:

  1. ACME credentials: ACME_EMAIL and ACME_DNS_PROVIDER (with provider-specific credentials)
  2. Allowed domains: TLS_ALLOWED_DOMAINS must include the hostname pattern

If TLS is requested without proper configuration, the ingress creation will fail with a descriptive error.

Allowed Domains (TLS_ALLOWED_DOMAINS)

This environment variable controls which hostnames can have TLS certificates issued. It's a comma-separated list of patterns:

Pattern Matches Does NOT Match
api.example.com api.example.com (exact) Any other hostname
*.example.com foo.example.com, bar.example.com example.com (apex), a.b.example.com (multi-level)
* Any hostname (use with caution) -

Wildcard behavior:

  • *.example.com matches single-level subdomains only
  • It does NOT match the apex domain (example.com)
  • It does NOT match multi-level subdomains (foo.bar.example.com)
  • To allow both apex and subdomains, use: TLS_ALLOWED_DOMAINS=example.com,*.example.com

Example configuration:

# Allow TLS for any subdomain of example.com plus the apex
TLS_ALLOWED_DOMAINS=example.com,*.example.com

# Allow TLS for specific subdomains only
TLS_ALLOWED_DOMAINS=api.example.com,www.example.com

# Allow TLS for any domain (not recommended for production)
TLS_ALLOWED_DOMAINS=*
Warning Scenarios

The ingress manager logs warnings in these situations:

  • TLS ingresses exist but ACME not configured: If existing ingresses have tls: true but ACME_EMAIL or ACME_DNS_PROVIDER is not set, a warning is logged at startup. TLS will not work until ACME is configured.

  • Domain not in allowed list: Creating an ingress with tls: true for a hostname not in TLS_ALLOWED_DOMAINS will fail with error domain_not_allowed.

Hostname Routing
  • Uses HTTP Host header matching (HTTP) or SNI (HTTPS)
  • Supports exact hostnames (api.example.com) and patterns ({instance}.example.com)
  • Pattern hostnames enable convention-based routing (e.g., foobar.example.com → instance foobar)
  • Hostnames must be unique across all ingresses
  • Default 404 response for unmatched hostnames

Filesystem Layout

/var/lib/hypeman/
  system/
    binaries/
      caddy/
        v2.10.2/
          x86_64/caddy
          aarch64/caddy
  caddy/
    config.json    # Caddy configuration (applied via admin API)
    caddy.pid      # PID file for daemon discovery
    caddy.log      # Caddy process output
    data/          # Caddy data (certificates, etc.)
    config/        # Caddy config storage
  ingresses/
    {id}.json      # Ingress resource metadata

API Endpoints

POST   /ingresses      - Create ingress
GET    /ingresses      - List ingresses  
GET    /ingresses/{id} - Get ingress by ID or name
DELETE /ingresses/{id} - Delete ingress

Configuration

Caddy Settings
Variable Description Default
CADDY_LISTEN_ADDRESS Address for ingress listeners 0.0.0.0
CADDY_ADMIN_ADDRESS Address for Caddy admin API 127.0.0.1
CADDY_ADMIN_PORT Port for Caddy admin API 2019
CADDY_STOP_ON_SHUTDOWN Stop Caddy when hypeman shuts down false
ACME / TLS Settings
Variable Description Default
ACME_EMAIL ACME account email (required for TLS)
ACME_DNS_PROVIDER DNS provider: cloudflare
ACME_CA ACME CA URL (for staging, etc.) Let's Encrypt production
TLS_ALLOWED_DOMAINS Comma-separated domain patterns allowed for TLS (required for TLS ingresses)
DNS_PROPAGATION_TIMEOUT Max time to wait for DNS propagation (e.g., 2m, 120s)
DNS_RESOLVERS Comma-separated DNS resolvers for propagation checking
Cloudflare DNS Provider
Variable Description
CLOUDFLARE_API_TOKEN Cloudflare API token with DNS edit permissions

Note on Ports: Each ingress rule can specify a port in the match criteria to listen on a specific host port. If not specified, defaults to port 80. Caddy dynamically listens on all unique ports across all ingresses.

Security

  • Admin API bound to localhost only by default
  • Ingress validation ensures target instances exist (for exact hostnames)
  • Instance IP resolution happens at request time via internal DNS server
  • Caddy runs as the same user as hypeman (not root)
  • Private keys for TLS certificates stored with restrictive permissions

Daemon Lifecycle

Startup
  1. Extract Caddy binary (if needed)
  2. Start internal DNS server for dynamic upstream resolution (port 5353)
  3. Check for existing running Caddy (via PID file or admin API)
  4. If not running, start Caddy with generated config
  5. Wait for admin API to become ready
Config Updates

Caddy's admin API allows live configuration updates:

  1. Generate new JSON config
  2. POST to /load endpoint on admin API
  3. Caddy validates and applies atomically
  4. Active connections are preserved during reload
Shutdown
  • By default (CADDY_STOP_ON_SHUTDOWN=false), Caddy continues running when hypeman exits
  • Set CADDY_STOP_ON_SHUTDOWN=true to stop Caddy with hypeman
  • Caddy can be manually stopped via admin API (/stop) or SIGTERM

Testing

# Run ingress tests
go test ./lib/ingress/...

Tests use:

  • Mock instance resolver (no real VMs needed)
  • Temporary directories for filesystem operations
  • Non-privileged ports to avoid permission issues

Future Improvements

  • Path-based L7 routing
  • Health checks for backends
  • Rate limiting
  • Custom error pages

Documentation

Index

Constants

View Source
const CaddyVersion = "v2.10.2"

CaddyVersion is the version of Caddy embedded in this build.

View Source
const DefaultDNSPort = dns.DefaultPort

DefaultDNSPort is the default port for the internal DNS server.

Variables

View Source
var (
	// ErrNotFound is returned when an ingress is not found.
	ErrNotFound = errors.New("ingress not found")

	// ErrAlreadyExists is returned when trying to create an ingress that already exists.
	ErrAlreadyExists = errors.New("ingress already exists")

	// ErrInvalidRequest is returned when the request is invalid.
	ErrInvalidRequest = errors.New("invalid request")

	// ErrInstanceNotFound is returned when the target instance is not found.
	ErrInstanceNotFound = errors.New("target instance not found")

	// ErrInstanceNoNetwork is returned when the target instance has no network.
	ErrInstanceNoNetwork = errors.New("target instance has no network configured")

	// ErrHostnameInUse is returned when a hostname is already in use by another ingress.
	ErrHostnameInUse = errors.New("hostname already in use by another ingress")

	// ErrConfigValidationFailed is returned when Caddy config validation fails.
	// This indicates the config was rejected by Caddy's admin API.
	ErrConfigValidationFailed = errors.New("config validation failed")

	// ErrPortInUse is returned when the requested port is already in use by another process.
	ErrPortInUse = errors.New("port already in use")

	// ErrDomainNotAllowed is returned when a TLS ingress is requested for a domain not in the allowed list.
	ErrDomainNotAllowed = errors.New("domain not allowed for TLS")

	// ErrAmbiguousName is returned when a lookup matches multiple ingresses.
	ErrAmbiguousName = errors.New("ambiguous ingress identifier matches multiple ingresses")
)

Common errors returned by the ingress package.

Functions

func ExtractCaddyBinary

func ExtractCaddyBinary(p *paths.Paths) (string, error)

ExtractCaddyBinary extracts the embedded Caddy binary to the data directory. Returns the path to the extracted binary. If the binary already exists but doesn't match the embedded version (e.g., after rebuilding with different modules), it will be re-extracted.

func GetCaddyBinaryPath

func GetCaddyBinaryPath(p *paths.Paths) (string, error)

GetCaddyBinaryPath returns path to extracted binary, extracting if needed.

func HasTLSRules

func HasTLSRules(ingresses []Ingress) bool

HasTLSRules checks if any ingress has TLS enabled.

func ParseCaddyError

func ParseCaddyError(caddyError string) error

ParseCaddyError parses a Caddy error response and returns a more specific error if possible.

func SupportedDNSProviders

func SupportedDNSProviders() string

SupportedDNSProviders returns a comma-separated list of supported DNS provider names. Used in error messages to keep them in sync as new providers are added.

Types

type ACMEConfig

type ACMEConfig struct {
	// Email is the ACME account email (required for TLS).
	Email string

	// DNSProvider is the DNS provider for ACME challenges.
	DNSProvider DNSProvider

	// CA is the ACME CA URL. Empty means Let's Encrypt production.
	CA string

	// DNS propagation settings (applies to all providers)
	DNSPropagationTimeout string // Max time to wait for DNS propagation (e.g., "2m")
	DNSResolvers          string // Comma-separated DNS resolvers to use for checking propagation

	// AllowedDomains is a comma-separated list of domain patterns allowed for TLS ingresses.
	// Supports wildcards like "*.example.com" and exact matches like "api.example.com".
	// If empty, no TLS domains are allowed.
	AllowedDomains string

	// Cloudflare API token (if DNSProvider=cloudflare).
	CloudflareAPIToken string
}

ACMEConfig holds ACME/TLS configuration for Caddy.

func (*ACMEConfig) IsDomainAllowed

func (c *ACMEConfig) IsDomainAllowed(hostname string) bool

IsDomainAllowed checks if a hostname is allowed for TLS based on the AllowedDomains config. Returns true if the hostname matches any of the allowed patterns.

Supported pattern types:

  • Exact match: "api.example.com" matches only "api.example.com"
  • Global wildcard: "*" matches any hostname (use with caution)
  • Subdomain wildcard: "*.example.com" matches single-level subdomains only

Wildcard behavior for "*.example.com":

  • Matches: "foo.example.com", "bar.example.com"
  • Does NOT match: "example.com" (apex domain)
  • Does NOT match: "foo.bar.example.com" (multi-level subdomain)

func (*ACMEConfig) IsTLSConfigured

func (c *ACMEConfig) IsTLSConfigured() bool

IsTLSConfigured returns true if ACME/TLS is properly configured.

type CaddyConfigGenerator

type CaddyConfigGenerator struct {
	// contains filtered or unexported fields
}

CaddyConfigGenerator generates Caddy configuration from ingress resources.

func NewCaddyConfigGenerator

func NewCaddyConfigGenerator(p *paths.Paths, listenAddress string, adminAddress string, adminPort int, acme ACMEConfig, dnsResolverPort int) *CaddyConfigGenerator

NewCaddyConfigGenerator creates a new Caddy config generator.

func (*CaddyConfigGenerator) GenerateConfig

func (g *CaddyConfigGenerator) GenerateConfig(ctx context.Context, ingresses []Ingress) ([]byte, error)

GenerateConfig generates the Caddy JSON configuration.

func (*CaddyConfigGenerator) WriteConfig

func (g *CaddyConfigGenerator) WriteConfig(ctx context.Context, ingresses []Ingress) error

WriteConfig writes the Caddy configuration to disk.

type CaddyDaemon

type CaddyDaemon struct {
	// contains filtered or unexported fields
}

CaddyDaemon manages the Caddy proxy daemon lifecycle. Caddy uses its admin API for configuration updates - no restart needed.

func NewCaddyDaemon

func NewCaddyDaemon(p *paths.Paths, adminAddress string, adminPort int, stopOnShutdown bool) *CaddyDaemon

NewCaddyDaemon creates a new CaddyDaemon manager. If adminPort is 0, it will be resolved later from existing config or picked fresh.

func (*CaddyDaemon) AdminPort

func (d *CaddyDaemon) AdminPort() int

AdminPort returns the admin port. If it was configured as 0 (random), this returns the actual port after it's been resolved.

func (*CaddyDaemon) AdminURL

func (d *CaddyDaemon) AdminURL() string

AdminURL returns the admin API URL.

func (*CaddyDaemon) DiscoverRunning

func (d *CaddyDaemon) DiscoverRunning() (int, bool)

DiscoverRunning checks if Caddy is already running and returns its PID.

func (*CaddyDaemon) GetPID

func (d *CaddyDaemon) GetPID() int

GetPID returns the PID of the running Caddy process, or 0 if not running.

func (*CaddyDaemon) IsRunning

func (d *CaddyDaemon) IsRunning() bool

IsRunning returns true if Caddy is currently running.

func (*CaddyDaemon) ReloadConfig

func (d *CaddyDaemon) ReloadConfig(config []byte) error

ReloadConfig reloads Caddy configuration by posting to the admin API.

func (*CaddyDaemon) Start

func (d *CaddyDaemon) Start(ctx context.Context) (int, error)

Start starts the Caddy daemon. If Caddy is already running (discovered via PID file or admin API), this is a no-op and returns the existing PID. Note: adminPort must be resolved (non-zero) before calling Start.

func (*CaddyDaemon) Stop

func (d *CaddyDaemon) Stop(ctx context.Context) error

Stop gracefully stops the Caddy daemon.

func (*CaddyDaemon) StopOnShutdown

func (d *CaddyDaemon) StopOnShutdown() bool

StopOnShutdown returns whether Caddy should be stopped when hypeman shuts down.

type CaddyLogForwarder

type CaddyLogForwarder struct {
	// contains filtered or unexported fields
}

CaddyLogForwarder tails Caddy's system log and forwards to OTEL.

func NewCaddyLogForwarder

func NewCaddyLogForwarder(p *paths.Paths, logger *slog.Logger) *CaddyLogForwarder

NewCaddyLogForwarder creates a new log forwarder.

func (*CaddyLogForwarder) Start

func (f *CaddyLogForwarder) Start(ctx context.Context) error

Start begins tailing Caddy's log file and forwarding to OTEL.

func (*CaddyLogForwarder) Stop

func (f *CaddyLogForwarder) Stop()

Stop stops the log forwarder.

type Config

type Config struct {
	// ListenAddress is the address Caddy should listen on (default: 0.0.0.0).
	ListenAddress string

	// AdminAddress is the address for Caddy admin API (default: 127.0.0.1).
	AdminAddress string

	// AdminPort is the port for Caddy admin API (default: 2019).
	AdminPort int

	// DNSPort is the port for the internal DNS server used for dynamic upstream resolution.
	// Default: 5353. Set to 0 to use a random available port.
	DNSPort int

	// StopOnShutdown determines whether to stop Caddy when hypeman shuts down (default: false).
	// When false, Caddy continues running independently.
	StopOnShutdown bool

	// ACME configuration for TLS certificates
	ACME ACMEConfig
}

Config holds configuration for the ingress manager.

func DefaultConfig

func DefaultConfig() Config

DefaultConfig returns the default ingress configuration.

type CreateIngressRequest

type CreateIngressRequest struct {
	// Name is a human-readable name for the ingress.
	Name string `json:"name"`

	// Rules define the routing rules for this ingress.
	Rules []IngressRule `json:"rules"`
}

CreateIngressRequest is the request body for creating a new ingress.

func (*CreateIngressRequest) Validate

func (r *CreateIngressRequest) Validate() error

Validate validates the CreateIngressRequest.

type DNSProvider

type DNSProvider string

DNSProvider represents supported DNS providers for ACME challenges.

const (
	// DNSProviderNone indicates no DNS provider is configured.
	DNSProviderNone DNSProvider = ""
	// DNSProviderCloudflare uses Cloudflare for DNS challenges.
	DNSProviderCloudflare DNSProvider = "cloudflare"
)

func ParseDNSProvider

func ParseDNSProvider(s string) (DNSProvider, error)

ParseDNSProvider parses a string into a DNSProvider, returning an error for unknown values.

type HostnamePattern

type HostnamePattern struct {
	// Original is the original pattern string (e.g., "{instance}.example.com")
	Original string

	// Wildcard is the Caddy wildcard pattern (e.g., "*.example.com")
	Wildcard string

	// Captures is the list of capture names in order (e.g., ["instance"])
	Captures []string

	// CaddyLabels maps capture names to Caddy placeholder expressions
	// e.g., {"instance": "{http.request.host.labels.2}"}
	CaddyLabels map[string]string
}

HostnamePattern represents a parsed hostname pattern with captures.

func (*HostnamePattern) ResolveInstance

func (p *HostnamePattern) ResolveInstance(targetInstance string) string

ResolveInstance resolves the target instance expression using the pattern's captures. For a target like "{instance}" and captures {"instance": "{http.request.host.labels.2}"}, returns "{http.request.host.labels.2}". For a literal target like "my-api", returns "my-api".

type Ingress

type Ingress struct {
	// ID is the unique identifier for this ingress (auto-generated).
	ID string `json:"id"`

	// Name is a human-readable name for the ingress.
	Name string `json:"name"`

	// Rules define the routing rules for this ingress.
	Rules []IngressRule `json:"rules"`

	// CreatedAt is the timestamp when this ingress was created.
	CreatedAt time.Time `json:"created_at"`
}

Ingress represents an ingress resource that defines how external traffic should be routed to VM instances.

type IngressMatch

type IngressMatch struct {
	// Hostname is the hostname to match. Can be:
	// - Literal: "api.example.com" (exact match on Host header)
	// - Pattern: "{instance}.example.com" (dynamic, extracts subdomain as instance name)
	// This is required.
	Hostname string `json:"hostname"`

	// Port is the host port to listen on for this rule.
	// If not specified, defaults to 80.
	Port int `json:"port,omitempty"`
}

IngressMatch specifies the conditions for matching incoming requests.

func (*IngressMatch) GetPort

func (m *IngressMatch) GetPort() int

GetPort returns the port for this match, defaulting to 80 if not specified.

func (*IngressMatch) IsPattern

func (m *IngressMatch) IsPattern() bool

IsPattern returns true if the hostname contains {name} captures.

func (*IngressMatch) ParsePattern

func (m *IngressMatch) ParsePattern() (*HostnamePattern, error)

ParsePattern parses the hostname pattern and returns a HostnamePattern. For "{instance}.example.com":

  • Wildcard: "*.example.com"
  • Captures: ["instance"]
  • CaddyLabels: {"instance": "{http.request.host.labels.2}"}

Caddy labels are indexed from the right (TLD first): - foo.bar.example.com → labels.0=com, labels.1=example, labels.2=bar, labels.3=foo

type IngressRule

type IngressRule struct {
	// Match specifies the conditions for matching incoming requests.
	Match IngressMatch `json:"match"`

	// Target specifies where matching requests should be routed.
	Target IngressTarget `json:"target"`

	// TLS enables TLS termination for this rule.
	// When enabled, a certificate will be automatically issued via ACME.
	TLS bool `json:"tls,omitempty"`

	// RedirectHTTP creates an automatic HTTP to HTTPS redirect for this hostname.
	// Only applies when TLS is enabled.
	RedirectHTTP bool `json:"redirect_http,omitempty"`
}

IngressRule defines a single routing rule within an ingress.

type IngressTarget

type IngressTarget struct {
	// Instance is the name or ID of the target instance.
	Instance string `json:"instance"`

	// Port is the port on the target instance.
	Port int `json:"port"`
}

IngressTarget specifies the target for routing matched requests.

type InstanceResolver

type InstanceResolver interface {
	// ResolveInstanceIP resolves an instance name or ID to its IP address.
	// Returns the IP address and nil error if found, or an error if the instance
	// doesn't exist, isn't running, or has no network.
	ResolveInstanceIP(ctx context.Context, nameOrID string) (string, error)

	// InstanceExists checks if an instance with the given name or ID exists.
	InstanceExists(ctx context.Context, nameOrID string) (bool, error)

	// ResolveInstance resolves an instance name, ID, or ID prefix to its canonical name and ID.
	// Returns (name, id, nil) if found, or an error if the instance doesn't exist.
	ResolveInstance(ctx context.Context, nameOrID string) (name string, id string, err error)
}

InstanceResolver provides instance resolution capabilities. This interface is implemented by the instance manager.

type Manager

type Manager interface {
	// Initialize starts the ingress subsystem.
	// This should be called during server startup.
	Initialize(ctx context.Context) error

	// Create creates a new ingress resource.
	Create(ctx context.Context, req CreateIngressRequest) (*Ingress, error)

	// Get retrieves an ingress by ID, name, or ID prefix.
	// Lookup order: exact ID match -> exact name match -> ID prefix match.
	// Returns ErrAmbiguousName if prefix matches multiple ingresses.
	Get(ctx context.Context, idOrName string) (*Ingress, error)

	// List returns all ingress resources.
	List(ctx context.Context) ([]Ingress, error)

	// Delete removes an ingress resource by ID, name, or ID prefix.
	// Lookup order: exact ID match -> exact name match -> ID prefix match.
	// Returns ErrAmbiguousName if prefix matches multiple ingresses.
	Delete(ctx context.Context, idOrName string) error

	// Shutdown gracefully stops the ingress subsystem.
	Shutdown(ctx context.Context) error

	// AdminURL returns the Caddy admin API URL.
	// Only valid after Initialize() has been called.
	AdminURL() string
}

Manager is the interface for managing ingress resources.

func NewManager

func NewManager(p *paths.Paths, config Config, instanceResolver InstanceResolver, otelLogger *slog.Logger) Manager

NewManager creates a new ingress manager. If otelLogger is non-nil, Caddy system logs will be forwarded to OTEL.

type ValidationError

type ValidationError struct {
	Field   string
	Message string
}

ValidationError represents a validation error.

func (*ValidationError) Error

func (e *ValidationError) Error() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL