feat: wire server entrypoint; add go-oidc/v3 and uuid dependencies

main.go connects all packages: config → pool → migrate → cipher → store
→ providermgr → catalog → optional OIDC → rate limiter → API server.
WriteTimeout is zero to support SSE streaming; no middleware.Timeout.
Config validation now allows OIDC to be optional (static-token fallback).
This commit is contained in:
2026-06-28 23:15:15 +02:00
parent 457d354d36
commit 4d78d5773d
4 changed files with 110 additions and 23 deletions

View File

@@ -24,3 +24,9 @@ GATEWAY_OIDC_ADMIN_GROUP=llm-admins
# --- Security ---
# 64 hex chars (32 bytes) for AES-256-GCM. Generate with: openssl rand -hex 32
GATEWAY_SECURITY_ENCRYPTION_KEY=
# Static bearer token for admin API. Generate with: openssl rand -hex 32
GATEWAY_SECURITY_ADMIN_TOKEN=
# --- Catalog ---
# How often to sync model lists from providers. Default: 30m
GATEWAY_CATALOG_SYNC_INTERVAL=30m

View File

@@ -13,11 +13,20 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/jackc/pgx/v5/pgxpool"
"git.govekar.net/LovroG05/localmodelroutex/internal/api"
"git.govekar.net/LovroG05/localmodelroutex/internal/catalog"
"git.govekar.net/LovroG05/localmodelroutex/internal/config"
"git.govekar.net/LovroG05/localmodelroutex/internal/crypto"
"git.govekar.net/LovroG05/localmodelroutex/internal/db"
"git.govekar.net/LovroG05/localmodelroutex/internal/oidcauth"
"git.govekar.net/LovroG05/localmodelroutex/internal/providermgr"
"git.govekar.net/LovroG05/localmodelroutex/internal/ratelimit"
"git.govekar.net/LovroG05/localmodelroutex/internal/store"
"git.govekar.net/LovroG05/localmodelroutex/internal/web"
// Self-register all provider implementations.
_ "git.govekar.net/LovroG05/localmodelroutex/internal/providers/all"
)
func main() {
@@ -27,9 +36,6 @@ func main() {
}
}
// run wires together config, the database, and the HTTP server, then blocks
// until a shutdown signal is received. Returning an error (instead of calling
// os.Exit everywhere) keeps deferred cleanup running.
func run() error {
cfg, err := config.Load()
if err != nil {
@@ -38,7 +44,6 @@ func run() error {
slog.SetDefault(newLogger(cfg))
// Root context cancelled on SIGINT/SIGTERM, driving graceful shutdown.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
@@ -54,16 +59,76 @@ func run() error {
}
slog.Info("database schema up to date")
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: newRouter(pool),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 120 * time.Second,
cipher, err := crypto.NewCipher(cfg.Security.EncryptionKey)
if err != nil {
return fmt.Errorf("init cipher: %w", err)
}
st := store.New(pool)
mgr := providermgr.New(st, cipher)
if err := mgr.Reload(ctx); err != nil {
slog.Warn("initial provider load failed", "err", err)
}
cache := catalog.NewCache(st)
if err := cache.Reload(ctx); err != nil {
slog.Warn("initial catalog load failed", "err", err)
}
syncer := catalog.NewSyncer(st, cache)
// OIDC is optional; only initialised when the issuer is configured.
var oidcV *oidcauth.Verifier
if cfg.OIDC.Issuer != "" {
v, err := oidcauth.New(ctx, cfg.OIDC.Issuer, cfg.OIDC.ClientID, cfg.OIDC.AdminGroup)
if err != nil {
return fmt.Errorf("init oidc: %w", err)
}
oidcV = v
slog.Info("OIDC admin auth enabled", "issuer", cfg.OIDC.Issuer)
}
limiter := ratelimit.New()
// syncNow is used by admin endpoints to force an immediate refresh.
syncNow := func(ctx context.Context) error {
if err := mgr.Reload(ctx); err != nil {
return err
}
return syncer.SyncAll(ctx, toProviderRefs(mgr.List()))
}
// refresh is called each syncer tick: reload providers then return refs.
refresh := func(ctx context.Context) ([]catalog.ProviderRef, error) {
if err := mgr.Reload(ctx); err != nil {
return nil, err
}
return toProviderRefs(mgr.List()), nil
}
go syncer.Run(ctx, cfg.Catalog.SyncInterval, refresh)
a := api.New(api.Deps{
Store: st,
Cache: cache,
Manager: mgr,
Cipher: cipher,
AdminToken: cfg.Security.AdminToken,
OIDCVerifier: oidcV,
Limiter: limiter,
SyncNow: syncNow,
})
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: newRouter(pool, a),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
// WriteTimeout is intentionally zero: SSE streams can run indefinitely.
IdleTimeout: 120 * time.Second,
}
// Run the listener in the background so we can also wait on ctx.Done.
serverErr := make(chan error, 1)
go func() {
slog.Info("http server listening", "addr", srv.Addr, "env", cfg.Server.Env)
@@ -88,8 +153,6 @@ func run() error {
return nil
}
// newLogger returns a slog logger: human-readable text at debug level in
// development, structured JSON at info level otherwise.
func newLogger(cfg *config.Config) *slog.Logger {
opts := &slog.HandlerOptions{Level: slog.LevelInfo}
if cfg.IsDev() {
@@ -99,21 +162,16 @@ func newLogger(cfg *config.Config) *slog.Logger {
return slog.New(slog.NewJSONHandler(os.Stdout, opts))
}
// newRouter builds the HTTP handler with baseline middleware and health
// probes. Feature routes hang off this router as the api package grows.
func newRouter(pool *pgxpool.Pool) http.Handler {
func newRouter(pool interface{ Ping(context.Context) error }, a *api.API) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(60 * time.Second))
// No Timeout middleware: SSE streams must not be cut off by a global deadline.
// Liveness: the process is running.
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
// Readiness: dependencies are reachable.
r.Get("/readyz", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
@@ -125,8 +183,19 @@ func newRouter(pool *pgxpool.Pool) http.Handler {
_, _ = w.Write([]byte("ok"))
})
// Dashboard SPA — catch-all, registered last so API routes take precedence.
a.Mount(r)
// Dashboard SPA — registered last so API routes take precedence.
r.Handle("/*", web.Handler())
return r
}
// toProviderRefs converts manager instances to catalog provider references.
func toProviderRefs(insts []providermgr.Instance) []catalog.ProviderRef {
refs := make([]catalog.ProviderRef, len(insts))
for i, inst := range insts {
refs[i] = catalog.ProviderRef{ID: inst.ID, Provider: inst.Provider}
}
return refs
}

View File

@@ -3,14 +3,17 @@ module git.govekar.net/LovroG05/localmodelroutex
go 1.26.4
require (
github.com/coreos/go-oidc/v3 v3.19.0
github.com/go-chi/chi/v5 v5.3.0
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/spf13/viper v1.21.0
)
require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -24,6 +27,7 @@ require (
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect

View File

@@ -6,6 +6,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -27,6 +29,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -39,6 +43,8 @@ github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7g
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -105,6 +111,8 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=