From 2754932517169c1838d8bf63e30e710e3c342bb3 Mon Sep 17 00:00:00 2001 From: Paul Hinze Date: Mon, 11 May 2026 14:12:34 -0500 Subject: [PATCH 1/7] Reshape ingress and TLS config schema (RFD-84) Introduces the [ingress] section with mode and address fields, and retires tls.standard_tls. The mode is a three-value enum (tls-autoprovision, behind-proxy-http, behind-proxy-https) that names the supported deployment shapes directly so invalid combinations become unrepresentable rather than rejected at load time. The cross-field validation that the previous shape required (warning when [tls] knobs become no-ops, the HTTP-01-cant-pair-with-custom-https rule, the mutual-exclusion check between two address fields) collapses to a small ValidateIngressCoherence method: enforce that [tls] is empty under behind-proxy-http, reject the reserved-but-unsupported unix: address prefix with a clear message, and require that ingress.address remain empty under tls-autoprovision since the :443 + :80 pair is structural for HTTP-01 ACME. Constants in ingress_modes.go give callers and downstream code a typed name to switch on rather than bare strings. See https://github.com/mirendev/rfd/pull/134 for the design and rationale. --- pkg/serverconfig/cli.gen.go | 3 +- pkg/serverconfig/config.gen.go | 49 +++++++++---- pkg/serverconfig/defaults.gen.go | 10 ++- pkg/serverconfig/env.gen.go | 28 ++++---- pkg/serverconfig/ingress_modes.go | 11 +++ pkg/serverconfig/loader.gen.go | 12 ++-- pkg/serverconfig/schema.yml | 39 +++++++--- pkg/serverconfig/validate.go | 60 ++++++++++++++++ pkg/serverconfig/validate_test.go | 111 +++++++++++++++++++++++++++++ pkg/serverconfig/validation.gen.go | 24 +++++++ 10 files changed, 304 insertions(+), 43 deletions(-) create mode 100644 pkg/serverconfig/ingress_modes.go create mode 100644 pkg/serverconfig/validate.go create mode 100644 pkg/serverconfig/validate_test.go diff --git a/pkg/serverconfig/cli.gen.go b/pkg/serverconfig/cli.gen.go index 8417672df..61d8abbc6 100644 --- a/pkg/serverconfig/cli.gen.go +++ b/pkg/serverconfig/cli.gen.go @@ -22,6 +22,8 @@ type CLIFlags struct { EtcdConfigPeerPort *int `long:"etcd-peer-port" description:"Etcd peer port"` EtcdConfigPrefix *string `long:"etcd-prefix" short:"p" description:"Etcd prefix"` EtcdConfigStartEmbedded *bool `long:"start-etcd" description:"Start embedded etcd server"` + IngressConfigAddress *string `long:"ingress-address" description:"Optional bind override. Replaces the mode's default bind entirely (interface and port). Ignored for tls-autoprovision. Reserved unix:/path prefix is not yet supported."` + IngressConfigMode *string `long:"ingress-mode" description:"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 with externally-provided certs, no ACME provisioning)"` ServerConfigAddress *string `long:"address" short:"a" description:"Address to listen on (host:port). For IPv6 use brackets, e.g. \"[::1]:8443\"."` ServerConfigConfigClusterName *string `long:"config-cluster-name" short:"C" description:"Name of the cluster in client config"` ServerConfigDataPath *string `long:"data-path" short:"d" description:"Data path"` @@ -38,7 +40,6 @@ type CLIFlags struct { TLSConfigAdditionalIPs []string `long:"ips" description:"Additional IPs assigned to the server cert"` TLSConfigAdditionalNames []string `long:"dns-names" description:"Additional DNS names assigned to the server cert"` TLSConfigSelfSigned *bool `long:"self-signed-tls" description:"Use self-signed certificates for TLS (for development/testing only)"` - TLSConfigStandardTLS *bool `long:"serve-tls" description:"Expose the http ingress on standard TLS ports"` VictoriaLogsConfigAddress *string `long:"victorialogs-addr" description:"VictoriaLogs address (when not using embedded)"` VictoriaLogsConfigHTTPPort *int `long:"victorialogs-http-port" description:"VictoriaLogs HTTP port in embedded mode"` VictoriaLogsConfigRetentionPeriod *string `long:"victorialogs-retention" description:"VictoriaLogs retention period (e.g. 30d, 2w, 1y)"` diff --git a/pkg/serverconfig/config.gen.go b/pkg/serverconfig/config.gen.go index dc3e4aa60..34abbf9fc 100644 --- a/pkg/serverconfig/config.gen.go +++ b/pkg/serverconfig/config.gen.go @@ -85,6 +85,7 @@ type Config struct { Buildkit BuildkitConfig `toml:"buildkit"` Containerd ContainerdConfig `toml:"containerd"` Etcd EtcdConfig `toml:"etcd"` + Ingress IngressConfig `toml:"ingress"` Labs []string `toml:"labs" env:"MIREN_LABS"` Mode *string `toml:"mode" env:"MIREN_MODE"` Server ServerConfig `toml:"server"` @@ -227,6 +228,38 @@ func (c *EtcdConfig) SetStartEmbedded(v bool) { c.StartEmbedded = &v } +// IngressConfig HTTP/HTTPS ingress configuration. See RFD-84 for the mode-based design. +type IngressConfig struct { + Address *string `toml:"address" env:"MIREN_INGRESS_ADDRESS"` + Mode *string `toml:"mode" env:"MIREN_INGRESS_MODE"` +} + +// GetAddress returns the value of Address or its zero value if nil +func (c *IngressConfig) GetAddress() string { + if c.Address != nil { + return *c.Address + } + return "" +} + +// SetAddress sets the value of Address +func (c *IngressConfig) SetAddress(v string) { + c.Address = &v +} + +// GetMode returns the value of Mode or its zero value if nil +func (c *IngressConfig) GetMode() string { + if c.Mode != nil { + return *c.Mode + } + return "" +} + +// SetMode sets the value of Mode +func (c *IngressConfig) SetMode(v string) { + c.Mode = &v +} + // ServerConfig Core server settings type ServerConfig struct { Address *string `toml:"address" env:"MIREN_SERVER_ADDRESS"` @@ -385,14 +418,13 @@ func (c *ServerConfig) SetStopSandboxesOnShutdown(v bool) { c.StopSandboxesOnShutdown = &v } -// TLSConfig TLS/certificate settings +// TLSConfig TLS certificate settings. Consulted only when ingress.mode is tls-autoprovision or behind-proxy-https. type TLSConfig struct { AcmeDNSProvider *string `toml:"acme_dns_provider" env:"MIREN_TLS_ACME_DNS_PROVIDER"` AcmeEmail *string `toml:"acme_email" env:"MIREN_TLS_ACME_EMAIL"` AdditionalIPs []string `toml:"additional_ips" env:"MIREN_TLS_ADDITIONAL_IPS"` AdditionalNames []string `toml:"additional_names" env:"MIREN_TLS_ADDITIONAL_NAMES"` SelfSigned *bool `toml:"self_signed" env:"MIREN_TLS_SELF_SIGNED"` - StandardTLS *bool `toml:"standard_tls" env:"MIREN_TLS_STANDARD_TLS"` } // GetAcmeDNSProvider returns the value of AcmeDNSProvider or its zero value if nil @@ -434,19 +466,6 @@ func (c *TLSConfig) SetSelfSigned(v bool) { c.SelfSigned = &v } -// GetStandardTLS returns the value of StandardTLS or its zero value if nil -func (c *TLSConfig) GetStandardTLS() bool { - if c.StandardTLS != nil { - return *c.StandardTLS - } - return false -} - -// SetStandardTLS sets the value of StandardTLS -func (c *TLSConfig) SetStandardTLS(v bool) { - c.StandardTLS = &v -} - // VictoriaLogsConfig VictoriaLogs configuration type VictoriaLogsConfig struct { Address *string `toml:"address" env:"MIREN_VICTORIALOGS_ADDRESS"` diff --git a/pkg/serverconfig/defaults.gen.go b/pkg/serverconfig/defaults.gen.go index 4e01a7cc4..64347c17d 100644 --- a/pkg/serverconfig/defaults.gen.go +++ b/pkg/serverconfig/defaults.gen.go @@ -13,6 +13,7 @@ func DefaultConfig() *Config { Buildkit: DefaultBuildkitConfig(), Containerd: DefaultContainerdConfig(), Etcd: DefaultEtcdConfig(), + Ingress: DefaultIngressConfig(), Labs: []string{}, Mode: strPtr("standalone"), Server: DefaultServerConfig(), @@ -54,6 +55,14 @@ func DefaultEtcdConfig() EtcdConfig { } } +// DefaultIngressConfig returns default IngressConfig +func DefaultIngressConfig() IngressConfig { + return IngressConfig{ + Address: strPtr(""), + Mode: strPtr("tls-autoprovision"), + } +} + // DefaultServerConfig returns default ServerConfig func DefaultServerConfig() ServerConfig { return ServerConfig{ @@ -79,7 +88,6 @@ func DefaultTLSConfig() TLSConfig { AdditionalIPs: []string{}, AdditionalNames: []string{}, SelfSigned: boolPtr(false), - StandardTLS: boolPtr(true), } } diff --git a/pkg/serverconfig/env.gen.go b/pkg/serverconfig/env.gen.go index 67aca72bb..3620f0e59 100644 --- a/pkg/serverconfig/env.gen.go +++ b/pkg/serverconfig/env.gen.go @@ -194,6 +194,22 @@ func applyEnvironmentVariables(cfg *Config, log *slog.Logger) error { } + // Apply MIREN_INGRESS_ADDRESS + if val := os.Getenv("MIREN_INGRESS_ADDRESS"); val != "" { + + cfg.Ingress.Address = &val + log.Debug("applied env var", "key", "MIREN_INGRESS_ADDRESS") + + } + + // Apply MIREN_INGRESS_MODE + if val := os.Getenv("MIREN_INGRESS_MODE"); val != "" { + + cfg.Ingress.Mode = &val + log.Debug("applied env var", "key", "MIREN_INGRESS_MODE") + + } + // Apply MIREN_SERVER_ADDRESS if val := os.Getenv("MIREN_SERVER_ADDRESS"); val != "" { @@ -368,18 +384,6 @@ func applyEnvironmentVariables(cfg *Config, log *slog.Logger) error { } - // Apply MIREN_TLS_STANDARD_TLS - if val := os.Getenv("MIREN_TLS_STANDARD_TLS"); val != "" { - - if b, err := strconv.ParseBool(val); err == nil { - cfg.TLS.StandardTLS = &b - log.Debug("applied env var", "key", "MIREN_TLS_STANDARD_TLS") - } else { - log.Warn("invalid MIREN_TLS_STANDARD_TLS value", "value", val, "error", err) - } - - } - // Apply MIREN_VICTORIALOGS_ADDRESS if val := os.Getenv("MIREN_VICTORIALOGS_ADDRESS"); val != "" { diff --git a/pkg/serverconfig/ingress_modes.go b/pkg/serverconfig/ingress_modes.go new file mode 100644 index 000000000..8b35987ad --- /dev/null +++ b/pkg/serverconfig/ingress_modes.go @@ -0,0 +1,11 @@ +package serverconfig + +// Ingress mode values. Keep these in sync with the enum on IngressConfig.mode +// in schema.yml (the schema is the source of truth for valid values; these +// constants exist so callers can refer to modes by name in switch statements +// and validation code instead of bare strings). +const ( + IngressModeAutoprovision = "tls-autoprovision" + IngressModeBehindProxyHTTP = "behind-proxy-http" + IngressModeBehindProxyHTTPS = "behind-proxy-https" +) diff --git a/pkg/serverconfig/loader.gen.go b/pkg/serverconfig/loader.gen.go index acae847b6..b33ee1557 100644 --- a/pkg/serverconfig/loader.gen.go +++ b/pkg/serverconfig/loader.gen.go @@ -214,6 +214,14 @@ func applyCLIFlags(cfg *Config, flags *CLIFlags) { cfg.Etcd.StartEmbedded = flags.EtcdConfigStartEmbedded } + if flags.IngressConfigAddress != nil && *flags.IngressConfigAddress != "" { + cfg.Ingress.Address = flags.IngressConfigAddress + } + + if flags.IngressConfigMode != nil && *flags.IngressConfigMode != "" { + cfg.Ingress.Mode = flags.IngressConfigMode + } + if flags.ServerConfigAddress != nil && *flags.ServerConfigAddress != "" { cfg.Server.Address = flags.ServerConfigAddress } @@ -278,10 +286,6 @@ func applyCLIFlags(cfg *Config, flags *CLIFlags) { cfg.TLS.SelfSigned = flags.TLSConfigSelfSigned } - if flags.TLSConfigStandardTLS != nil { - cfg.TLS.StandardTLS = flags.TLSConfigStandardTLS - } - if flags.VictoriaLogsConfigAddress != nil && *flags.VictoriaLogsConfigAddress != "" { cfg.Victorialogs.Address = flags.VictoriaLogsConfigAddress } diff --git a/pkg/serverconfig/schema.yml b/pkg/serverconfig/schema.yml index 9ef27c586..d3ad381d2 100644 --- a/pkg/serverconfig/schema.yml +++ b/pkg/serverconfig/schema.yml @@ -37,6 +37,11 @@ configs: toml: server nested: true + ingress: + type: IngressConfig + toml: ingress + nested: true + tls: type: TLSConfig toml: tls @@ -183,8 +188,31 @@ configs: validation: enum: ["", auto, universal, accelerator] + IngressConfig: + description: HTTP/HTTPS ingress configuration. See RFD-84 for the mode-based design. + fields: + mode: + type: string + default: tls-autoprovision + cli: + long: ingress-mode + description: "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 with externally-provided certs, no ACME provisioning)" + env: MIREN_INGRESS_MODE + toml: mode + validation: + enum: [tls-autoprovision, behind-proxy-http, behind-proxy-https] + + address: + type: string + default: "" + cli: + long: ingress-address + description: "Optional bind override. Replaces the mode's default bind entirely (interface and port). Ignored for tls-autoprovision. Reserved unix:/path prefix is not yet supported." + env: MIREN_INGRESS_ADDRESS + toml: address + TLSConfig: - description: TLS/certificate settings + description: TLS certificate settings. Consulted only when ingress.mode is tls-autoprovision or behind-proxy-https. fields: additional_names: type: "[]string" @@ -206,15 +234,6 @@ configs: validation: format: ip_list - standard_tls: - type: bool - default: true - cli: - long: serve-tls - description: Expose the http ingress on standard TLS ports - env: MIREN_TLS_STANDARD_TLS - toml: standard_tls - acme_dns_provider: type: string default: "" diff --git a/pkg/serverconfig/validate.go b/pkg/serverconfig/validate.go new file mode 100644 index 000000000..87c95683f --- /dev/null +++ b/pkg/serverconfig/validate.go @@ -0,0 +1,60 @@ +package serverconfig + +import ( + "fmt" + "net" + "strings" +) + +// ValidateIngressCoherence runs cross-field validations on top of the generated +// Config.Validate(). Callers invoke it right after Load() so operators see +// configuration errors before any listener is wired. +// +// Two concerns live here: +// +// 1. ingress.address format checks, including a clear-message rejection of the +// reserved-but-not-yet-supported unix:/path form (see RFD-84). +// 2. Coherence between ingress.mode and the [tls] block: behind-proxy-http +// does not consult [tls] at all, so populating those fields is almost +// certainly an operator mistake. Hard-error rather than silently ignore. +func (c *Config) ValidateIngressCoherence() error { + mode := c.Ingress.GetMode() + addr := c.Ingress.GetAddress() + + if addr != "" { + if strings.HasPrefix(addr, "unix:") { + return fmt.Errorf("ingress.address: unix socket binding (%q) is reserved for a future release; use a host:port form for now", addr) + } + if _, _, err := net.SplitHostPort(addr); err != nil { + return fmt.Errorf("ingress.address %q: must be a host:port form (e.g. \"0.0.0.0:80\", \"127.0.0.1:443\", \"[::1]:8080\"): %w", addr, err) + } + } + + if mode == IngressModeAutoprovision && addr != "" { + return fmt.Errorf("ingress.address must be empty when ingress.mode = %q; autoprovision binds :443 + :80 structurally to support HTTP-01 ACME challenges", mode) + } + + if mode == IngressModeBehindProxyHTTP { + var populated []string + if c.TLS.GetSelfSigned() { + populated = append(populated, "tls.self_signed") + } + if c.TLS.GetAcmeEmail() != "" { + populated = append(populated, "tls.acme_email") + } + if c.TLS.GetAcmeDNSProvider() != "" { + populated = append(populated, "tls.acme_dns_provider") + } + if len(c.TLS.AdditionalIPs) > 0 { + populated = append(populated, "tls.additional_ips") + } + if len(c.TLS.AdditionalNames) > 0 { + populated = append(populated, "tls.additional_names") + } + if len(populated) > 0 { + return fmt.Errorf("ingress.mode = %q does not terminate TLS, but the following [tls] fields are set and would be ignored: %s. Either remove them or pick a TLS-terminating mode (tls-autoprovision, behind-proxy-https)", mode, strings.Join(populated, ", ")) + } + } + + return nil +} diff --git a/pkg/serverconfig/validate_test.go b/pkg/serverconfig/validate_test.go new file mode 100644 index 000000000..595c11c0c --- /dev/null +++ b/pkg/serverconfig/validate_test.go @@ -0,0 +1,111 @@ +package serverconfig + +import ( + "strings" + "testing" +) + +func TestValidateIngressCoherence(t *testing.T) { + type setup func(*Config) + + cases := []struct { + name string + setup setup + wantContains string // empty means no error + }{ + { + name: "default config is valid", + setup: func(c *Config) { + c.Ingress.SetMode(IngressModeAutoprovision) + }, + }, + { + name: "tls-autoprovision rejects address override", + setup: func(c *Config) { + c.Ingress.SetMode(IngressModeAutoprovision) + c.Ingress.SetAddress("0.0.0.0:443") + }, + wantContains: "ingress.address must be empty", + }, + { + name: "behind-proxy-https accepts default (empty) address", + setup: func(c *Config) { + c.Ingress.SetMode(IngressModeBehindProxyHTTPS) + c.TLS.SetSelfSigned(true) + }, + }, + { + name: "behind-proxy-https accepts custom address", + setup: func(c *Config) { + c.Ingress.SetMode(IngressModeBehindProxyHTTPS) + c.Ingress.SetAddress("127.0.0.1:8443") + c.TLS.SetSelfSigned(true) + }, + }, + { + name: "behind-proxy-http accepts plain address", + setup: func(c *Config) { + c.Ingress.SetMode(IngressModeBehindProxyHTTP) + c.Ingress.SetAddress("0.0.0.0:80") + }, + }, + { + name: "behind-proxy-http rejects populated tls.self_signed", + setup: func(c *Config) { + c.Ingress.SetMode(IngressModeBehindProxyHTTP) + c.TLS.SetSelfSigned(true) + }, + wantContains: "tls.self_signed", + }, + { + name: "behind-proxy-http rejects populated tls.acme_email", + setup: func(c *Config) { + c.Ingress.SetMode(IngressModeBehindProxyHTTP) + c.TLS.SetAcmeEmail("ops@example.com") + }, + wantContains: "tls.acme_email", + }, + { + name: "behind-proxy-http reports all populated tls fields", + setup: func(c *Config) { + c.Ingress.SetMode(IngressModeBehindProxyHTTP) + c.TLS.SetAcmeEmail("ops@example.com") + c.TLS.SetAcmeDNSProvider("cloudflare") + }, + wantContains: "tls.acme_email, tls.acme_dns_provider", + }, + { + name: "rejects unix: address with clear message", + setup: func(c *Config) { + c.Ingress.SetMode(IngressModeBehindProxyHTTP) + c.Ingress.SetAddress("unix:/var/run/miren.sock") + }, + wantContains: "unix socket binding", + }, + { + name: "rejects malformed address", + setup: func(c *Config) { + c.Ingress.SetMode(IngressModeBehindProxyHTTP) + c.Ingress.SetAddress("not-a-real-address") + }, + wantContains: "must be a host:port form", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := DefaultConfig() + tc.setup(cfg) + + err := cfg.ValidateIngressCoherence() + switch { + case tc.wantContains == "" && err != nil: + t.Fatalf("unexpected error: %v", err) + case tc.wantContains != "" && err == nil: + t.Fatalf("expected error containing %q, got nil", tc.wantContains) + case tc.wantContains != "" && !strings.Contains(err.Error(), tc.wantContains): + t.Fatalf("error %q does not contain %q", err.Error(), tc.wantContains) + } + }) + } +} diff --git a/pkg/serverconfig/validation.gen.go b/pkg/serverconfig/validation.gen.go index 60ce8fa98..ee3fa112b 100644 --- a/pkg/serverconfig/validation.gen.go +++ b/pkg/serverconfig/validation.gen.go @@ -22,6 +22,10 @@ func (c *Config) Validate() error { if err := c.Etcd.Validate(); err != nil { return fmt.Errorf("etcd: %w", err) } + + if err := c.Ingress.Validate(); err != nil { + return fmt.Errorf("ingress: %w", err) + } // Validate mode if c.Mode != nil { validModes := map[string]bool{ @@ -114,6 +118,26 @@ func (c *EtcdConfig) Validate() error { return nil } +// Validate validates IngressConfig +func (c *IngressConfig) Validate() error { + + // Validate mode enum + if c.Mode != nil { + validMode := map[string]bool{ + "tls-autoprovision": true, + "behind-proxy-http": true, + "behind-proxy-https": true, + } + if !validMode[*c.Mode] { + return fmt.Errorf("invalid mode %q: must be one of [tls-autoprovision behind-proxy-http behind-proxy-https]", *c.Mode) + } + } + + // Check for port conflicts in IngressConfig + + return nil +} + // Validate validates ServerConfig func (c *ServerConfig) Validate() error { From 3aa16033966c6cb341a63e07143d56fba80d9ecc Mon Sep 17 00:00:00 2001 From: Paul Hinze Date: Mon, 11 May 2026 14:12:44 -0500 Subject: [PATCH 2/7] Wire ingress modes and drop autoprovision localhost-Host exception (RFD-84) Replaces the GetStandardTLS branching in server.go with a switch on cfg.Ingress.GetMode(). Each non-autoprovision mode resolves its bind address from cfg.Ingress.GetAddress() (with a localhost default), and dispatches to the right serve function: - tls-autoprovision: existing ServeTLSWithController or ServeTLSSelfSigned (binds :443 + :80 as before) - behind-proxy-https: new ServeTLSWithControllerOnAddr or ServeTLSSelfSignedOnAddr (single TLS listener at the configured address; adopted from Jeff Casimir's PR #790 essentially unchanged) - behind-proxy-http: plain http.ListenAndServe at the configured address Also drops the Host-based exception in ServeTLSWithController's :80 handler that used to route localhost / 127.0.0.1 / ::1 / raw-IP Host requests directly to the default route app over plain HTTP. That was a dev-convenience hack with production security smell. Operators who want plain-HTTP access for dev workflow now pick behind-proxy-http explicitly; the autoprovision :80 listener now does only what it says (redirect + ACME). ValidateIngressCoherence runs right after Load() in both Server() and ServerConfigValidate() so config errors surface before any listener starts. Thanks to Jeff Casimir (https://github.com/jcasimir) for surfacing the use case in PRs #789/#790/#791. The autotls helpers in this commit are the OnAddr variants from his PR #790 essentially unchanged. --- cli/commands/server.go | 42 ++++++++++++++++++--- cli/commands/server_config_cmds.go | 3 ++ components/autotls/autotls.go | 59 ++++++++++++++++++++++++++---- components/autotls/selfsigned.go | 49 +++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 13 deletions(-) diff --git a/cli/commands/server.go b/cli/commands/server.go index 4e876b8f4..bb614e294 100644 --- a/cli/commands/server.go +++ b/cli/commands/server.go @@ -67,6 +67,9 @@ 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) + } // Initialize Miren Labs feature flags labs.Init(ctx.Log, cfg.Labs) @@ -825,9 +828,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 @@ -845,13 +848,42 @@ func Server(ctx *Context, opts serverconfig.CLIFlags) error { readyFn() } } - } else { + + 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" + } go func() { - err := http.ListenAndServe(":80", hs) + ctx.Log.Info("starting HTTP server", "addr", addr) + err := http.ListenAndServe(addr, hs) if err != nil { - ctx.Log.Error("failed to start HTTP server", "error", err) + ctx.Log.Error("failed to start HTTP server", "addr", addr, "error", err) } }() + + 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) diff --git a/cli/commands/server_config_cmds.go b/cli/commands/server_config_cmds.go index 5e1f7affb..8ef28627f 100644 --- a/cli/commands/server_config_cmds.go +++ b/cli/commands/server_config_cmds.go @@ -60,6 +60,9 @@ 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) + } ctx.UILog.Info("Configuration is valid", "file", opts.ConfigFile) diff --git a/components/autotls/autotls.go b/components/autotls/autotls.go index 8a5c677e3..b777cdc2f 100644 --- a/components/autotls/autotls.go +++ b/components/autotls/autotls.go @@ -3,6 +3,7 @@ package autotls import ( "context" "crypto/tls" + "fmt" "log/slog" "net" "net/http" @@ -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 @@ -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 +} diff --git a/components/autotls/selfsigned.go b/components/autotls/selfsigned.go index 74887e833..694deb49f 100644 --- a/components/autotls/selfsigned.go +++ b/components/autotls/selfsigned.go @@ -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() From 209073e173d4a1d0222a8fb92fe9109adcb63fae Mon Sep 17 00:00:00 2001 From: Paul Hinze Date: Mon, 11 May 2026 17:56:48 -0500 Subject: [PATCH 3/7] docs: update tls.md and server-config.md for ingress modes (RFD-84) - server-config.md: new [ingress] section documenting mode + address, with the three-mode reference table. [tls] section reframed as cert-only and no longer lists the removed standard_tls field. - tls.md: new "Ingress Modes and TLS" section that introduces the three modes and how cert sourcing varies across them. Old TLS reference table no longer mentions standard_tls. - command/server.md: regenerated to pick up --ingress-mode and --ingress-address flags and drop --serve-tls. --- docs/docs/command/server.md | 3 ++- docs/docs/server-config.md | 32 +++++++++++++++++++++++++++----- docs/docs/tls.md | 20 +++++++++++++++++--- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/docs/docs/command/server.md b/docs/docs/command/server.md index c6abbcef8..d544db336 100644 --- a/docs/docs/command/server.md +++ b/docs/docs/command/server.md @@ -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). Ignored for tls-autoprovision. 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 with externally-provided certs, no ACME provisioning) - `--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) @@ -44,7 +46,6 @@ 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 - `--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 diff --git a/docs/docs/server-config.md b/docs/docs/server-config.md index 001483d09..c1d262323 100644 --- a/docs/docs/server-config.md +++ b/docs/docs/server-config.md @@ -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] @@ -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} diff --git a/docs/docs/tls.md b/docs/docs/tls.md index 1f16f83a4..5752f9dd8 100644 --- a/docs/docs/tls.md +++ b/docs/docs/tls.md @@ -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 From b0645bd46346ad17d1367f31127cd162d5bd4c09 Mon Sep 17 00:00:00 2001 From: Paul Hinze Date: Mon, 11 May 2026 18:02:01 -0500 Subject: [PATCH 4/7] Remove --serve-tls/standard_tls callsites left over from the schema reshape The systemd install path, the embedded systemd entrypoint test script, and the example server.toml all still referenced the removed flag and field. Drop the explicit --serve-tls since tls-autoprovision (the default mode) is exactly what those callers want. Update the example config to point at the new [ingress] section and link RFD-84. --- cli/commands/server_install.go | 8 +++++--- configs/server.toml.example | 18 ++++++++++++++---- hack/systemd/entrypoint.sh | 2 +- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/cli/commands/server_install.go b/cli/commands/server_install.go index 84a0ecd95..af747cc13 100644 --- a/cli/commands/server_install.go +++ b/cli/commands/server_install.go @@ -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, " ") @@ -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 diff --git a/configs/server.toml.example b/configs/server.toml.example index 5cca10352..395ac4019 100644 --- a/configs/server.toml.example +++ b/configs/server.toml.example @@ -41,6 +41,16 @@ 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. Ignored under tls-autoprovision. +# Example: address = "0.0.0.0:80" +# address = "" + [tls] # Additional DNS names to include in the server certificate # Example: ["miren.local", "*.miren.local"] @@ -50,9 +60,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 @@ -111,10 +118,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"] diff --git a/hack/systemd/entrypoint.sh b/hack/systemd/entrypoint.sh index 825c411e3..c9977ab2a 100755 --- a/hack/systemd/entrypoint.sh +++ b/hack/systemd/entrypoint.sh @@ -68,7 +68,7 @@ Wants=network-online.target [Service] Type=simple Environment="NO_COLOR=1" -ExecStart=/var/lib/miren/release/miren server -vv --address=0.0.0.0:8443 --serve-tls +ExecStart=/var/lib/miren/release/miren server -vv --address=0.0.0.0:8443 Restart=always RestartSec=10 StandardOutput=journal From 64ae06b10745e7da30e8ed81ec0ce09ce81a104d Mon Sep 17 00:00:00 2001 From: Paul Hinze Date: Tue, 12 May 2026 10:19:31 -0500 Subject: [PATCH 5/7] Address CodeRabbit review on #799 Four findings, all real: - behind-proxy-http listener now goes through the existing errgroup (eg.Go) for both serve and graceful shutdown, so startup failures propagate via eg.Wait() instead of getting logged and ignored. The fire-and-forget pattern is still present in the autotls helpers; that's a separate cleanup to do across all the ingress paths in a follow-up. - Help text and config example said 'ignored' under tls-autoprovision for ingress.address, but the validator hard-errors. Fixed both the schema description and the example comment to say 'rejected by validation' so users know what to expect. - behind-proxy-https mode description claimed 'no ACME provisioning', which conflicts with DNS-01 support called out in the RFD and in ValidateIngressCoherence. Softened to 'certs from self-signed or DNS-01 ACME, since :80 isn't bound for HTTP-01'. cli.gen.go regenerated from the schema changes. --- cli/commands/server.go | 17 ++++++++++++----- configs/server.toml.example | 3 ++- pkg/serverconfig/cli.gen.go | 4 ++-- pkg/serverconfig/schema.yml | 4 ++-- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/cli/commands/server.go b/cli/commands/server.go index bb614e294..9bea91144 100644 --- a/cli/commands/server.go +++ b/cli/commands/server.go @@ -874,13 +874,20 @@ func Server(ctx *Context, opts serverconfig.CLIFlags) error { if addr == "" { addr = "127.0.0.1:80" } - go func() { + httpSrv := &http.Server{Addr: addr, Handler: hs} + eg.Go(func() error { ctx.Log.Info("starting HTTP server", "addr", addr) - err := http.ListenAndServe(addr, hs) - if err != nil { - ctx.Log.Error("failed to start HTTP server", "addr", addr, "error", err) + 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) diff --git a/configs/server.toml.example b/configs/server.toml.example index 395ac4019..3cb34606b 100644 --- a/configs/server.toml.example +++ b/configs/server.toml.example @@ -47,7 +47,8 @@ http_request_timeout = 60 mode = "tls-autoprovision" # Optional bind override (full host:port). Replaces the mode's default bind -# entirely. Ignored under tls-autoprovision. +# 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 = "" diff --git a/pkg/serverconfig/cli.gen.go b/pkg/serverconfig/cli.gen.go index 61d8abbc6..c58329007 100644 --- a/pkg/serverconfig/cli.gen.go +++ b/pkg/serverconfig/cli.gen.go @@ -22,8 +22,8 @@ type CLIFlags struct { EtcdConfigPeerPort *int `long:"etcd-peer-port" description:"Etcd peer port"` EtcdConfigPrefix *string `long:"etcd-prefix" short:"p" description:"Etcd prefix"` EtcdConfigStartEmbedded *bool `long:"start-etcd" description:"Start embedded etcd server"` - IngressConfigAddress *string `long:"ingress-address" description:"Optional bind override. Replaces the mode's default bind entirely (interface and port). Ignored for tls-autoprovision. Reserved unix:/path prefix is not yet supported."` - IngressConfigMode *string `long:"ingress-mode" description:"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 with externally-provided certs, no ACME provisioning)"` + IngressConfigAddress *string `long:"ingress-address" description:"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."` + IngressConfigMode *string `long:"ingress-mode" description:"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)"` ServerConfigAddress *string `long:"address" short:"a" description:"Address to listen on (host:port). For IPv6 use brackets, e.g. \"[::1]:8443\"."` ServerConfigConfigClusterName *string `long:"config-cluster-name" short:"C" description:"Name of the cluster in client config"` ServerConfigDataPath *string `long:"data-path" short:"d" description:"Data path"` diff --git a/pkg/serverconfig/schema.yml b/pkg/serverconfig/schema.yml index d3ad381d2..a0325881d 100644 --- a/pkg/serverconfig/schema.yml +++ b/pkg/serverconfig/schema.yml @@ -196,7 +196,7 @@ configs: default: tls-autoprovision cli: long: ingress-mode - description: "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 with externally-provided certs, no ACME provisioning)" + description: "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)" env: MIREN_INGRESS_MODE toml: mode validation: @@ -207,7 +207,7 @@ configs: default: "" cli: long: ingress-address - description: "Optional bind override. Replaces the mode's default bind entirely (interface and port). Ignored for tls-autoprovision. Reserved unix:/path prefix is not yet supported." + description: "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." env: MIREN_INGRESS_ADDRESS toml: address From 07d65f4c580f6ddd1fcc547a517c00b6d1ccb9d0 Mon Sep 17 00:00:00 2001 From: Paul Hinze Date: Tue, 12 May 2026 10:32:10 -0500 Subject: [PATCH 6/7] Regenerate server.md docs for updated --ingress-* descriptions The previous commit updated the schema descriptions but rebuilt with a stale binary, so the generated docs/docs/command/server.md still had the old text. CI's go-generate-check caught it. --- docs/docs/command/server.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/command/server.md b/docs/docs/command/server.md index d544db336..5caacc9d1 100644 --- a/docs/docs/command/server.md +++ b/docs/docs/command/server.md @@ -36,8 +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). Ignored for tls-autoprovision. 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 with externally-provided certs, no ACME provisioning) +- `--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) From 1145ee7c93d46250983108060156c9a3dbbddcdb Mon Sep 17 00:00:00 2001 From: Paul Hinze Date: Tue, 12 May 2026 13:08:16 -0500 Subject: [PATCH 7/7] Accept --serve-tls / standard_tls as a deprecated no-op for upgrade safety Per Evan's review on #799: existing systemd unit files from pre-RFD-84 installs pass --serve-tls in ExecStart, and operators may have standard_tls = true in their config.toml or MIREN_TLS_STANDARD_TLS set in their environment. The previous commit removed the field outright, which would break those upgrades by either rejecting the unknown flag or (for env/TOML cases) silently dropping a value the operator expected to take effect. Re-adding the field as a real (non-cli_only) bool on TLSConfig so: - TOML standard_tls = true parses cleanly (go-toml/v2 is lenient about unknown fields anyway, but the explicit field is clearer) - MIREN_TLS_STANDARD_TLS env var is read into the field - --serve-tls CLI flag is accepted The field is never read by any code path. ingress.mode is the source of truth for the deployment shape. A deprecation warning fires at startup when the field is explicitly set (i.e. StandardTLS != nil), pointing operators at RFD-84. Also extended configgen with a cli.hidden schema knob and used it here. Note: mflags (Miren's CLI library) doesn't currently read the hidden tag, so --serve-tls still appears in --help. The 'Deprecated and ignored' description is loud enough on its own; if we want true hiding later, that's a small mflags PR. --- cli/commands/server.go | 1 + cli/commands/server_config_cmds.go | 1 + docs/docs/command/server.md | 1 + pkg/serverconfig/cli.gen.go | 1 + pkg/serverconfig/cmd/configgen/main.go | 3 ++- pkg/serverconfig/config.gen.go | 14 +++++++++++++ pkg/serverconfig/defaults.gen.go | 1 + pkg/serverconfig/env.gen.go | 12 ++++++++++++ pkg/serverconfig/loader.gen.go | 4 ++++ pkg/serverconfig/schema.yml | 9 +++++++++ pkg/serverconfig/toml_strict_test.go | 27 ++++++++++++++++++++++++++ pkg/serverconfig/validate.go | 14 +++++++++++++ 12 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 pkg/serverconfig/toml_strict_test.go diff --git a/cli/commands/server.go b/cli/commands/server.go index 9bea91144..b4229e6e7 100644 --- a/cli/commands/server.go +++ b/cli/commands/server.go @@ -70,6 +70,7 @@ func Server(ctx *Context, opts serverconfig.CLIFlags) error { 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) diff --git a/cli/commands/server_config_cmds.go b/cli/commands/server_config_cmds.go index 8ef28627f..135f2c686 100644 --- a/cli/commands/server_config_cmds.go +++ b/cli/commands/server_config_cmds.go @@ -63,6 +63,7 @@ func ServerConfigValidate(ctx *Context, opts struct { 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) diff --git a/docs/docs/command/server.md b/docs/docs/command/server.md index 5caacc9d1..16b126002 100644 --- a/docs/docs/command/server.md +++ b/docs/docs/command/server.md @@ -46,6 +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` — 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 diff --git a/pkg/serverconfig/cli.gen.go b/pkg/serverconfig/cli.gen.go index c58329007..f8ee0594e 100644 --- a/pkg/serverconfig/cli.gen.go +++ b/pkg/serverconfig/cli.gen.go @@ -40,6 +40,7 @@ type CLIFlags struct { TLSConfigAdditionalIPs []string `long:"ips" description:"Additional IPs assigned to the server cert"` TLSConfigAdditionalNames []string `long:"dns-names" description:"Additional DNS names assigned to the server cert"` TLSConfigSelfSigned *bool `long:"self-signed-tls" description:"Use self-signed certificates for TLS (for development/testing only)"` + TLSConfigStandardTLS *bool `long:"serve-tls" description:"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." hidden:"yes"` VictoriaLogsConfigAddress *string `long:"victorialogs-addr" description:"VictoriaLogs address (when not using embedded)"` VictoriaLogsConfigHTTPPort *int `long:"victorialogs-http-port" description:"VictoriaLogs HTTP port in embedded mode"` VictoriaLogsConfigRetentionPeriod *string `long:"victorialogs-retention" description:"VictoriaLogs retention period (e.g. 30d, 2w, 1y)"` diff --git a/pkg/serverconfig/cmd/configgen/main.go b/pkg/serverconfig/cmd/configgen/main.go index 95071e93a..e24b779cb 100644 --- a/pkg/serverconfig/cmd/configgen/main.go +++ b/pkg/serverconfig/cmd/configgen/main.go @@ -50,6 +50,7 @@ type CLIConfig struct { Long string `yaml:"long"` Short string `yaml:"short"` Description string `yaml:"description"` + Hidden bool `yaml:"hidden"` } // Validation represents field validation rules @@ -445,7 +446,7 @@ type CLIFlags struct { {{- range $cname, $config := .Configs}} {{- range $fname, $field := $config.Fields}} {{- if $field.CLI}} - {{if eq $cname "Config"}}{{$fname | title}}{{else}}{{$cname}}{{$fname | title}}{{end}} {{goType $field.Type}} ` + "`" + `{{if $field.CLI.Long}}long:"{{$field.CLI.Long}}"{{end}}{{if $field.CLI.Short}} short:"{{$field.CLI.Short}}"{{end}}{{if $field.CLI.Description}} description:"{{$field.CLI.Description | escapeTag}}"{{end}}` + "`" + ` + {{if eq $cname "Config"}}{{$fname | title}}{{else}}{{$cname}}{{$fname | title}}{{end}} {{goType $field.Type}} ` + "`" + `{{if $field.CLI.Long}}long:"{{$field.CLI.Long}}"{{end}}{{if $field.CLI.Short}} short:"{{$field.CLI.Short}}"{{end}}{{if $field.CLI.Description}} description:"{{$field.CLI.Description | escapeTag}}"{{end}}{{if $field.CLI.Hidden}} hidden:"yes"{{end}}` + "`" + ` {{- end}} {{- end}} {{- end}} diff --git a/pkg/serverconfig/config.gen.go b/pkg/serverconfig/config.gen.go index 34abbf9fc..836722d87 100644 --- a/pkg/serverconfig/config.gen.go +++ b/pkg/serverconfig/config.gen.go @@ -425,6 +425,7 @@ type TLSConfig struct { AdditionalIPs []string `toml:"additional_ips" env:"MIREN_TLS_ADDITIONAL_IPS"` AdditionalNames []string `toml:"additional_names" env:"MIREN_TLS_ADDITIONAL_NAMES"` SelfSigned *bool `toml:"self_signed" env:"MIREN_TLS_SELF_SIGNED"` + StandardTLS *bool `toml:"standard_tls" env:"MIREN_TLS_STANDARD_TLS"` } // GetAcmeDNSProvider returns the value of AcmeDNSProvider or its zero value if nil @@ -466,6 +467,19 @@ func (c *TLSConfig) SetSelfSigned(v bool) { c.SelfSigned = &v } +// GetStandardTLS returns the value of StandardTLS or its zero value if nil +func (c *TLSConfig) GetStandardTLS() bool { + if c.StandardTLS != nil { + return *c.StandardTLS + } + return false +} + +// SetStandardTLS sets the value of StandardTLS +func (c *TLSConfig) SetStandardTLS(v bool) { + c.StandardTLS = &v +} + // VictoriaLogsConfig VictoriaLogs configuration type VictoriaLogsConfig struct { Address *string `toml:"address" env:"MIREN_VICTORIALOGS_ADDRESS"` diff --git a/pkg/serverconfig/defaults.gen.go b/pkg/serverconfig/defaults.gen.go index 64347c17d..c7957e0c5 100644 --- a/pkg/serverconfig/defaults.gen.go +++ b/pkg/serverconfig/defaults.gen.go @@ -88,6 +88,7 @@ func DefaultTLSConfig() TLSConfig { AdditionalIPs: []string{}, AdditionalNames: []string{}, SelfSigned: boolPtr(false), + StandardTLS: nil, } } diff --git a/pkg/serverconfig/env.gen.go b/pkg/serverconfig/env.gen.go index 3620f0e59..f3012f913 100644 --- a/pkg/serverconfig/env.gen.go +++ b/pkg/serverconfig/env.gen.go @@ -384,6 +384,18 @@ func applyEnvironmentVariables(cfg *Config, log *slog.Logger) error { } + // Apply MIREN_TLS_STANDARD_TLS + if val := os.Getenv("MIREN_TLS_STANDARD_TLS"); val != "" { + + if b, err := strconv.ParseBool(val); err == nil { + cfg.TLS.StandardTLS = &b + log.Debug("applied env var", "key", "MIREN_TLS_STANDARD_TLS") + } else { + log.Warn("invalid MIREN_TLS_STANDARD_TLS value", "value", val, "error", err) + } + + } + // Apply MIREN_VICTORIALOGS_ADDRESS if val := os.Getenv("MIREN_VICTORIALOGS_ADDRESS"); val != "" { diff --git a/pkg/serverconfig/loader.gen.go b/pkg/serverconfig/loader.gen.go index b33ee1557..f80fce36d 100644 --- a/pkg/serverconfig/loader.gen.go +++ b/pkg/serverconfig/loader.gen.go @@ -286,6 +286,10 @@ func applyCLIFlags(cfg *Config, flags *CLIFlags) { cfg.TLS.SelfSigned = flags.TLSConfigSelfSigned } + if flags.TLSConfigStandardTLS != nil { + cfg.TLS.StandardTLS = flags.TLSConfigStandardTLS + } + if flags.VictoriaLogsConfigAddress != nil && *flags.VictoriaLogsConfigAddress != "" { cfg.Victorialogs.Address = flags.VictoriaLogsConfigAddress } diff --git a/pkg/serverconfig/schema.yml b/pkg/serverconfig/schema.yml index a0325881d..772542cfc 100644 --- a/pkg/serverconfig/schema.yml +++ b/pkg/serverconfig/schema.yml @@ -214,6 +214,15 @@ configs: TLSConfig: description: TLS certificate settings. Consulted only when ingress.mode is tls-autoprovision or behind-proxy-https. fields: + standard_tls: + type: bool + cli: + long: serve-tls + description: "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." + hidden: true + env: MIREN_TLS_STANDARD_TLS + toml: standard_tls + additional_names: type: "[]string" default: [] diff --git a/pkg/serverconfig/toml_strict_test.go b/pkg/serverconfig/toml_strict_test.go new file mode 100644 index 000000000..74cc7dbf2 --- /dev/null +++ b/pkg/serverconfig/toml_strict_test.go @@ -0,0 +1,27 @@ +package serverconfig + +import ( + "testing" + + toml "github.com/pelletier/go-toml/v2" +) + +// Verifies that unknown TOML fields don't blow up the parser. This matters for +// backwards compatibility with operators who still have `standard_tls = true` +// in their pre-RFD-84 server.toml files. If go-toml/v2's default is strict, +// we need a different shim than cli_only. +func TestUnknownTOMLFieldIsIgnored(t *testing.T) { + data := []byte(` +[tls] +acme_email = "ops@example.com" +standard_tls = true +some_nonsense_field = "whatever" +`) + var c Config + if err := toml.Unmarshal(data, &c); err != nil { + t.Fatalf("toml.Unmarshal failed on unknown field, default is strict: %v", err) + } + if got := c.TLS.GetAcmeEmail(); got != "ops@example.com" { + t.Fatalf("AcmeEmail = %q, want ops@example.com", got) + } +} diff --git a/pkg/serverconfig/validate.go b/pkg/serverconfig/validate.go index 87c95683f..85d31c1dc 100644 --- a/pkg/serverconfig/validate.go +++ b/pkg/serverconfig/validate.go @@ -2,6 +2,7 @@ package serverconfig import ( "fmt" + "log/slog" "net" "strings" ) @@ -58,3 +59,16 @@ func (c *Config) ValidateIngressCoherence() error { return nil } + +// WarnDeprecatedConfig logs warnings for any deprecated configuration fields +// that have been explicitly set by the operator. Call after Load so the +// warnings surface at startup. Currently only tls.standard_tls is treated this +// way: it's retained as a no-op for backwards compatibility (existing systemd +// unit files, env vars, and config files from pre-RFD-84 installs) but the +// operator should migrate to ingress.mode. +func (c *Config) WarnDeprecatedConfig(log *slog.Logger) { + if c.TLS.StandardTLS != nil { + log.Warn("tls.standard_tls (also --serve-tls / MIREN_TLS_STANDARD_TLS) is deprecated and ignored; use ingress.mode to pick the deployment shape. See RFD-84 at rfd.miren.garden/rfd/84.", + "value", *c.TLS.StandardTLS) + } +}