From 648e9dc3d967e35e1b5426d4eea89179ff5eeebf Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Sat, 28 Mar 2026 12:10:35 -0700 Subject: [PATCH] Support plain HTTP mode for mc-proxy L7 deployment Custom config package with optional TLS fields. When tls_cert/tls_key are empty, serves plain HTTP (behind mc-proxy which terminates TLS). Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/mcq/server.go | 71 +++++++------------- go.mod | 2 +- internal/config/config.go | 138 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 49 deletions(-) create mode 100644 internal/config/config.go diff --git a/cmd/mcq/server.go b/cmd/mcq/server.go index 450e733..50463cd 100644 --- a/cmd/mcq/server.go +++ b/cmd/mcq/server.go @@ -4,28 +4,23 @@ import ( "context" "fmt" "log/slog" - "net" + "net/http" "os" "os/signal" "syscall" "time" + "github.com/go-chi/chi/v5" "github.com/spf13/cobra" mcdslauth "git.wntrmute.dev/mc/mcdsl/auth" - "git.wntrmute.dev/mc/mcdsl/config" - "git.wntrmute.dev/mc/mcdsl/httpserver" + mcqconfig "git.wntrmute.dev/mc/mcq/internal/config" "git.wntrmute.dev/mc/mcq/internal/db" - "git.wntrmute.dev/mc/mcq/internal/grpcserver" "git.wntrmute.dev/mc/mcq/internal/server" "git.wntrmute.dev/mc/mcq/internal/webserver" ) -type mcqConfig struct { - config.Base -} - func serverCmd() *cobra.Command { var configPath string @@ -42,7 +37,7 @@ func serverCmd() *cobra.Command { } func runServer(configPath string) error { - cfg, err := config.Load[mcqConfig](configPath, "MCQ") + cfg, err := mcqconfig.Load(configPath) if err != nil { return fmt.Errorf("load config: %w", err) } @@ -68,12 +63,11 @@ func runServer(configPath string) error { return fmt.Errorf("create auth client: %w", err) } - // HTTP server — all routes on one router. - httpSrv := httpserver.New(cfg.Server, logger) - httpSrv.Router.Use(httpSrv.LoggingMiddleware) + // Build router with all routes. + router := chi.NewRouter() // Register REST API routes (/v1/*). - server.RegisterRoutes(httpSrv.Router, server.Deps{ + server.RegisterRoutes(router, server.Deps{ DB: database, Auth: authClient, Logger: logger, @@ -88,36 +82,22 @@ func runServer(configPath string) error { if err != nil { return fmt.Errorf("create web server: %w", err) } - webSrv.RegisterRoutes(httpSrv.Router) + webSrv.RegisterRoutes(router) - // Start gRPC server if configured. - var grpcSrv *grpcserver.Server - var grpcLis net.Listener - if cfg.Server.GRPCAddr != "" { - grpcSrv, err = grpcserver.New(cfg.Server.TLSCert, cfg.Server.TLSKey, grpcserver.Deps{ - DB: database, - Authenticator: authClient, - }, logger) - if err != nil { - return fmt.Errorf("create gRPC server: %w", err) - } - grpcLis, err = net.Listen("tcp", cfg.Server.GRPCAddr) - if err != nil { - return fmt.Errorf("listen gRPC on %s: %w", cfg.Server.GRPCAddr, err) - } + // Plain HTTP server (behind mc-proxy L7 which terminates TLS). + httpSrv := &http.Server{ + Addr: cfg.Server.ListenAddr, + Handler: router, + ReadTimeout: cfg.Server.ReadTimeout.Duration, + WriteTimeout: cfg.Server.WriteTimeout.Duration, + IdleTimeout: cfg.Server.IdleTimeout.Duration, } // Graceful shutdown. - grpcServeStarted := false shutdownAll := func() { - if grpcSrv != nil { - grpcSrv.GracefulStop() - } else if grpcLis != nil && !grpcServeStarted { - _ = grpcLis.Close() - } - shutdownTimeout := 30 * time.Second - if cfg.Server.ShutdownTimeout.Duration > 0 { - shutdownTimeout = cfg.Server.ShutdownTimeout.Duration + shutdownTimeout := cfg.Server.ShutdownTimeout.Duration + if shutdownTimeout == 0 { + shutdownTimeout = 30 * time.Second } shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) defer cancel() @@ -127,19 +107,14 @@ func runServer(configPath string) error { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - errCh := make(chan error, 2) - - if grpcSrv != nil { - grpcServeStarted = true - go func() { - logger.Info("gRPC server listening", "addr", grpcLis.Addr()) - errCh <- grpcSrv.Serve(grpcLis) - }() - } + errCh := make(chan error, 1) go func() { logger.Info("mcq starting", "version", version, "addr", cfg.Server.ListenAddr) - errCh <- httpSrv.ListenAndServeTLS() + err := httpSrv.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + errCh <- err + } }() select { diff --git a/go.mod b/go.mod index 12729b3..d7d1560 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( git.wntrmute.dev/mc/mcdsl v1.2.0 github.com/alecthomas/chroma/v2 v2.18.0 github.com/go-chi/chi/v5 v5.2.5 + github.com/pelletier/go-toml/v2 v2.3.0 github.com/spf13/cobra v1.10.2 github.com/yuin/goldmark v1.7.12 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc @@ -20,7 +21,6 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/pflag v1.0.9 // indirect golang.org/x/net v0.48.0 // indirect diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..634efdb --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,138 @@ +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"` + Log LogConfig `toml:"log"` +} + +// 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})) + } + } + } +}