Files
mcq/internal/config/config.go
Kyle Isom b48fcc8465 sso: public MCIAS authorize URL + docs
Add [sso].public_url so the browser SSO authorize redirect uses the
public MCIAS hostname while the code exchange stays on the internal
address (mcdsl v1.9.0). Document the SSO URL split and the rootless-podman
/ unikernel-eligibility rules in CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:20:50 -07:00

155 lines
3.9 KiB
Go

package config
import (
"fmt"
"os"
"reflect"
"strings"
"time"
"github.com/pelletier/go-toml/v2"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
)
// Config is the MCQ configuration.
type Config struct {
Server ServerConfig `toml:"server"`
Database DatabaseConfig `toml:"database"`
MCIAS mcdslauth.Config `toml:"mcias"`
SSO SSOConfig `toml:"sso"`
Log LogConfig `toml:"log"`
}
// SSOConfig holds SSO redirect settings for the web UI.
type SSOConfig struct {
// RedirectURI is the callback URL that MCIAS redirects to after login.
// Must exactly match the redirect_uri registered in MCIAS config. For
// public (non-Tailnet) browser access this must be the public hostname.
RedirectURI string `toml:"redirect_uri"`
// PublicURL is the browser-facing MCIAS base URL used to build the SSO
// authorize redirect (e.g. "https://mcias.metacircular.net"). When empty,
// the backend [mcias].server_url is used for the redirect too. Set this
// when browsers cannot resolve the internal MCIAS name; the
// server-to-server code exchange still uses [mcias].server_url.
PublicURL string `toml:"public_url"`
}
// ServerConfig holds HTTP/gRPC server settings. TLS fields are optional;
// when empty, MCQ serves plain HTTP (for use behind mc-proxy L7).
type ServerConfig struct {
ListenAddr string `toml:"listen_addr"`
GRPCAddr string `toml:"grpc_addr"`
TLSCert string `toml:"tls_cert"`
TLSKey string `toml:"tls_key"`
ReadTimeout Duration `toml:"read_timeout"`
WriteTimeout Duration `toml:"write_timeout"`
IdleTimeout Duration `toml:"idle_timeout"`
ShutdownTimeout Duration `toml:"shutdown_timeout"`
}
type DatabaseConfig struct {
Path string `toml:"path"`
}
type LogConfig struct {
Level string `toml:"level"`
}
// Duration wraps time.Duration for TOML string parsing.
type Duration struct {
time.Duration
}
func (d *Duration) UnmarshalText(text []byte) error {
var err error
d.Duration, err = time.ParseDuration(string(text))
return err
}
// Load reads, parses, and validates a config file.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path) // #nosec G304 -- config path is operator-controlled
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
cfg := &Config{
Server: ServerConfig{
ListenAddr: ":8080",
ReadTimeout: Duration{30 * time.Second},
WriteTimeout: Duration{30 * time.Second},
IdleTimeout: Duration{120 * time.Second},
ShutdownTimeout: Duration{60 * time.Second},
},
Log: LogConfig{Level: "info"},
}
if err := toml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
applyEnvOverrides(cfg)
if err := cfg.validate(); err != nil {
return nil, err
}
return cfg, nil
}
func (c *Config) validate() error {
if c.Server.ListenAddr == "" {
return fmt.Errorf("config: server.listen_addr is required")
}
if c.Database.Path == "" {
return fmt.Errorf("config: database.path is required")
}
if c.MCIAS.ServerURL == "" {
return fmt.Errorf("config: mcias.server_url is required")
}
return nil
}
func applyEnvOverrides(cfg *Config) {
if port := os.Getenv("PORT"); port != "" {
cfg.Server.ListenAddr = ":" + port
}
applyEnvToStruct("MCQ", reflect.ValueOf(cfg).Elem())
}
func applyEnvToStruct(prefix string, v reflect.Value) {
t := v.Type()
for i := range t.NumField() {
field := t.Field(i)
fv := v.Field(i)
tag := field.Tag.Get("toml")
if tag == "" || tag == "-" {
continue
}
envKey := prefix + "_" + strings.ToUpper(tag)
if fv.Kind() == reflect.Struct && field.Type != reflect.TypeOf(Duration{}) {
applyEnvToStruct(envKey, fv)
continue
}
envVal := os.Getenv(envKey)
if envVal == "" {
continue
}
if fv.Kind() == reflect.String {
fv.SetString(envVal)
}
if field.Type == reflect.TypeOf(Duration{}) {
if d, err := time.ParseDuration(envVal); err == nil {
fv.Set(reflect.ValueOf(Duration{d}))
}
}
}
}