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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
2
go.mod
2
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
|
||||
|
||||
138
internal/config/config.go
Normal file
138
internal/config/config.go
Normal file
@@ -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}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user