Files
Admin Bot 14cc67833c
CI / Vet, test, build (push) Successful in 3m52s
CI / Build and push image (push) Successful in 1m17s
feat(auth): wire JWT validation into /v1/* LLM endpoints
2026-08-31 23:01:59 -07:00

250 lines
8.1 KiB
Go

package config
import (
"fmt"
"net"
"os"
"time"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
"gopkg.in/yaml.v3"
)
// rawConfig represents the structure of the YAML configuration file.
type rawConfig struct {
Routes []rawRoute `yaml:"routes"`
Models []rawModel `yaml:"models"`
Adapters []rawAdapter `yaml:"adapters"`
Auth rawAuth `yaml:"auth"`
}
// rawAuth represents auth configuration in YAML.
type rawAuth struct {
Enabled bool `yaml:"enabled"`
Issuer string `yaml:"issuer"`
Audience string `yaml:"audience"`
JWKSURL string `yaml:"jwksUrl"`
RequiredCapability string `yaml:"requiredCapability"`
}
// rawRoute represents a single route in the YAML configuration.
type rawRoute struct {
Name string `yaml:"name"`
Upstream rawUpstream `yaml:"upstream"`
}
// rawModel represents a single model entry in the YAML configuration.
type rawModel struct {
Name string `yaml:"name"`
Address string `yaml:"address"`
Path string `yaml:"path"`
AuthRequired *bool `yaml:"authRequired"`
}
// rawAdapter represents a service adapter in the YAML configuration.
type rawAdapter struct {
ServiceName string `yaml:"serviceName"`
Upstream struct {
URL string `yaml:"url"`
TimeoutSeconds int32 `yaml:"timeoutSeconds"`
} `yaml:"upstream"`
Auth struct {
Required bool `yaml:"required"`
Capability string `yaml:"capability"`
} `yaml:"auth"`
Retryable bool `yaml:"retryable"`
Resources []struct {
Name string `yaml:"name"`
Methods []struct {
Verb string `yaml:"verb"`
UpstreamPath string `yaml:"upstreamPath"`
RequestSchema string `yaml:"requestSchema"`
ResponseSchema string `yaml:"responseSchema"`
} `yaml:"methods"`
} `yaml:"resources"`
}
// rawUpstream represents upstream configuration in YAML.
type rawUpstream struct {
Address string `yaml:"address"`
PathRewrite string `yaml:"pathRewrite"`
ConnectTimeout string `yaml:"connectTimeout"`
ReadTimeout string `yaml:"readTimeout"`
WriteTimeout string `yaml:"writeTimeout"`
MaxBodySize int64 `yaml:"maxBodySize"`
AuthRequired *bool `yaml:"authRequired"`
}
// LoadRoutesAndModelsFromFile loads route, model, adapter, and auth configuration from a YAML file.
func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*ModelUpstream, []*serviceadapter.ServiceAdapter, AuthConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, nil, nil, AuthConfig{}, fmt.Errorf("failed to read config file %q: %w", path, err)
}
var raw rawConfig
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, nil, nil, AuthConfig{}, fmt.Errorf("failed to parse config file %q: %w", path, err)
}
// Load routes
routes := make(map[string]*Route)
for _, rawRoute := range raw.Routes {
if rawRoute.Name == "" {
return nil, nil, nil, AuthConfig{}, fmt.Errorf("route has empty name")
}
if _, exists := routes[rawRoute.Name]; exists {
return nil, nil, nil, AuthConfig{}, fmt.Errorf("duplicate route: \"%s\"", rawRoute.Name)
}
upstream, err := parseUpstream(rawRoute.Name, rawRoute.Upstream)
if err != nil {
return nil, nil, nil, AuthConfig{}, err
}
routes[rawRoute.Name] = &Route{
Name: rawRoute.Name,
Upstream: upstream,
}
}
// Load models
models := make(map[string]*ModelUpstream)
for _, rawModel := range raw.Models {
if rawModel.Name == "" {
return nil, nil, nil, AuthConfig{}, fmt.Errorf("model has empty name")
}
if _, exists := models[rawModel.Name]; exists {
return nil, nil, nil, AuthConfig{}, fmt.Errorf("duplicate model: \"%s\"", rawModel.Name)
}
if rawModel.Address == "" {
return nil, nil, nil, AuthConfig{}, fmt.Errorf("model \"%s\": field 'address' is required", rawModel.Name)
}
if _, _, err := net.SplitHostPort(rawModel.Address); err != nil {
return nil, nil, nil, AuthConfig{}, fmt.Errorf("model \"%s\": invalid address \"%s\": %w", rawModel.Name, rawModel.Address, err)
}
// Default authRequired to global auth.enabled if not specified per-model
authRequired := false
if rawModel.AuthRequired != nil {
authRequired = *rawModel.AuthRequired
}
models[rawModel.Name] = &ModelUpstream{
Name: rawModel.Name,
Address: rawModel.Address,
Path: rawModel.Path,
AuthRequired: authRequired,
}
}
// Load adapters
adapters := make([]*serviceadapter.ServiceAdapter, 0, len(raw.Adapters))
for _, ra := range raw.Adapters {
if ra.ServiceName == "" {
return nil, nil, nil, AuthConfig{}, fmt.Errorf("adapter has empty serviceName")
}
a := &serviceadapter.ServiceAdapter{
Name: ra.ServiceName,
ServiceName: ra.ServiceName,
CreatedAt: time.Now(),
}
a.Spec.ServiceName = ra.ServiceName
a.Spec.Upstream.URL = ra.Upstream.URL
a.Spec.Upstream.TimeoutSeconds = ra.Upstream.TimeoutSeconds
a.Spec.Auth.Required = ra.Auth.Required
a.Spec.Auth.Capability = ra.Auth.Capability
a.Spec.Retryable = ra.Retryable
for _, rr := range ra.Resources {
res := serviceadapter.Resource{Name: rr.Name}
for _, rm := range rr.Methods {
res.Methods = append(res.Methods, serviceadapter.Method{
Verb: rm.Verb,
UpstreamPath: rm.UpstreamPath,
RequestSchema: rm.RequestSchema,
ResponseSchema: rm.ResponseSchema,
})
}
a.Spec.Resources = append(a.Spec.Resources, res)
}
adapters = append(adapters, a)
}
// Parse auth config
authConfig := AuthConfig{
Enabled: raw.Auth.Enabled,
Issuer: raw.Auth.Issuer,
Audience: raw.Auth.Audience,
JWKSURL: raw.Auth.JWKSURL,
RequiredCapability: raw.Auth.RequiredCapability,
}
return routes, models, adapters, authConfig, nil
}
// LoadRoutesFromFile loads route configuration from a YAML file.
// Deprecated: Use LoadRoutesAndModelsFromFile instead.
func LoadRoutesFromFile(path string) (map[string]*Route, error) {
routes, _, _, _, err := LoadRoutesAndModelsFromFile(path)
return routes, err
}
// parseUpstream validates and parses upstream configuration from raw YAML.
func parseUpstream(routeName string, raw rawUpstream) (Upstream, error) {
// Validate address is not empty
if raw.Address == "" {
return Upstream{}, fmt.Errorf("route \"%s\": field 'address' is required", routeName)
}
// Validate address format (host:port)
if _, _, err := net.SplitHostPort(raw.Address); err != nil {
return Upstream{}, fmt.Errorf("route \"%s\": invalid address \"%s\": %w", routeName, raw.Address, err)
}
// Validate connectTimeout
if raw.ConnectTimeout == "" {
return Upstream{}, fmt.Errorf("route \"%s\": field 'connectTimeout' is required", routeName)
}
connectTimeout, err := time.ParseDuration(raw.ConnectTimeout)
if err != nil {
return Upstream{}, fmt.Errorf("route \"%s\": invalid connectTimeout \"%s\": %w", routeName, raw.ConnectTimeout, err)
}
// Validate readTimeout
if raw.ReadTimeout == "" {
return Upstream{}, fmt.Errorf("route \"%s\": field 'readTimeout' is required", routeName)
}
readTimeout, err := time.ParseDuration(raw.ReadTimeout)
if err != nil {
return Upstream{}, fmt.Errorf("route \"%s\": invalid readTimeout \"%s\": %w", routeName, raw.ReadTimeout, err)
}
// Validate writeTimeout
if raw.WriteTimeout == "" {
return Upstream{}, fmt.Errorf("route \"%s\": field 'writeTimeout' is required", routeName)
}
writeTimeout, err := time.ParseDuration(raw.WriteTimeout)
if err != nil {
return Upstream{}, fmt.Errorf("route \"%s\": invalid writeTimeout \"%s\": %w", routeName, raw.WriteTimeout, err)
}
// Validate maxBodySize is not zero (it must be explicitly set)
if raw.MaxBodySize == 0 {
return Upstream{}, fmt.Errorf("route \"%s\": field 'maxBodySize' is required and must be > 0", routeName)
}
// Validate authRequired is not missing
if raw.AuthRequired == nil {
return Upstream{}, fmt.Errorf("route \"%s\": field 'authRequired' is required", routeName)
}
return Upstream{
Address: raw.Address,
PathRewrite: raw.PathRewrite,
ConnectTimeout: connectTimeout,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
MaxBodySize: raw.MaxBodySize,
AuthRequired: *raw.AuthRequired,
}, nil
}