Skip to content
Merged
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
56 changes: 48 additions & 8 deletions cli/commands/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ func Server(ctx *Context, opts serverconfig.CLIFlags) error {
if err != nil {
return fmt.Errorf("failed to load configuration: %w", err)
}
if err := cfg.ValidateIngressCoherence(); err != nil {
return fmt.Errorf("configuration validation failed: %w", err)
}
cfg.WarnDeprecatedConfig(ctx.Log)

// Initialize Miren Labs feature flags
labs.Init(ctx.Log, cfg.Labs)
Expand Down Expand Up @@ -825,9 +829,9 @@ func Server(ctx *Context, opts serverconfig.CLIFlags) error {
}
}()

if cfg.TLS.GetStandardTLS() {
switch mode := cfg.Ingress.GetMode(); mode {
case serverconfig.IngressModeAutoprovision:
if cfg.TLS.GetSelfSigned() {
// Use self-signed certificate (for development/testing)
if err := autotls.ServeTLSSelfSigned(sub, ctx.Log, hs); err != nil {
ctx.Log.Error("failed to enable self-signed TLS", "error", err)
return err
Expand All @@ -845,13 +849,49 @@ func Server(ctx *Context, opts serverconfig.CLIFlags) error {
readyFn()
}
}
} else {
go func() {
err := http.ListenAndServe(":80", hs)
if err != nil {
ctx.Log.Error("failed to start HTTP server", "error", err)

case serverconfig.IngressModeBehindProxyHTTPS:
addr := cfg.Ingress.GetAddress()
if addr == "" {
addr = "127.0.0.1:443"
}
if cfg.TLS.GetSelfSigned() {
if err := autotls.ServeTLSSelfSignedOnAddr(sub, ctx.Log, hs, addr); err != nil {
ctx.Log.Error("failed to enable self-signed TLS on custom address", "error", err)
return err
}
}()
} else {
certProvider := co.CertificateProvider()
if certProvider == nil {
return fmt.Errorf("no certificate provider available")
}
if err := autotls.ServeTLSWithControllerOnAddr(sub, ctx.Log, certProvider, hs, addr); err != nil {
return err
}
}

case serverconfig.IngressModeBehindProxyHTTP:
addr := cfg.Ingress.GetAddress()
if addr == "" {
addr = "127.0.0.1:80"
}
httpSrv := &http.Server{Addr: addr, Handler: hs}
eg.Go(func() error {
ctx.Log.Info("starting HTTP server", "addr", addr)
if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("failed to start HTTP server on %s: %w", addr, err)
}
return nil
})
eg.Go(func() error {
<-sub.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return httpSrv.Shutdown(shutdownCtx)
})

default:
return fmt.Errorf("unrecognized ingress.mode %q (should have been caught by config validation)", mode)
}

registry := ocireg.NewRegistry(cfg.Server.GetDataPath(), ctx.Log, ec)
Expand Down
4 changes: 4 additions & 0 deletions cli/commands/server_config_cmds.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ func ServerConfigValidate(ctx *Context, opts struct {
if err != nil {
return fmt.Errorf("configuration is invalid: %w", err)
}
if err := cfg.ValidateIngressCoherence(); err != nil {
return fmt.Errorf("configuration is invalid: %w", err)
}
cfg.WarnDeprecatedConfig(ctx.Log)

ctx.UILog.Info("Configuration is valid", "file", opts.ConfigFile)

Expand Down
8 changes: 5 additions & 3 deletions cli/commands/server_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,8 @@ func ServerInstall(ctx *Context, opts struct {
execStartParts = append(execStartParts, fmt.Sprintf("--address=%s", opts.Address))
}

execStartParts = append(execStartParts, "--serve-tls")
// Ingress mode defaults to tls-autoprovision; no flag needed to opt into
// the standard :443 + :80 TLS setup that systemd-installed servers want.

execStart := strings.Join(execStartParts, " ")

Expand Down Expand Up @@ -767,8 +768,9 @@ func waitForSystemdServerReady(ctx *Context, serverAddress string) error {
maxRetries := 30
retryDelay := 2 * time.Second

// Parse the server address to build the health URL
// Server install always uses --serve-tls so it's always HTTPS
// Parse the server address to build the health URL.
// Systemd-installed servers run in the default tls-autoprovision mode, so
// the API is always served over HTTPS.
host, port, err := net.SplitHostPort(serverAddress)
if err != nil {
// No port specified, use default
Expand Down
59 changes: 51 additions & 8 deletions components/autotls/autotls.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package autotls
import (
"context"
"crypto/tls"
"fmt"
"log/slog"
"net"
"net/http"
Expand Down Expand Up @@ -57,14 +58,6 @@ func ServeTLSWithController(ctx context.Context, log *slog.Logger, certProvider
host = hostWithoutPort
}

isLocalhost := host == "localhost" || host == "127.0.0.1" || host == "::1"
isIPAddress := net.ParseIP(host) != nil

if isLocalhost || isIPAddress {
h.ServeHTTP(w, r)
return
}

if r.Method != "GET" && r.Method != "HEAD" {
http.Error(w, "Use HTTPS", http.StatusBadRequest)
return
Expand Down Expand Up @@ -116,3 +109,53 @@ func ServeTLSWithController(ctx context.Context, log *slog.Logger, certProvider

return nil
}

// ServeTLSWithControllerOnAddr serves HTTPS on a single configurable address
// without binding port 80. Used by the behind-proxy-https ingress mode, where
// the public hostname lives at a proxy and Miren only handles the TLS leg.
// Because :80 is not bound, ACME HTTP-01 and TLS-ALPN-01 challenges cannot
// complete in this mode; certificates must come from DNS-01 ACME or be
// self-signed (use ServeTLSSelfSignedOnAddr for the self-signed case).
func ServeTLSWithControllerOnAddr(ctx context.Context, log *slog.Logger, certProvider CertificateProvider, h http.Handler, addr string) error {
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen %s: %w", addr, err)
}
return serveTLSOnListener(ctx, log, certProvider, h, ln)
}

func serveTLSOnListener(ctx context.Context, log *slog.Logger, certProvider CertificateProvider, h http.Handler, ln net.Listener) error {
log = log.With("module", "autotls", "mode", "controller", "addr", ln.Addr().String())
log.Info("serving TLS with certificate controller")

tlsConfig := &tls.Config{
GetCertificate: certProvider.GetCertificate,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"h2", "http/1.1"},
}

server := &http.Server{
Handler: h,
TLSConfig: tlsConfig,
ReadHeaderTimeout: 5 * time.Second,
}

go func() {
err := server.ServeTLS(ln, "", "")
if err != nil && err != http.ErrServerClosed {
log.Error("error serving HTTPS", "error", err)
}
}()

go func() {
<-ctx.Done()
log.Info("shutting down HTTPS server")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Error("HTTPS server shutdown error", "error", err)
}
}()

return nil
}
49 changes: 49 additions & 0 deletions components/autotls/selfsigned.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,55 @@ func ServeTLSSelfSigned(ctx context.Context, log *slog.Logger, h http.Handler) e
return nil
}

// ServeTLSSelfSignedOnAddr serves HTTPS on a single configurable address using
// an in-memory self-signed certificate. Unlike ServeTLSSelfSigned, it does not
// also bind port 80 for redirect, so it can sit behind a TLS-terminating proxy
// or run on a non-standard address without colliding with anything else.
func ServeTLSSelfSignedOnAddr(ctx context.Context, log *slog.Logger, h http.Handler, addr string) error {
log = log.With("module", "autotls", "mode", "self-signed", "addr", addr)
log.Info("serving TLS with self-signed certificate on custom address")

cert, err := generateSelfSignedCert()
if err != nil {
return fmt.Errorf("failed to generate self-signed certificate: %w", err)
}

ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen %s: %w", addr, err)
}

tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
}

server := &http.Server{
Handler: h,
TLSConfig: tlsConfig,
ReadHeaderTimeout: 5 * time.Second,
}

go func() {
err := server.ServeTLS(ln, "", "")
if err != nil && err != http.ErrServerClosed {
log.Error("error serving HTTPS", "error", err)
}
}()

go func() {
<-ctx.Done()
log.Info("shutting down HTTPS server")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Error("HTTPS server shutdown error", "error", err)
}
}()

return nil
}

// generateSelfSignedCert creates an in-memory self-signed certificate
func generateSelfSignedCert() (tls.Certificate, error) {
cert, _, _, err := generateSelfSignedCertWithPEM()
Expand Down
19 changes: 15 additions & 4 deletions configs/server.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ skip_client_config = false
# HTTP request timeout in seconds
http_request_timeout = 60

[ingress]
# Ingress mode: tls-autoprovision (default), behind-proxy-http, behind-proxy-https.
# See https://rfd.miren.garden/rfd/84 for details.
mode = "tls-autoprovision"

# Optional bind override (full host:port). Replaces the mode's default bind
# entirely. Rejected by validation under tls-autoprovision, where the
# :443 + :80 pair is structural for HTTP-01 ACME.
# Example: address = "0.0.0.0:80"
# address = ""
Comment thread
coderabbitai[bot] marked this conversation as resolved.

[tls]
# Additional DNS names to include in the server certificate
# Example: ["miren.local", "*.miren.local"]
Expand All @@ -50,9 +61,6 @@ additional_names = []
# Example: ["10.0.0.1", "192.168.1.100"]
additional_ips = []

# Expose the HTTP ingress on standard TLS ports (443)
standard_tls = false

[etcd]
# Etcd endpoints for distributed mode
# In standalone mode, these are ignored and local etcd is used
Expand Down Expand Up @@ -111,10 +119,13 @@ socket_path = ""
# address = "0.0.0.0:8443"
# data_path = "/data/miren"
#
# [ingress]
# mode = "tls-autoprovision"
#
# [tls]
# additional_names = ["miren.prod.example.com", "*.miren.prod.example.com"]
# additional_ips = ["10.0.1.10", "10.0.2.10"]
# standard_tls = true
# acme_email = "ops@example.com"
#
# [etcd]
# endpoints = ["https://etcd-1:2379", "https://etcd-2:2379"]
Expand Down
4 changes: 3 additions & 1 deletion docs/docs/command/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ miren server [flags]
- `--etcd-peer-port` — Etcd peer port
- `--etcd-prefix, -p` — Etcd prefix
- `--http-request-timeout` — HTTP request timeout in seconds
- `--ingress-address` — Optional bind override. Replaces the mode's default bind entirely (interface and port). Rejected by validation in tls-autoprovision (where :443 + :80 is structural). Reserved unix:/path prefix is not yet supported.
- `--ingress-mode` — Ingress mode: tls-autoprovision (default, :443 + :80 with ACME or self-signed), behind-proxy-http (plain HTTP for use behind a TLS-terminating proxy), behind-proxy-https (TLS terminated by Miren; certs come from self-signed or DNS-01 ACME, since :80 isn't bound for HTTP-01)
- `--ips` — Additional IPs assigned to the server cert
- `--labs` — Comma-separated list of Miren Labs features to enable/disable. Prefix with - to disable.
- `--mode, -m` — Server mode: standalone (default), distributed (experimental)
Expand All @@ -44,7 +46,7 @@ miren server [flags]
- `--runner-address` — Runner address (host:port). For IPv6 use brackets, e.g. "[::1]:8444".
- `--runner-id, -r` — Runner ID
- `--self-signed-tls` — Use self-signed certificates for TLS (for development/testing only)
- `--serve-tls` — Expose the http ingress on standard TLS ports
- `--serve-tls` — Deprecated and ignored. Retained as a no-op so existing systemd unit files, env vars, and config files from pre-RFD-84 installs still parse. Use ingress.mode to pick the deployment shape.
- `--skip-client-config` — Skip writing client config file to clientconfig.d
- `--start-buildkit` — Start embedded BuildKit daemon for container image builds
- `--start-containerd` — Start embedded containerd daemon
Expand Down
32 changes: 27 additions & 5 deletions docs/docs/server-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@ data_path = "/var/lib/miren"
network_backend = "vxlan"
http_request_timeout = 60

[ingress]
mode = "tls-autoprovision"

[tls]
standard_tls = true
acme_email = "admin@example.com"

[etcd]
Expand Down Expand Up @@ -84,18 +86,38 @@ In standalone mode, embedded services start automatically unless explicitly disa
| `stop_sandboxes_on_shutdown` | bool | `false` | Stop all sandboxes when server shuts down (useful in development) | `MIREN_SERVER_STOP_SANDBOXES_ON_SHUTDOWN` | `--stop-sandboxes-on-shutdown` |
| `network_backend` | string | `vxlan` | Network backend: `vxlan` or `wireguard` | `MIREN_SERVER_NETWORK_BACKEND` | `--network-backend` |

## `[ingress]` — Ingress Settings {#ingress}

Selects the deployment shape for Miren's HTTP/HTTPS ingress. The mode determines where Miren listens and whether it terminates TLS. See [TLS](/tls) for cert sourcing under each mode.

| Field | Type | Default | Description | Env Var | CLI Flag |
|-------|------|---------|-------------|---------|----------|
| `mode` | string | `tls-autoprovision` | Ingress mode: `tls-autoprovision`, `behind-proxy-http`, or `behind-proxy-https` | `MIREN_INGRESS_MODE` | `--ingress-mode` |
| `address` | string | — | Optional bind override (full `host:port`). Replaces the mode's default bind entirely. Ignored under `tls-autoprovision`. | `MIREN_INGRESS_ADDRESS` | `--ingress-address` |

### Modes

| Mode | Default bind | TLS terminated | Cert source |
|------|--------------|----------------|-------------|
| `tls-autoprovision` (default) | `0.0.0.0:443` plus `:80` for redirect / HTTP-01 ACME | yes | `[tls]` (ACME or self-signed) |
| `behind-proxy-http` | `127.0.0.1:80` | no | n/a |
| `behind-proxy-https` | `127.0.0.1:443` | yes | `[tls]` (self-signed or DNS-01 ACME) |

The `behind-proxy-*` modes default to localhost to keep accidental misconfigurations from quietly exposing an internal endpoint to the network. Set `ingress.address = "0.0.0.0:80"` (or similar) explicitly when the proxy is on a different host.

`unix:/path` is reserved for a future release and rejected today with a clear error.

## `[tls]` — TLS Settings {#tls}

Controls TLS certificates for the server and HTTP ingress. See [TLS](/tls) for setup guides.
Controls cert sourcing for ingress modes that terminate TLS (`tls-autoprovision` and `behind-proxy-https`). Ignored under `behind-proxy-http`; populating these fields under that mode is an error. See [TLS](/tls) for setup guides.

| Field | Type | Default | Description | Env Var | CLI Flag |
|-------|------|---------|-------------|---------|----------|
| `additional_names` | string[] | `[]` | Extra DNS names for the server certificate | `MIREN_TLS_ADDITIONAL_NAMES` | `--dns-names` |
| `additional_ips` | string[] | `[]` | Extra IPs for the server certificate | `MIREN_TLS_ADDITIONAL_IPS` | `--ips` |
| `standard_tls` | bool | `true` | Expose HTTP ingress on standard TLS ports (443) | `MIREN_TLS_STANDARD_TLS` | `--serve-tls` |
| `acme_dns_provider` | string | — | DNS provider for ACME DNS-01 challenges (e.g. `cloudflare`, `route53`) | `MIREN_TLS_ACME_DNS_PROVIDER` | `--acme-dns-provider` |
| `acme_dns_provider` | string | — | DNS provider for ACME DNS-01 challenges (e.g. `cloudflare`, `route53`). Required under `behind-proxy-https` if not using `self_signed`. | `MIREN_TLS_ACME_DNS_PROVIDER` | `--acme-dns-provider` |
| `acme_email` | string | — | Email for ACME account registration | `MIREN_TLS_ACME_EMAIL` | `--acme-email` |
| `self_signed` | bool | `false` | Use self-signed certificates (development only) | `MIREN_TLS_SELF_SIGNED` | `--self-signed-tls` |
| `self_signed` | bool | `false` | Use self-signed certificates (development only, or behind a TLS-terminating proxy that doesn't verify) | `MIREN_TLS_SELF_SIGNED` | `--self-signed-tls` |

## `[etcd]` — Etcd Settings {#etcd}

Expand Down
20 changes: 17 additions & 3 deletions docs/docs/tls.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,29 @@ AWS_REGION=us-east-1

See the [lego DNS provider documentation](https://go-acme.github.io/lego/dns/) for the full list of supported providers and their required environment variables.

## Server Configuration Reference
## Ingress Modes and TLS

All TLS settings live under the `[tls]` section of the server config file (typically `/var/lib/miren/server/config.toml`):
Whether Miren terminates TLS at all (and on which ports) is set by `ingress.mode`. The default `tls-autoprovision` mode is what this page has been describing: TLS on `:443`, plus `:80` for the HTTPS redirect and HTTP-01 ACME challenges.

Two other modes are available for deployments where Miren sits behind a TLS-terminating proxy (nginx, Caddy, Cloudflare Tunnel, ALB):

| Mode | What Miren does | Cert source |
|------|-----------------|-------------|
| `tls-autoprovision` (default) | Binds `:443` for TLS and `:80` for redirect / HTTP-01 ACME | `[tls]` (ACME or self-signed) |
| `behind-proxy-http` | Plain HTTP at the configured address (default `127.0.0.1:80`); TLS lives at the proxy | n/a — `[tls]` is unused |
| `behind-proxy-https` | TLS terminated at the configured address (default `127.0.0.1:443`); no `:80` listener, so no HTTP-01 ACME | `[tls]` self-signed or DNS-01 ACME only |

See [Server Configuration Reference → `[ingress]`](/server-config#ingress) for the full schema. The HTTP-01 ACME flow described above only applies under `tls-autoprovision`; under `behind-proxy-https`, certs must come from DNS-01 ACME or be self-signed because Miren doesn't bind `:80` in that mode (and the public DNS for the hostname points at the proxy anyway, not at Miren).

## TLS Settings Reference

All TLS settings live under the `[tls]` section of the server config file (typically `/var/lib/miren/server/config.toml`). Consulted only under TLS-terminating ingress modes:

| Setting | CLI Flag | Description |
|---------|----------|-------------|
| `acme_email` | `--acme-email` | Email for Let's Encrypt account registration and expiry notifications |
| `acme_dns_provider` | `--acme-dns-provider` | DNS provider name for DNS-01 challenges (e.g., `cloudflare`, `route53`, `dnsimple`) |
| `standard_tls` | `--serve-tls` | Enable TLS on ports 443/80 (default: `true`) |
| `self_signed` | `--self-signed-tls` | Use a self-signed cert instead of ACME (development, or behind a non-verifying TLS proxy) |

## Troubleshooting

Expand Down
Loading
Loading