initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled

This commit is contained in:
Daniel Noulet 2026-10-07 15:11:35 +02:00
commit 951dd8d26b
175 changed files with 50041 additions and 0 deletions

572
internal/config/config.go Normal file
View file

@ -0,0 +1,572 @@
// Package config loads and validates every runtime setting from the
// environment.
//
// Configuration is environment-only on purpose. The Helm chart is the single
// source of truth for how a deployment is configured; letting the binary also
// read a config file would create a second source that operators forget about.
// Every value has a safe default except the secrets and the OIDC issuer, which
// must be supplied explicitly when auth is enabled.
package config
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
)
// Environment prefixes, tried in order. This lets the Helm chart mount a
// ConfigMap under a distinct prefix without colliding with the pod's own
// environment, which is what makes one image usable across environments.
var envPrefixes = []string{"BANANAS_", "GO_BANANAS_"}
// Config is the fully resolved runtime configuration.
type Config struct {
// Environment is the deployment name, used in logs and the admin UI.
Environment string
// LogLevel is one of debug, info, warn, error.
LogLevel string
// LogFormat is json or text.
LogFormat string
// HTTPAddr is the listen address, host:port.
HTTPAddr string
// PublicBaseURL is the externally reachable origin, without a trailing
// slash. It is used to build absolute URLs in the runtime manifest and to
// validate OIDC redirect targets.
PublicBaseURL string
// TrustedProxies lists the CIDRs or IPs whose X-Forwarded-For headers may be
// believed. It is empty by default, which makes the service ignore
// forwarding headers entirely and log the direct peer.
TrustedProxies []string
// ReadTimeout and WriteTimeout bound a single request. WriteTimeout is
// generous because the runtime CSS endpoint is on the hot path of every page
// load in every consuming application.
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
// ShutdownTimeout is how long in-flight requests get to finish on SIGTERM.
ShutdownTimeout time.Duration
// CORSAllowedOrigins are the browser origins allowed to call the admin API
// from a different origin. The runtime API is always public and is not
// subject to this list.
CORSAllowedOrigins []string
// RateLimitPerMinute caps requests per client for the runtime API.
RateLimitPerMinute int
// DatabasePath is the SQLite file path. Use ":memory:" for tests.
DatabasePath string
// DatabaseBusyTimeout is how long a writer waits for the database lock
// before failing.
DatabaseBusyTimeout time.Duration
// DatabaseMaxConns bounds the connection pool. SQLite serialises writers, so
// a small pool with WAL is faster than a large one.
DatabaseMaxConns int
// AuthMode is oidc, dev, or none.
AuthMode string
// OIDCIssuer is the issuer URL, e.g. https://auth-dev.urpi.be/realms/urpi.
OIDCIssuer string
// OIDCClientID is the client identifier registered with the provider.
OIDCClientID string
// OIDCClientSecret authenticates the confidential client.
OIDCClientSecret string
// OIDCScopes are requested in addition to openid.
OIDCScopes []string
// OIDCRedirectURL overrides the computed callback URL. It is only needed
// when the public URL cannot be derived, for example behind a path-based
// ingress.
OIDCRedirectURL string
// OIDCPostLogoutRedirectURL is where the provider sends the browser after
// logout. It must be an allowed post-logout target on the provider side.
OIDCPostLogoutRedirectURL string
// OIDCAdminGroups are the group names that map to the admin role.
OIDCAdminGroups []string
// OIDCEditorGroups are the group names that map to the editor role.
OIDCEditorGroups []string
// OIDCViewerGroups are the group names that map to the viewer role.
OIDCViewerGroups []string
// OIDCSkipTLSVerify disables provider certificate verification. It exists
// only so that a self-signed local provider can be used during development
// and it is refused outside the development environment.
OIDCSkipTLSVerify bool
// SessionSecret signs the session cookie. It must be at least 32 bytes and
// is required whenever auth is enabled.
SessionSecret string
// SessionCookieName is the cookie carrying the session.
SessionCookieName string
// SessionSecure marks the session cookie Secure.
SessionSecure bool
// SessionTTL is the session lifetime.
SessionTTL time.Duration
// SessionSameSite is Lax, Strict or None.
SessionSameSite string
// RuntimePublic controls whether the runtime API requires a credential. It
// is enabled by default because a theming service's whole purpose is to be
// reachable from browsers and CDN caches.
RuntimePublic bool
// RuntimeCacheControl is the Cache-Control header sent on runtime assets.
// It is tuned for aggressive CDN caching with a revalidation window that
// matches how often themes are published.
RuntimeCacheControl string
// RuntimeCDNBaseURL, when set, is used as the absolute base in the manifest
// so consumers fetch assets from a CDN rather than from this service.
RuntimeCDNBaseURL string
// SeedOnStart reconciles the built-in themes at start-up.
SeedOnStart bool
// DevFallbackRole is the role granted by the development auth bypass.
DevFallbackRole string
// DevUsers maps a development email to a role, overriding DevFallbackRole.
DevUsers map[string]string
// EnableMetrics exposes /metrics in Prometheus format.
EnableMetrics bool
// EnableProfiler exposes the net/http/pprof handlers under /debug/pprof.
EnableProfiler bool
// StaticDir is the directory holding the built admin UI. When empty the UI
// is not served.
StaticDir string
// SDKDir is the directory holding the built runtime SDK bundles.
SDKDir string
// BasePath prefixes every route, for deployments behind a path-based ingress.
BasePath string
// ephemeralSessionSecret records that the secret was generated rather than
// supplied. It is not part of the configuration surface; the application reads
// it only to decide whether to warn.
ephemeralSessionSecret bool
}
// Auth mode values.
const (
AuthModeOIDC = "oidc"
AuthModeDev = "dev"
AuthModeNone = "none"
)
// Defaults returns a Config populated with production-safe defaults. Load
// applies the environment on top of this value.
func Defaults() Config {
return Config{
Environment: "development",
LogLevel: "info",
LogFormat: "text",
HTTPAddr: ":8080",
PublicBaseURL: "http://localhost:8080",
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
ShutdownTimeout: 25 * time.Second,
CORSAllowedOrigins: []string{},
RateLimitPerMinute: 3000,
DatabasePath: "./data/go-bananas.db",
DatabaseBusyTimeout: 5 * time.Second,
DatabaseMaxConns: 8,
AuthMode: AuthModeDev,
OIDCScopes: []string{"profile", "email", "groups"},
SessionCookieName: "go-bananas-session",
SessionSecure: false,
SessionTTL: 8 * time.Hour,
SessionSameSite: "Lax",
RuntimePublic: true,
RuntimeCacheControl: "public, max-age=60, s-maxage=300, stale-while-revalidate=86400",
SeedOnStart: true,
DevFallbackRole: "admin",
DevUsers: map[string]string{},
EnableMetrics: true,
EnableProfiler: false,
StaticDir: "./web/dist",
SDKDir: "./sdk/dist",
}
}
// Load reads configuration from the environment and validates it.
func Load() (Config, error) {
return loadFrom(os.LookupEnv)
}
// loadFrom is the testable core of Load.
func loadFrom(lookup func(string) (string, bool)) (Config, error) {
cfg := Defaults()
get := func(name string) (string, bool) {
for _, prefix := range envPrefixes {
if v, ok := lookup(prefix + name); ok {
return v, true
}
}
return "", false
}
str := func(name string, target *string) {
if v, ok := get(name); ok {
*target = strings.TrimSpace(v)
}
}
cfg.Environment = firstNonEmpty(cfg.Environment,
func() (string, bool) { return get("ENVIRONMENT") },
func() (string, bool) { return get("ENV") },
)
str("LOG_LEVEL", &cfg.LogLevel)
str("LOG_FORMAT", &cfg.LogFormat)
str("HTTP_ADDR", &cfg.HTTPAddr)
str("PUBLIC_BASE_URL", &cfg.PublicBaseURL)
str("BASE_PATH", &cfg.BasePath)
str("STATIC_DIR", &cfg.StaticDir)
str("SDK_DIR", &cfg.SDKDir)
dur := func(name string, target *time.Duration) error {
v, ok := get(name)
if !ok {
return nil
}
parsed, err := time.ParseDuration(strings.TrimSpace(v))
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
*target = parsed
return nil
}
num := func(name string, target *int) error {
v, ok := get(name)
if !ok {
return nil
}
parsed, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
*target = parsed
return nil
}
boolean := func(name string, target *bool) error {
v, ok := get(name)
if !ok {
return nil
}
parsed, err := strconv.ParseBool(strings.TrimSpace(v))
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
*target = parsed
return nil
}
list := func(name string, target *[]string) {
if v, ok := get(name); ok {
*target = splitList(v)
}
}
var err error
for _, f := range []func() error{
func() error { return dur("HTTP_READ_TIMEOUT", &cfg.ReadTimeout) },
func() error { return dur("HTTP_WRITE_TIMEOUT", &cfg.WriteTimeout) },
func() error { return dur("HTTP_IDLE_TIMEOUT", &cfg.IdleTimeout) },
func() error { return dur("SHUTDOWN_TIMEOUT", &cfg.ShutdownTimeout) },
func() error { return dur("SESSION_TTL", &cfg.SessionTTL) },
func() error { return dur("DB_BUSY_TIMEOUT", &cfg.DatabaseBusyTimeout) },
func() error { return num("RATE_LIMIT_PER_MINUTE", &cfg.RateLimitPerMinute) },
func() error { return num("DB_MAX_CONNS", &cfg.DatabaseMaxConns) },
func() error { return boolean("RUNTIME_PUBLIC", &cfg.RuntimePublic) },
func() error { return boolean("SEED_ON_START", &cfg.SeedOnStart) },
func() error { return boolean("ENABLE_METRICS", &cfg.EnableMetrics) },
func() error { return boolean("ENABLE_PROFILER", &cfg.EnableProfiler) },
func() error { return boolean("SESSION_SECURE", &cfg.SessionSecure) },
func() error { return boolean("OIDC_SKIP_TLS_VERIFY", &cfg.OIDCSkipTLSVerify) },
} {
if err = f(); err != nil {
return cfg, err
}
}
list("TRUSTED_PROXIES", &cfg.TrustedProxies)
list("CORS_ALLOWED_ORIGINS", &cfg.CORSAllowedOrigins)
list("OIDC_SCOPES", &cfg.OIDCScopes)
list("OIDC_ADMIN_GROUPS", &cfg.OIDCAdminGroups)
list("OIDC_EDITOR_GROUPS", &cfg.OIDCEditorGroups)
list("OIDC_VIEWER_GROUPS", &cfg.OIDCViewerGroups)
str("AUTH_MODE", &cfg.AuthMode)
str("OIDC_ISSUER", &cfg.OIDCIssuer)
str("OIDC_CLIENT_ID", &cfg.OIDCClientID)
str("OIDC_CLIENT_SECRET", &cfg.OIDCClientSecret)
str("OIDC_REDIRECT_URL", &cfg.OIDCRedirectURL)
str("OIDC_POST_LOGOUT_REDIRECT_URL", &cfg.OIDCPostLogoutRedirectURL)
str("SESSION_SECRET", &cfg.SessionSecret)
str("SESSION_COOKIE_NAME", &cfg.SessionCookieName)
str("SESSION_SAMESITE", &cfg.SessionSameSite)
str("DEV_FALLBACK_ROLE", &cfg.DevFallbackRole)
str("RUNTIME_CACHE_CONTROL", &cfg.RuntimeCacheControl)
str("RUNTIME_CDN_BASE_URL", &cfg.RuntimeCDNBaseURL)
str("DATABASE_PATH", &cfg.DatabasePath)
if v, ok := get("DEV_USERS"); ok {
cfg.DevUsers = parseDevUsers(v)
}
// A development run should not require the operator to invent a 32-byte
// secret before the first request. An ephemeral one is generated instead,
// which is safe because nothing durable is signed with it: sessions simply do
// not survive a restart, which a local run never notices.
if cfg.AuthMode != AuthModeNone && cfg.SessionSecret == "" && cfg.IsDevelopment() {
generated, genErr := randomSecret()
if genErr != nil {
return cfg, genErr
}
cfg.SessionSecret = generated
cfg.ephemeralSessionSecret = true
}
return cfg, cfg.Validate()
}
// EphemeralSessionSecret reports whether the session secret was generated rather
// than supplied. The caller logs it as a warning, because it means sessions
// survive only until the process restarts.
func (c Config) EphemeralSessionSecret() bool { return c.ephemeralSessionSecret }
// randomSecret returns a 48-byte cryptographically random value, base64 encoded.
func randomSecret() (string, error) {
buf := make([]byte, 48)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("generate session secret: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// Validate checks the configuration for internal consistency. It returns every
// problem it finds rather than only the first, because fixing configuration one
// error per deploy is a miserable loop.
func (c Config) Validate() error {
var problems []string
add := func(format string, args ...any) {
problems = append(problems, fmt.Sprintf(format, args...))
}
switch c.AuthMode {
case AuthModeOIDC, AuthModeDev, AuthModeNone:
default:
add("AUTH_MODE %q must be one of oidc, dev or none", c.AuthMode)
}
if c.AuthMode == AuthModeOIDC {
if c.OIDCIssuer == "" {
add("OIDC_ISSUER is required when AUTH_MODE=oidc")
} else if parsed, err := url.Parse(c.OIDCIssuer); err != nil {
add("OIDC_ISSUER is not a valid URL: %v", err)
} else if parsed.Scheme != "https" && !isLocalHost(parsed.Hostname()) {
add("OIDC_ISSUER must use https outside local development, got %q", parsed.Scheme)
}
if c.OIDCClientID == "" {
add("OIDC_CLIENT_ID is required when AUTH_MODE=oidc")
}
if c.OIDCClientSecret == "" {
add("OIDC_CLIENT_SECRET is required when AUTH_MODE=oidc")
}
if c.OIDCSkipTLSVerify && !c.IsDevelopment() {
add("OIDC_SKIP_TLS_VERIFY is refused outside development; it disables certificate verification on the identity provider connection")
}
}
if c.AuthMode != AuthModeNone {
if len(c.SessionSecret) < 32 {
add("SESSION_SECRET must be at least 32 bytes when auth is enabled, got %d", len(c.SessionSecret))
}
}
if c.PublicBaseURL != "" {
parsed, err := url.Parse(c.PublicBaseURL)
switch {
case err != nil:
add("PUBLIC_BASE_URL is not a valid URL: %v", err)
case parsed.Scheme == "":
add("PUBLIC_BASE_URL must include a scheme, got %q", c.PublicBaseURL)
case parsed.Host == "":
add("PUBLIC_BASE_URL must include a host, got %q", c.PublicBaseURL)
}
}
if c.BasePath != "" && !strings.HasPrefix(c.BasePath, "/") {
add("BASE_PATH must start with a slash, got %q", c.BasePath)
}
switch strings.ToLower(c.SessionSameSite) {
case "lax", "strict", "none":
default:
add("SESSION_SAMESITE %q must be lax, strict or none", c.SessionSameSite)
}
if strings.EqualFold(c.SessionSameSite, "none") && !c.SessionSecure {
// Browsers reject SameSite=None without Secure, so this combination
// silently produces a session that never survives a redirect.
add("SESSION_SAMESITE=none requires SESSION_SECURE=true")
}
if !c.SessionSecure && !c.IsDevelopment() && c.PublicBaseURL != "" {
if strings.HasPrefix(c.PublicBaseURL, "https://") {
add("SESSION_SECURE must be true when PUBLIC_BASE_URL is https")
}
}
if c.DatabasePath == "" {
add("DATABASE_PATH must not be empty")
}
if c.DatabaseMaxConns < 1 {
add("DB_MAX_CONNS must be at least 1, got %d", c.DatabaseMaxConns)
}
if c.RateLimitPerMinute < 0 {
add("RATE_LIMIT_PER_MINUTE must not be negative")
}
if !c.IsDevelopment() && c.AuthMode == AuthModeDev {
add("AUTH_MODE=dev is refused outside development; it grants admin to anyone who can reach the service")
}
if c.AuthMode == AuthModeNone && !c.IsDevelopment() {
add("AUTH_MODE=none is refused outside development")
}
if len(problems) > 0 {
return errors.New("invalid configuration:\n - " + strings.Join(problems, "\n - "))
}
return nil
}
// IsDevelopment reports whether the service is running in development.
func (c Config) IsDevelopment() bool {
switch strings.ToLower(c.Environment) {
case "development", "dev", "local", "test", "testing", "ci":
return true
default:
return false
}
}
// RedirectURL returns the OIDC callback URL, derived from the public base when
// not overridden.
func (c Config) RedirectURL() string {
if c.OIDCRedirectURL != "" {
return c.OIDCRedirectURL
}
return c.origin() + c.path("/auth/callback")
}
// PostLogoutRedirectURL returns the OIDC end-session target.
func (c Config) PostLogoutRedirectURL() string {
if c.OIDCPostLogoutRedirectURL != "" {
return c.OIDCPostLogoutRedirectURL
}
return c.origin() + c.path("/")
}
// Origin returns the public origin with no trailing slash.
func (c Config) Origin() string { return c.origin() }
// origin normalises the public base URL.
func (c Config) origin() string {
return strings.TrimRight(c.PublicBaseURL, "/")
}
// path prefixes a route with the configured base path.
func (c Config) path(route string) string {
base := strings.TrimRight(c.BasePath, "/")
if base == "" {
return route
}
return base + route
}
// Path exposes base-path prefixing to the HTTP layer.
func (c Config) Path(route string) string { return c.path(route) }
// CDNBasis returns the absolute base that runtime asset URLs are built from,
// honouring the CDN override.
func (c Config) CDNBasis() string {
if c.RuntimeCDNBaseURL != "" {
return strings.TrimRight(c.RuntimeCDNBaseURL, "/")
}
return c.origin() + c.path("/rt/v1")
}
// splitList parses a comma or whitespace separated environment list.
func splitList(raw string) []string {
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == '\n' || r == '\t' || r == ' '
})
out := make([]string, 0, len(fields))
for _, f := range fields {
if trimmed := strings.TrimSpace(f); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
// parseDevUsers parses the development role map in "email=role,email=role" form.
func parseDevUsers(raw string) map[string]string {
out := make(map[string]string)
for _, pair := range splitList(raw) {
email, role, ok := strings.Cut(pair, "=")
if !ok {
continue
}
email = strings.ToLower(strings.TrimSpace(email))
role = strings.ToLower(strings.TrimSpace(role))
if email == "" || role == "" {
continue
}
out[email] = role
}
return out
}
// firstNonEmpty returns the first non-empty value from a default and a list of
// candidate environment lookups.
func firstNonEmpty(def string, lookups ...func() (string, bool)) string {
for _, lookup := range lookups {
if v, ok := lookup(); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return def
}
// isLocalHost reports whether host refers to the local machine.
func isLocalHost(host string) bool {
switch strings.ToLower(host) {
case "localhost", "127.0.0.1", "::1", "keycloak", "idp", "auth":
return true
default:
return strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".localhost")
}
}
// Redacted returns a copy of the configuration with secrets masked, suitable
// for logging or for the diagnostics endpoint.
func (c Config) Redacted() Config {
mask := func(s string) string {
if s == "" {
return ""
}
return "***redacted***"
}
c.OIDCClientSecret = mask(c.OIDCClientSecret)
c.SessionSecret = mask(c.SessionSecret)
return c
}