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"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
|
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/db"
|
||||||
"git.wntrmute.dev/mc/mcq/internal/grpcserver"
|
|
||||||
"git.wntrmute.dev/mc/mcq/internal/server"
|
"git.wntrmute.dev/mc/mcq/internal/server"
|
||||||
"git.wntrmute.dev/mc/mcq/internal/webserver"
|
"git.wntrmute.dev/mc/mcq/internal/webserver"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mcqConfig struct {
|
|
||||||
config.Base
|
|
||||||
}
|
|
||||||
|
|
||||||
func serverCmd() *cobra.Command {
|
func serverCmd() *cobra.Command {
|
||||||
var configPath string
|
var configPath string
|
||||||
|
|
||||||
@@ -42,7 +37,7 @@ func serverCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runServer(configPath string) error {
|
func runServer(configPath string) error {
|
||||||
cfg, err := config.Load[mcqConfig](configPath, "MCQ")
|
cfg, err := mcqconfig.Load(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load config: %w", err)
|
return fmt.Errorf("load config: %w", err)
|
||||||
}
|
}
|
||||||
@@ -68,12 +63,11 @@ func runServer(configPath string) error {
|
|||||||
return fmt.Errorf("create auth client: %w", err)
|
return fmt.Errorf("create auth client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTP server — all routes on one router.
|
// Build router with all routes.
|
||||||
httpSrv := httpserver.New(cfg.Server, logger)
|
router := chi.NewRouter()
|
||||||
httpSrv.Router.Use(httpSrv.LoggingMiddleware)
|
|
||||||
|
|
||||||
// Register REST API routes (/v1/*).
|
// Register REST API routes (/v1/*).
|
||||||
server.RegisterRoutes(httpSrv.Router, server.Deps{
|
server.RegisterRoutes(router, server.Deps{
|
||||||
DB: database,
|
DB: database,
|
||||||
Auth: authClient,
|
Auth: authClient,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
@@ -88,36 +82,22 @@ func runServer(configPath string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create web server: %w", err)
|
return fmt.Errorf("create web server: %w", err)
|
||||||
}
|
}
|
||||||
webSrv.RegisterRoutes(httpSrv.Router)
|
webSrv.RegisterRoutes(router)
|
||||||
|
|
||||||
// Start gRPC server if configured.
|
// Plain HTTP server (behind mc-proxy L7 which terminates TLS).
|
||||||
var grpcSrv *grpcserver.Server
|
httpSrv := &http.Server{
|
||||||
var grpcLis net.Listener
|
Addr: cfg.Server.ListenAddr,
|
||||||
if cfg.Server.GRPCAddr != "" {
|
Handler: router,
|
||||||
grpcSrv, err = grpcserver.New(cfg.Server.TLSCert, cfg.Server.TLSKey, grpcserver.Deps{
|
ReadTimeout: cfg.Server.ReadTimeout.Duration,
|
||||||
DB: database,
|
WriteTimeout: cfg.Server.WriteTimeout.Duration,
|
||||||
Authenticator: authClient,
|
IdleTimeout: cfg.Server.IdleTimeout.Duration,
|
||||||
}, 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Graceful shutdown.
|
// Graceful shutdown.
|
||||||
grpcServeStarted := false
|
|
||||||
shutdownAll := func() {
|
shutdownAll := func() {
|
||||||
if grpcSrv != nil {
|
shutdownTimeout := cfg.Server.ShutdownTimeout.Duration
|
||||||
grpcSrv.GracefulStop()
|
if shutdownTimeout == 0 {
|
||||||
} else if grpcLis != nil && !grpcServeStarted {
|
shutdownTimeout = 30 * time.Second
|
||||||
_ = grpcLis.Close()
|
|
||||||
}
|
|
||||||
shutdownTimeout := 30 * time.Second
|
|
||||||
if cfg.Server.ShutdownTimeout.Duration > 0 {
|
|
||||||
shutdownTimeout = cfg.Server.ShutdownTimeout.Duration
|
|
||||||
}
|
}
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -127,19 +107,14 @@ func runServer(configPath string) error {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
errCh := make(chan error, 2)
|
errCh := make(chan error, 1)
|
||||||
|
|
||||||
if grpcSrv != nil {
|
|
||||||
grpcServeStarted = true
|
|
||||||
go func() {
|
|
||||||
logger.Info("gRPC server listening", "addr", grpcLis.Addr())
|
|
||||||
errCh <- grpcSrv.Serve(grpcLis)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
logger.Info("mcq starting", "version", version, "addr", cfg.Server.ListenAddr)
|
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 {
|
select {
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -6,6 +6,7 @@ require (
|
|||||||
git.wntrmute.dev/mc/mcdsl v1.2.0
|
git.wntrmute.dev/mc/mcdsl v1.2.0
|
||||||
github.com/alecthomas/chroma/v2 v2.18.0
|
github.com/alecthomas/chroma/v2 v2.18.0
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
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/spf13/cobra v1.10.2
|
||||||
github.com/yuin/goldmark v1.7.12
|
github.com/yuin/goldmark v1.7.12
|
||||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
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/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
golang.org/x/net v0.48.0 // 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