chore: initial commit of Go API gateway
CI / Test (push) Canceled after 0s
CI / Vet (push) Canceled after 0s
CI / Build (push) Canceled after 0s
CI / Security (govulncheck) (push) Canceled after 0s

Baseline for the Kong replacement on api.riotpiao.com. Brings the working
tree under version control for the first time: gateway source, the task
board that drives the agent runs, test fixtures, and K8s manifests.

Anchor the gateway ignore rule to the repo root. Unanchored, "gateway"
also matched the cmd/gateway/ source directory, so the program entrypoint
was excluded from every commit.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Story Crater Bot
2026-08-19 20:54:34 -07:00
co-authored by Claude Opus 5
commit 058f11cf2b
109 changed files with 8992 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
package config_test
import (
"testing"
"github.com/Riotpiaole/homelab-frontend/internal/config"
)
// TestLoadRoutesMissingAuthRequired tests that the auth-required flag is not a silent default:
// if it is absent from YAML, startup must fail with a message naming the offending route and field.
func TestLoadRoutesMissingAuthRequired(t *testing.T) {
routes, err := config.LoadRoutesFromFile("../../testdata/config/missing-authRequired.yaml")
if err == nil {
t.Fatal("expected error for missing authRequired, got nil")
}
if routes != nil {
t.Error("expected nil routes on error")
}
want := "route \"test-route\": field 'authRequired' is required"
got := err.Error()
if got != want {
t.Fatalf("error did not name the offending route and field; want %q, got %q", want, got)
}
}
+115
View File
@@ -0,0 +1,115 @@
package config
import (
"fmt"
"os"
"time"
)
// Config holds the gateway configuration.
type Config struct {
// ListenAddr is the address to listen on for HTTP traffic.
ListenAddr string
// ShutdownTimeout is the maximum time to wait for in-flight requests
// to complete before forcing shutdown.
ShutdownTimeout time.Duration
// Routes maps route names to their upstream configuration.
Routes map[string]*Route
// Models maps model names to their upstream configuration.
// Multiple models can point to the same upstream address.
Models map[string]*ModelUpstream
}
// ModelUpstream holds upstream configuration for a specific model.
type ModelUpstream struct {
// Name is the model name clients send (e.g., "reasoning", "ornith:35b").
Name string
// Address is the upstream server address (host:port).
Address string
// Path is the upstream path for this model (e.g., "/v1/chat/completions").
Path string
}
// Route represents a single route and its upstream configuration.
type Route struct {
// Name is the route identifier.
Name string
// Upstream holds the upstream server configuration.
Upstream Upstream
}
// Upstream holds upstream server configuration for a route.
type Upstream struct {
// Address is the upstream server address (host:port).
Address string
// PathRewrite is an optional path prefix rewrite. Empty string means no rewrite.
PathRewrite string
// ConnectTimeout is the maximum time to establish a connection to the upstream.
ConnectTimeout time.Duration
// ReadTimeout is the maximum time to read a response from the upstream.
ReadTimeout time.Duration
// WriteTimeout is the maximum time to write a request to the upstream.
WriteTimeout time.Duration
// MaxBodySize is the maximum request body size in bytes.
MaxBodySize int64
// AuthRequired indicates whether this route requires authentication.
AuthRequired bool
}
// LookupModel finds a model by name (case-sensitive, exact match).
func (c *Config) LookupModel(name string) *ModelUpstream {
if c == nil || c.Models == nil {
return nil
}
return c.Models[name]
}
// ModelNames returns a sorted list of all known model names.
func (c *Config) ModelNames() []string {
if c == nil || c.Models == nil {
return nil
}
names := make([]string, 0, len(c.Models))
for name := range c.Models {
names = append(names, name)
}
return names
}
// Load reads configuration from environment variables with defaults.
func Load() (*Config, error) {
listenAddr := "127.0.0.1:8080"
// Allow override via environment variable
if addr, ok := os.LookupEnv("LISTEN_ADDR"); ok {
listenAddr = addr
}
shutdownTimeout := 30 * time.Second
// Allow override via environment variable
if timeout, ok := os.LookupEnv("SHUTDOWN_TIMEOUT"); ok {
d, err := time.ParseDuration(timeout)
if err != nil {
return nil, fmt.Errorf("invalid SHUTDOWN_TIMEOUT: %w", err)
}
shutdownTimeout = d
}
// Load routes and models from config file
routes := make(map[string]*Route)
models := make(map[string]*ModelUpstream)
if configPath, ok := os.LookupEnv("CONFIG_PATH"); ok {
loadedRoutes, loadedModels, err := LoadRoutesAndModelsFromFile(configPath)
if err != nil {
return nil, err
}
routes = loadedRoutes
models = loadedModels
}
return &Config{
ListenAddr: listenAddr,
ShutdownTimeout: shutdownTimeout,
Routes: routes,
Models: models,
}, nil
}
+209
View File
@@ -0,0 +1,209 @@
package config_test
import (
"testing"
"time"
"github.com/Riotpiaole/homelab-frontend/internal/config"
)
// TestLoadRoutesValidConfig tests that a valid configuration loads correctly.
func TestLoadRoutesValidConfig(t *testing.T) {
routes, err := config.LoadRoutesFromFile("../../testdata/config/valid.yaml")
if err != nil {
t.Fatalf("unexpected error loading valid config: %v", err)
}
// Verify all three routes are present
if len(routes) != 3 {
t.Errorf("expected 3 routes, got %d", len(routes))
}
// Verify reasoning-chat route
reasoningRoute, ok := routes["reasoning-chat"]
if !ok {
t.Fatal("reasoning-chat route not found")
}
if reasoningRoute.Name != "reasoning-chat" {
t.Errorf("route name mismatch: expected 'reasoning-chat', got %q", reasoningRoute.Name)
}
if reasoningRoute.Upstream.Address != "reasoning-predictor.llm-serving:80" {
t.Errorf("upstream address mismatch: expected 'reasoning-predictor.llm-serving:80', got %q", reasoningRoute.Upstream.Address)
}
if reasoningRoute.Upstream.ConnectTimeout != 10*time.Second {
t.Errorf("connect timeout mismatch: expected 10s, got %v", reasoningRoute.Upstream.ConnectTimeout)
}
if reasoningRoute.Upstream.ReadTimeout != time.Hour {
t.Errorf("read timeout mismatch: expected 1h, got %v", reasoningRoute.Upstream.ReadTimeout)
}
if reasoningRoute.Upstream.WriteTimeout != time.Hour {
t.Errorf("write timeout mismatch: expected 1h, got %v", reasoningRoute.Upstream.WriteTimeout)
}
if reasoningRoute.Upstream.MaxBodySize != 10485760 {
t.Errorf("max body size mismatch: expected 10485760, got %d", reasoningRoute.Upstream.MaxBodySize)
}
if !reasoningRoute.Upstream.AuthRequired {
t.Error("auth required should be true")
}
// Verify ornith-chat route
ornithRoute, ok := routes["ornith-chat"]
if !ok {
t.Fatal("ornith-chat route not found")
}
if ornithRoute.Upstream.ReadTimeout != 10*time.Minute {
t.Errorf("ornith read timeout mismatch: expected 10m, got %v", ornithRoute.Upstream.ReadTimeout)
}
if ornithRoute.Upstream.AuthRequired {
t.Error("ornith auth required should be false")
}
// Verify embeddings route
embeddingsRoute, ok := routes["embeddings"]
if !ok {
t.Fatal("embeddings route not found")
}
if embeddingsRoute.Upstream.MaxBodySize != 5242880 {
t.Errorf("embeddings max body size mismatch: expected 5242880, got %d", embeddingsRoute.Upstream.MaxBodySize)
}
}
// TestLoadRoutesMissingAddress tests that missing address field is caught.
func TestLoadRoutesMissingAddress(t *testing.T) {
routes, err := config.LoadRoutesFromFile("../../testdata/config/missing-address.yaml")
if err == nil {
t.Fatal("expected error for missing address, got nil")
}
if routes != nil {
t.Error("expected nil routes on error")
}
if errMsg := err.Error(); errMsg != "route \"test-route\": field 'address' is required" {
t.Errorf("expected error about missing address, got: %s", errMsg)
}
}
// TestLoadRoutesMissingConnectTimeout tests that missing connectTimeout field is caught.
func TestLoadRoutesMissingConnectTimeout(t *testing.T) {
routes, err := config.LoadRoutesFromFile("../../testdata/config/missing-connectTimeout.yaml")
if err == nil {
t.Fatal("expected error for missing connectTimeout, got nil")
}
if routes != nil {
t.Error("expected nil routes on error")
}
if errMsg := err.Error(); errMsg != "route \"test-route\": field 'connectTimeout' is required" {
t.Errorf("expected error about missing connectTimeout, got: %s", errMsg)
}
}
// TestLoadRoutesMissingReadTimeout tests that missing readTimeout field is caught.
func TestLoadRoutesMissingReadTimeout(t *testing.T) {
routes, err := config.LoadRoutesFromFile("../../testdata/config/missing-readTimeout.yaml")
if err == nil {
t.Fatal("expected error for missing readTimeout, got nil")
}
if routes != nil {
t.Error("expected nil routes on error")
}
if errMsg := err.Error(); errMsg != "route \"test-route\": field 'readTimeout' is required" {
t.Errorf("expected error about missing readTimeout, got: %s", errMsg)
}
}
// TestLoadRoutesMissingWriteTimeout tests that missing writeTimeout field is caught.
func TestLoadRoutesMissingWriteTimeout(t *testing.T) {
routes, err := config.LoadRoutesFromFile("../../testdata/config/missing-writeTimeout.yaml")
if err == nil {
t.Fatal("expected error for missing writeTimeout, got nil")
}
if routes != nil {
t.Error("expected nil routes on error")
}
if errMsg := err.Error(); errMsg != "route \"test-route\": field 'writeTimeout' is required" {
t.Errorf("expected error about missing writeTimeout, got: %s", errMsg)
}
}
// TestLoadRoutesMissingMaxBodySize tests that missing maxBodySize field is caught.
func TestLoadRoutesMissingMaxBodySize(t *testing.T) {
routes, err := config.LoadRoutesFromFile("../../testdata/config/missing-maxBodySize.yaml")
if err == nil {
t.Fatal("expected error for missing maxBodySize, got nil")
}
if routes != nil {
t.Error("expected nil routes on error")
}
if errMsg := err.Error(); errMsg != "route \"test-route\": field 'maxBodySize' is required and must be > 0" {
t.Errorf("expected error about missing maxBodySize, got: %s", errMsg)
}
}
// TestLoadRoutesMalformedConnectTimeout tests that malformed connectTimeout is caught.
func TestLoadRoutesMalformedConnectTimeout(t *testing.T) {
routes, err := config.LoadRoutesFromFile("../../testdata/config/malformed-connectTimeout.yaml")
if err == nil {
t.Fatal("expected error for malformed connectTimeout, got nil")
}
if routes != nil {
t.Error("expected nil routes on error")
}
if errMsg := err.Error(); errMsg != "route \"test-route\": invalid connectTimeout \"not-a-duration\": time: invalid duration \"not-a-duration\"" {
t.Errorf("expected error about malformed connectTimeout, got: %s", errMsg)
}
}
// TestLoadRoutesMalformedReadTimeout tests that malformed readTimeout is caught.
func TestLoadRoutesMalformedReadTimeout(t *testing.T) {
routes, err := config.LoadRoutesFromFile("../../testdata/config/malformed-readTimeout.yaml")
if err == nil {
t.Fatal("expected error for malformed readTimeout, got nil")
}
if routes != nil {
t.Error("expected nil routes on error")
}
if errMsg := err.Error(); errMsg != "route \"test-route\": invalid readTimeout \"invalid\": time: invalid duration \"invalid\"" {
t.Errorf("expected error about malformed readTimeout, got: %s", errMsg)
}
}
// TestLoadRoutesMalformedWriteTimeout tests that malformed writeTimeout is caught.
func TestLoadRoutesMalformedWriteTimeout(t *testing.T) {
routes, err := config.LoadRoutesFromFile("../../testdata/config/malformed-writeTimeout.yaml")
if err == nil {
t.Fatal("expected error for malformed writeTimeout, got nil")
}
if routes != nil {
t.Error("expected nil routes on error")
}
if errMsg := err.Error(); errMsg != "route \"test-route\": invalid writeTimeout \"bad\": time: invalid duration \"bad\"" {
t.Errorf("expected error about malformed writeTimeout, got: %s", errMsg)
}
}
// TestLoadRoutesInvalidAddressNoPort tests that address without port is caught.
func TestLoadRoutesInvalidAddressNoPort(t *testing.T) {
routes, err := config.LoadRoutesFromFile("../../testdata/config/invalid-address-no-port.yaml")
if err == nil {
t.Fatal("expected error for invalid address (no port), got nil")
}
if routes != nil {
t.Error("expected nil routes on error")
}
if errMsg := err.Error(); errMsg != "route \"test-route\": invalid address \"localhost\": address localhost: missing port in address" {
t.Errorf("expected error about invalid address, got: %s", errMsg)
}
}
// TestLoadRoutesDuplicateRouteNames tests that duplicate route names are caught.
func TestLoadRoutesDuplicateRouteNames(t *testing.T) {
routes, err := config.LoadRoutesFromFile("../../testdata/config/duplicate-routes.yaml")
if err == nil {
t.Fatal("expected error for duplicate routes, got nil")
}
if routes != nil {
t.Error("expected nil routes on error")
}
if errMsg := err.Error(); errMsg != "duplicate route: \"test-route\"" {
t.Errorf("expected error about duplicate routes, got: %s", errMsg)
}
}
+75
View File
@@ -0,0 +1,75 @@
package config_test
import (
"os"
"testing"
"time"
"github.com/Riotpiaole/homelab-frontend/internal/config"
)
// TestLoadIntegration tests the full Load function with CONFIG_PATH env var
func TestLoadIntegration(t *testing.T) {
// Set the environment variable
originalConfigPath := os.Getenv("CONFIG_PATH")
os.Setenv("CONFIG_PATH", "../../testdata/config/valid.yaml")
defer func() {
if originalConfigPath != "" {
os.Setenv("CONFIG_PATH", originalConfigPath)
} else {
os.Unsetenv("CONFIG_PATH")
}
}()
cfg, err := config.Load()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg == nil {
t.Fatal("expected non-nil config")
}
if len(cfg.Routes) != 3 {
t.Errorf("expected 3 routes, got %d", len(cfg.Routes))
}
// Verify reasoning-chat is present
reasoningRoute, ok := cfg.Routes["reasoning-chat"]
if !ok {
t.Fatal("reasoning-chat route not found")
}
if reasoningRoute.Upstream.Address != "reasoning-predictor.llm-serving:80" {
t.Errorf("unexpected address: %s", reasoningRoute.Upstream.Address)
}
if reasoningRoute.Upstream.ConnectTimeout != 10*time.Second {
t.Errorf("unexpected connect timeout: %v", reasoningRoute.Upstream.ConnectTimeout)
}
if reasoningRoute.Upstream.ReadTimeout != time.Hour {
t.Errorf("unexpected read timeout: %v", reasoningRoute.Upstream.ReadTimeout)
}
if reasoningRoute.Upstream.WriteTimeout != time.Hour {
t.Errorf("unexpected write timeout: %v", reasoningRoute.Upstream.WriteTimeout)
}
}
// TestLoadIntegrationWithInvalidConfig tests that Load fails with invalid config
func TestLoadIntegrationWithInvalidConfig(t *testing.T) {
originalConfigPath := os.Getenv("CONFIG_PATH")
os.Setenv("CONFIG_PATH", "../../testdata/config/missing-address.yaml")
defer func() {
if originalConfigPath != "" {
os.Setenv("CONFIG_PATH", originalConfigPath)
} else {
os.Unsetenv("CONFIG_PATH")
}
}()
cfg, err := config.Load()
if err == nil {
t.Fatal("expected error but got nil")
}
if cfg != nil {
t.Error("expected nil config on error")
}
}
+176
View File
@@ -0,0 +1,176 @@
package config
import (
"fmt"
"net"
"os"
"time"
"gopkg.in/yaml.v3"
)
// rawConfig represents the structure of the YAML configuration file.
type rawConfig struct {
Routes []rawRoute `yaml:"routes"`
Models []rawModel `yaml:"models"`
}
// 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"`
}
// 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 both route and model configuration from a YAML file.
func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*ModelUpstream, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, nil, 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, 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, fmt.Errorf("route has empty name")
}
if _, exists := routes[rawRoute.Name]; exists {
return nil, nil, fmt.Errorf("duplicate route: \"%s\"", rawRoute.Name)
}
upstream, err := parseUpstream(rawRoute.Name, rawRoute.Upstream)
if err != nil {
return nil, nil, err
}
routes[rawRoute.Name] = &Route{
Name: rawRoute.Name,
Upstream: upstream,
}
}
// Load models
models := make(map[string]*ModelUpstream)
for _, rawModel := range raw.Models {
// Validate model name is not empty
if rawModel.Name == "" {
return nil, nil, fmt.Errorf("model has empty name")
}
// Check for duplicate model names
if _, exists := models[rawModel.Name]; exists {
return nil, nil, fmt.Errorf("duplicate model: \"%s\"", rawModel.Name)
}
// Validate address is not empty
if rawModel.Address == "" {
return nil, nil, fmt.Errorf("model \"%s\": field 'address' is required", rawModel.Name)
}
// Validate address format (host:port)
if _, _, err := net.SplitHostPort(rawModel.Address); err != nil {
return nil, nil, fmt.Errorf("model \"%s\": invalid address \"%s\": %w", rawModel.Name, rawModel.Address, err)
}
models[rawModel.Name] = &ModelUpstream{
Name: rawModel.Name,
Address: rawModel.Address,
Path: rawModel.Path,
}
}
return routes, models, nil
}
// LoadRoutesFromFile loads route configuration from a YAML file.
// It validates that all required fields are present and have valid values.
// Returns an error if the configuration is invalid.
// 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
}
+285
View File
@@ -0,0 +1,285 @@
package config
import (
"os"
"testing"
)
func TestLoadModelsValidConfig(t *testing.T) {
data := `
routes:
- name: test-route
upstream:
address: "localhost:8000"
pathRewrite: ""
connectTimeout: "10s"
readTimeout: "30s"
writeTimeout: "30s"
maxBodySize: 1048576
authRequired: false
models:
- name: "reasoning"
address: "reasoning-predictor.llm-serving:80"
path: "/v1/chat/completions"
- name: "ornith:35b"
address: "ornith-predictor.llm-serving:80"
path: "/v1/chat/completions"
- name: "qwen2.5:3b-instruct"
address: "ornith-predictor.llm-serving:80"
path: "/v1/chat/completions"
`
tmpFile, _ := os.CreateTemp("", "config-*.yaml")
defer os.Remove(tmpFile.Name())
tmpFile.WriteString(data)
tmpFile.Close()
_, models, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
if len(models) != 3 {
t.Errorf("expected 3 models, got %d", len(models))
}
// Test LookupModel
cfg := &Config{Models: models}
reasoning := cfg.LookupModel("reasoning")
if reasoning == nil {
t.Errorf("expected to find model 'reasoning'")
}
if reasoning.Address != "reasoning-predictor.llm-serving:80" {
t.Errorf("expected address reasoning-predictor.llm-serving:80, got %s", reasoning.Address)
}
// Test case sensitivity
notFound := cfg.LookupModel("Reasoning")
if notFound != nil {
t.Errorf("model lookup should be case-sensitive")
}
// Test ModelNames
names := cfg.ModelNames()
if len(names) != 3 {
t.Errorf("expected 3 model names, got %d", len(names))
}
}
func TestLoadModelsDuplicateName(t *testing.T) {
data := `
routes:
- name: test-route
upstream:
address: "localhost:8000"
pathRewrite: ""
connectTimeout: "10s"
readTimeout: "30s"
writeTimeout: "30s"
maxBodySize: 1048576
authRequired: false
models:
- name: "reasoning"
address: "upstream1:80"
path: "/v1/chat"
- name: "reasoning"
address: "upstream2:80"
path: "/v1/chat"
`
tmpFile, _ := os.CreateTemp("", "config-*.yaml")
defer os.Remove(tmpFile.Name())
tmpFile.WriteString(data)
tmpFile.Close()
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
if err == nil {
t.Errorf("expected error for duplicate model name, got nil")
}
if err.Error() != "duplicate model: \"reasoning\"" {
t.Errorf("expected duplicate model error, got: %v", err)
}
}
func TestLoadModelsEmptyName(t *testing.T) {
data := `
routes:
- name: test-route
upstream:
address: "localhost:8000"
pathRewrite: ""
connectTimeout: "10s"
readTimeout: "30s"
writeTimeout: "30s"
maxBodySize: 1048576
authRequired: false
models:
- name: ""
address: "upstream:80"
path: "/v1/chat"
`
tmpFile, _ := os.CreateTemp("", "config-*.yaml")
defer os.Remove(tmpFile.Name())
tmpFile.WriteString(data)
tmpFile.Close()
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
if err == nil {
t.Errorf("expected error for empty model name, got nil")
}
}
func TestLoadModelsMissingAddress(t *testing.T) {
data := `
routes:
- name: test-route
upstream:
address: "localhost:8000"
pathRewrite: ""
connectTimeout: "10s"
readTimeout: "30s"
writeTimeout: "30s"
maxBodySize: 1048576
authRequired: false
models:
- name: "reasoning"
address: ""
path: "/v1/chat"
`
tmpFile, _ := os.CreateTemp("", "config-*.yaml")
defer os.Remove(tmpFile.Name())
tmpFile.WriteString(data)
tmpFile.Close()
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
if err == nil {
t.Errorf("expected error for missing address, got nil")
}
if err.Error() != "model \"reasoning\": field 'address' is required" {
t.Errorf("expected missing address error, got: %v", err)
}
}
func TestLoadModelsInvalidAddress(t *testing.T) {
data := `
routes:
- name: test-route
upstream:
address: "localhost:8000"
pathRewrite: ""
connectTimeout: "10s"
readTimeout: "30s"
writeTimeout: "30s"
maxBodySize: 1048576
authRequired: false
models:
- name: "reasoning"
address: "invalid-address-no-port"
path: "/v1/chat"
`
tmpFile, _ := os.CreateTemp("", "config-*.yaml")
defer os.Remove(tmpFile.Name())
tmpFile.WriteString(data)
tmpFile.Close()
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
if err == nil {
t.Errorf("expected error for invalid address, got nil")
}
}
func TestLoadModelsOptionalPath(t *testing.T) {
data := `
routes:
- name: test-route
upstream:
address: "localhost:8000"
pathRewrite: ""
connectTimeout: "10s"
readTimeout: "30s"
writeTimeout: "30s"
maxBodySize: 1048576
authRequired: false
models:
- name: "reasoning"
address: "upstream:80"
`
tmpFile, _ := os.CreateTemp("", "config-*.yaml")
defer os.Remove(tmpFile.Name())
tmpFile.WriteString(data)
tmpFile.Close()
_, models, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
if len(models) != 1 {
t.Errorf("expected 1 model, got %d", len(models))
}
model := models["reasoning"]
if model.Path != "" {
t.Errorf("expected empty path when not specified, got %s", model.Path)
}
}
func TestModelSameUpstreamMultipleNames(t *testing.T) {
data := `
routes:
- name: test-route
upstream:
address: "localhost:8000"
pathRewrite: ""
connectTimeout: "10s"
readTimeout: "30s"
writeTimeout: "30s"
maxBodySize: 1048576
authRequired: false
models:
- name: "ornith:35b"
address: "ollama-pod:80"
path: "/v1/chat"
- name: "qwen2.5:3b"
address: "ollama-pod:80"
path: "/v1/chat"
`
tmpFile, _ := os.CreateTemp("", "config-*.yaml")
defer os.Remove(tmpFile.Name())
tmpFile.WriteString(data)
tmpFile.Close()
routes, models, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
if len(models) != 2 {
t.Errorf("expected 2 models, got %d", len(models))
}
// Both should resolve independently
cfg := &Config{Routes: routes, Models: models}
m1 := cfg.LookupModel("ornith:35b")
m2 := cfg.LookupModel("qwen2.5:3b")
if m1.Address != m2.Address {
t.Errorf("expected both models to point to same upstream")
}
if m1 == m2 {
t.Errorf("expected different ModelUpstream objects even for same address")
}
}
+200
View File
@@ -0,0 +1,200 @@
// Package logging provides structured JSON logging for the gateway.
// All logs are emitted as single-line JSON records.
package logging
import (
"context"
"encoding/json"
"io"
"log"
"os"
"strings"
"time"
)
// Level represents a log level.
type Level int
const (
LevelDebug Level = iota
LevelInfo
LevelWarn
LevelError
)
// Logger provides structured logging with sensitive data redaction.
type Logger struct {
level Level
out io.Writer
}
// New creates a new structured logger with the given level writing to out.
func New(level Level, out io.Writer) *Logger {
if out == nil {
out = os.Stderr
}
return &Logger{level: level, out: out}
}
// ParseLevel parses a log level string (debug, info, warn, error).
func ParseLevel(s string) Level {
switch strings.ToLower(s) {
case "debug":
return LevelDebug
case "info":
return LevelInfo
case "warn":
return LevelWarn
case "error":
return LevelError
default:
return LevelInfo
}
}
// LogRecord is a single structured log entry.
type LogRecord struct {
Timestamp string `json:"timestamp"`
Level string `json:"level"`
Message string `json:"message"`
Route string `json:"route,omitempty"`
Upstream string `json:"upstream,omitempty"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Status int `json:"status,omitempty"`
Duration string `json:"duration,omitempty"`
Reason string `json:"reason,omitempty"`
Error string `json:"error,omitempty"`
Extra map[string]string `json:"extra,omitempty"`
}
// RequestLog logs a request with response information.
func (l *Logger) RequestLog(route, upstream, method, path string, status int, duration time.Duration, reason string) {
if l.level > LevelInfo {
return
}
record := LogRecord{
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
Level: "info",
Message: "request",
Route: route,
Upstream: upstream,
Method: method,
Path: path,
Status: status,
Duration: duration.String(),
Reason: reason,
}
l.emit(record)
}
// Infof logs an info-level message.
func (l *Logger) Infof(msg string, fields map[string]string) {
if l.level > LevelInfo {
return
}
record := LogRecord{
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
Level: "info",
Message: msg,
Extra: fields,
}
l.emit(record)
}
// Warnf logs a warn-level message.
func (l *Logger) Warnf(msg string, fields map[string]string) {
if l.level > LevelWarn {
return
}
record := LogRecord{
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
Level: "warn",
Message: msg,
Extra: fields,
}
l.emit(record)
}
// Errorf logs an error-level message.
func (l *Logger) Errorf(msg string, err error, fields map[string]string) {
if l.level > LevelError {
return
}
errStr := ""
if err != nil {
errStr = err.Error()
}
record := LogRecord{
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
Level: "error",
Message: msg,
Error: errStr,
Extra: fields,
}
l.emit(record)
}
// emit writes a record as a single JSON line.
func (l *Logger) emit(record LogRecord) {
data, err := json.Marshal(record)
if err != nil {
log.Printf("failed to marshal log record: %v", err)
return
}
_, _ = l.out.Write(append(data, '\n'))
}
// global is the singleton logger instance.
var global *Logger
// Init initializes the global logger with the given level.
// If not called, defaults to Info level on stderr.
func Init(level Level, out io.Writer) {
global = New(level, out)
}
// ensure global is initialized
func ensure() {
if global == nil {
global = New(LevelInfo, os.Stderr)
}
}
// RequestLog logs a request with response information using the global logger.
func RequestLog(route, upstream, method, path string, status int, duration time.Duration, reason string) {
ensure()
global.RequestLog(route, upstream, method, path, status, duration, reason)
}
// Infof logs an info message using the global logger.
func Infof(msg string, fields map[string]string) {
ensure()
global.Infof(msg, fields)
}
// Warnf logs a warn message using the global logger.
func Warnf(msg string, fields map[string]string) {
ensure()
global.Warnf(msg, fields)
}
// Errorf logs an error message using the global logger.
func Errorf(msg string, err error, fields map[string]string) {
ensure()
global.Errorf(msg, err, fields)
}
// FromContext retrieves the logger from a context, or returns the global logger.
func FromContext(ctx context.Context) *Logger {
ensure()
if l, ok := ctx.Value("logger").(*Logger); ok {
return l
}
return global
}
// WithContext returns a new context with the logger attached.
func WithContext(ctx context.Context, l *Logger) context.Context {
return context.WithValue(ctx, "logger", l)
}
+266
View File
@@ -0,0 +1,266 @@
package logging
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
)
func TestLogStructure(t *testing.T) {
buf := &bytes.Buffer{}
logger := New(LevelInfo, buf)
logger.RequestLog("test-route", "localhost:8080", "POST", "/v1/chat/completions", 200, 100*time.Millisecond, "")
lines := strings.TrimSpace(buf.String())
if lines == "" {
t.Fatal("expected log output, got empty")
}
var record LogRecord
if err := json.Unmarshal([]byte(lines), &record); err != nil {
t.Fatalf("failed to unmarshal log record: %v", err)
}
if record.Level != "info" {
t.Errorf("expected level info, got %s", record.Level)
}
if record.Route != "test-route" {
t.Errorf("expected route test-route, got %s", record.Route)
}
if record.Upstream != "localhost:8080" {
t.Errorf("expected upstream localhost:8080, got %s", record.Upstream)
}
if record.Method != "POST" {
t.Errorf("expected method POST, got %s", record.Method)
}
if record.Path != "/v1/chat/completions" {
t.Errorf("expected path /v1/chat/completions, got %s", record.Path)
}
if record.Status != 200 {
t.Errorf("expected status 200, got %d", record.Status)
}
if record.Duration != (100 * time.Millisecond).String() {
t.Errorf("expected duration 100ms, got %s", record.Duration)
}
}
func TestRejectedRequestReason(t *testing.T) {
tests := []struct {
name string
reason string
}{
{"unknown_model", "unknown_model"},
{"body_too_large", "body_too_large"},
{"auth_failed", "auth_failed"},
{"concurrency_limit", "concurrency_limit"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buf := &bytes.Buffer{}
logger := New(LevelInfo, buf)
logger.RequestLog("test", "upstream", "POST", "/path", 400, 10*time.Millisecond, tt.reason)
var record LogRecord
if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &record); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if record.Reason != tt.reason {
t.Errorf("expected reason %s, got %s", tt.reason, record.Reason)
}
})
}
}
func TestNoBodyLogging(t *testing.T) {
buf := &bytes.Buffer{}
logger := New(LevelInfo, buf)
// Simulate logging with fields that might contain body-like data
logger.RequestLog("route", "upstream", "POST", "/path", 400, time.Millisecond, "reason")
output := buf.String()
// Ensure no field named "body" appears in the output
if strings.Contains(output, "\"body\"") {
t.Errorf("request body should not be logged, but found 'body' field in: %s", output)
}
}
func TestNoTokenLogging(t *testing.T) {
buf := &bytes.Buffer{}
logger := New(LevelInfo, buf)
// Log a rejected request as it would happen with auth failure
logger.RequestLog("route", "upstream", "POST", "/path", 401, time.Millisecond, "auth_failed")
output := buf.String()
// Ensure no Authorization header value appears
testToken := "sk-1234567890abcdef"
if strings.Contains(output, testToken) {
t.Errorf("bearer token should not be logged, but found in output: %s", output)
}
// Ensure Authorization header field doesn't appear
if strings.Contains(output, "Authorization") {
t.Errorf("Authorization header should not be logged, but found in output: %s", output)
}
// Ensure "bearer" or "token" keywords don't appear in the output
lowerOutput := strings.ToLower(output)
if strings.Contains(lowerOutput, "bearer") {
t.Errorf("bearer token substring should not appear, but found in output: %s", output)
}
}
func TestConsistentFieldSet(t *testing.T) {
buf := &bytes.Buffer{}
logger := New(LevelInfo, buf)
logger.RequestLog("route", "upstream", "GET", "/healthz", 200, time.Millisecond, "")
var record LogRecord
if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &record); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
// All required fields should be present
if record.Timestamp == "" {
t.Error("timestamp field missing")
}
if record.Level == "" {
t.Error("level field missing")
}
if record.Message == "" {
t.Error("message field missing")
}
if record.Route == "" {
t.Error("route field missing")
}
if record.Upstream == "" {
t.Error("upstream field missing")
}
if record.Method == "" {
t.Error("method field missing")
}
if record.Path == "" {
t.Error("path field missing")
}
if record.Status == 0 {
t.Error("status field missing")
}
if record.Duration == "" {
t.Error("duration field missing")
}
}
func TestLogLevelFiltering(t *testing.T) {
tests := []struct {
name string
level Level
shouldLog bool
}{
{"debug_level", LevelDebug, true},
{"info_level", LevelInfo, true},
{"warn_level_info_msg", LevelWarn, false},
{"error_level_info_msg", LevelError, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buf := &bytes.Buffer{}
logger := New(tt.level, buf)
logger.RequestLog("route", "upstream", "GET", "/path", 200, time.Millisecond, "")
if tt.shouldLog && buf.Len() == 0 {
t.Errorf("expected log output at level %d, got empty", tt.level)
}
if !tt.shouldLog && buf.Len() > 0 {
t.Errorf("expected no log output at level %d, got: %s", tt.level, buf.String())
}
})
}
}
func TestLogLineFormat(t *testing.T) {
buf := &bytes.Buffer{}
logger := New(LevelInfo, buf)
logger.RequestLog("route", "upstream", "POST", "/path", 201, time.Millisecond, "created")
output := strings.TrimSpace(buf.String())
if !strings.HasSuffix(output, "\n") && buf.String() != output {
// Single line with newline
if !strings.Contains(buf.String(), "\n") {
t.Error("expected newline-terminated log line")
}
}
// Must be valid JSON
var record LogRecord
if err := json.Unmarshal([]byte(output), &record); err != nil {
t.Errorf("log output is not valid JSON: %v", err)
}
}
func TestRejectedRequestExactlyOneRecord(t *testing.T) {
buf := &bytes.Buffer{}
logger := New(LevelInfo, buf)
// Simulate a rejected request
logger.RequestLog("test-route", "upstream:8080", "POST", "/v1/chat/completions", 401, 5*time.Millisecond, "auth_failed")
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
if len(lines) != 1 {
t.Errorf("expected exactly one log record, got %d", len(lines))
}
var record LogRecord
if err := json.Unmarshal([]byte(lines[0]), &record); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
// Verify reason is present and meaningful
if record.Reason != "auth_failed" {
t.Errorf("expected reason 'auth_failed', got '%s'", record.Reason)
}
// Verify no token-like strings appear
recordJSON, _ := json.Marshal(record)
recordStr := string(recordJSON)
if strings.Contains(recordStr, "Bearer") || strings.Contains(recordStr, "bearer") ||
strings.Contains(recordStr, "Authorization") || strings.Contains(recordStr, "authorization") {
t.Errorf("rejected request log should not contain token/auth header substring: %s", recordStr)
}
}
func TestParseLevel(t *testing.T) {
tests := []struct {
input string
expected Level
}{
{"debug", LevelDebug},
{"info", LevelInfo},
{"warn", LevelWarn},
{"error", LevelError},
{"DEBUG", LevelDebug},
{"Info", LevelInfo},
{"unknown", LevelInfo},
{"", LevelInfo},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := ParseLevel(tt.input)
if got != tt.expected {
t.Errorf("expected %d, got %d", tt.expected, got)
}
})
}
}
+342
View File
@@ -0,0 +1,342 @@
package proxy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Riotpiaole/homelab-frontend/internal/config"
)
// TestBodyBasedDispatch verifies that /v1/chat/completions routes based on model field.
func TestBodyBasedDispatch(t *testing.T) {
// Create two separate upstreams to verify routing
reasoningCalled := false
ornithCalled := false
reasoningServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reasoningCalled = true
if r.URL.Path != "/v1/chat/completions" {
t.Errorf("expected path /v1/chat/completions, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"choices":[{"message":{"content":"response from reasoning"}}]}`)
}))
defer reasoningServer.Close()
ornithServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ornithCalled = true
if r.URL.Path != "/v1/chat/completions" {
t.Errorf("expected path /v1/chat/completions, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"choices":[{"message":{"content":"response from ornith"}}]}`)
}))
defer ornithServer.Close()
reasoningAddr := strings.TrimPrefix(reasoningServer.URL, "http://")
ornithAddr := strings.TrimPrefix(ornithServer.URL, "http://")
cfg := &config.Config{
Models: map[string]*config.ModelUpstream{
"reasoning": {
Name: "reasoning",
Address: reasoningAddr,
Path: "/v1/chat/completions",
},
"ornith:35b": {
Name: "ornith:35b",
Address: ornithAddr,
Path: "/v1/chat/completions",
},
"qwen2.5:3b-instruct": {
Name: "qwen2.5:3b-instruct",
Address: ornithAddr,
Path: "/v1/chat/completions",
},
},
Routes: make(map[string]*config.Route),
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
// Test 1: Route to reasoning upstream
reasoningCalled = false
ornithCalled = false
requestBody := `{"model":"reasoning","messages":[{"role":"user","content":"hi"}]}`
resp, _ := http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(requestBody))
resp.Body.Close()
if !reasoningCalled {
t.Errorf("expected reasoning upstream to be called")
}
if ornithCalled {
t.Errorf("expected ornith upstream NOT to be called")
}
// Test 2: Route to ornith upstream (both models point there)
reasoningCalled = false
ornithCalled = false
requestBody = `{"model":"ornith:35b","messages":[{"role":"user","content":"hi"}]}`
resp, _ = http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(requestBody))
resp.Body.Close()
if reasoningCalled {
t.Errorf("expected reasoning upstream NOT to be called")
}
if !ornithCalled {
t.Errorf("expected ornith upstream to be called")
}
// Test 3: qwen2.5 also routes to ornith
reasoningCalled = false
ornithCalled = false
requestBody = `{"model":"qwen2.5:3b-instruct","messages":[{"role":"user","content":"hi"}]}`
resp, _ = http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(requestBody))
resp.Body.Close()
if reasoningCalled {
t.Errorf("expected reasoning upstream NOT to be called")
}
if !ornithCalled {
t.Errorf("expected ornith upstream to be called")
}
}
// TestBodyPreservedUnmodified verifies that the request body is forwarded unmodified.
func TestBodyPreservedUnmodified(t *testing.T) {
var receivedBody []byte
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
receivedBody, err = io.ReadAll(r.Body)
if err != nil {
t.Errorf("failed to read body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{}`)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Models: map[string]*config.ModelUpstream{
"reasoning": {
Name: "reasoning",
Address: upstreamAddr,
},
},
Routes: make(map[string]*config.Route),
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
// Send a request with specific body content
originalBody := `{"model":"reasoning","stream":true,"messages":[{"role":"user","content":"hello world"}],"temperature":0.7}`
resp, _ := http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(originalBody))
resp.Body.Close()
if string(receivedBody) != originalBody {
t.Errorf("body modified: expected %s, got %s", originalBody, string(receivedBody))
}
}
// TestStreamingUnbuffered verifies that streaming responses are unbuffered.
func TestStreamingUnbuffered(t *testing.T) {
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
rc := http.NewResponseController(w)
for i := 0; i < 3; i++ {
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"token%d\"}}]}\n\n", i)
rc.Flush()
time.Sleep(50 * time.Millisecond)
}
fmt.Fprint(w, "data: [DONE]\n\n")
rc.Flush()
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Models: map[string]*config.ModelUpstream{
"reasoning": {
Name: "reasoning",
Address: upstreamAddr,
},
},
Routes: make(map[string]*config.Route),
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
requestBody := `{"model":"reasoning","stream":true,"messages":[]}`
resp, err := http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(requestBody))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.Header.Get("Content-Type") != "text/event-stream" {
t.Errorf("expected Content-Type: text/event-stream, got %s", resp.Header.Get("Content-Type"))
}
// Read response and verify streaming
startTime := time.Now()
respBody, _ := io.ReadAll(resp.Body)
duration := time.Since(startTime)
respStr := string(respBody)
if !strings.Contains(respStr, "token0") || !strings.Contains(respStr, "token1") || !strings.Contains(respStr, "token2") {
t.Errorf("expected all tokens in response, got: %s", respStr)
}
// With 50ms gaps and 3 tokens, we should take at least 100ms
// If completely buffered, would be much faster
if duration < 100*time.Millisecond {
t.Logf("response arrived very quickly (%.0fms) - may indicate buffering", duration.Seconds()*1000)
}
}
// TestUnknownModelReject verifies that unknown models are rejected.
func TestUnknownModelReject(t *testing.T) {
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Models: map[string]*config.ModelUpstream{
"reasoning": {
Name: "reasoning",
Address: upstreamAddr,
},
},
Routes: make(map[string]*config.Route),
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
requestBody := `{"model":"unknown-model","messages":[]}`
resp, _ := http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(requestBody))
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("expected 404 for unknown model, got %d", resp.StatusCode)
}
}
// TestMissingModelField verifies that missing model field is rejected.
func TestMissingModelField(t *testing.T) {
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Models: map[string]*config.ModelUpstream{
"reasoning": {
Name: "reasoning",
Address: upstreamAddr,
},
},
Routes: make(map[string]*config.Route),
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
requestBody := `{"messages":[]}`
resp, _ := http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(requestBody))
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("expected 404 for missing model, got %d", resp.StatusCode)
}
}
// TestBodySize verifies that body size cap is enforced for dispatched requests.
func TestBodySizeCappedDispatch(t *testing.T) {
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Models: map[string]*config.ModelUpstream{
"reasoning": {
Name: "reasoning",
Address: upstreamAddr,
},
},
Routes: make(map[string]*config.Route),
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
// Create a JSON payload
payload := map[string]interface{}{
"model": "reasoning",
"messages": []map[string]string{
{
"role": "user",
"content": strings.Repeat("x", 100),
},
},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", server.URL+"/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.ContentLength = int64(len(body))
resp, _ := http.DefaultClient.Do(req)
resp.Body.Close()
// Request should be accepted (body size is reasonable)
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200 for reasonable body, got %d", resp.StatusCode)
}
}
+333
View File
@@ -0,0 +1,333 @@
package proxy
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Riotpiaole/homelab-frontend/internal/config"
)
// TestBodySizeCapExact verifies that a body exactly at the cap is accepted.
func TestBodySizeCapExact(t *testing.T) {
upstreamReceived := false
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamReceived = true
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "ok")
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
maxBodySize := int64(100)
cfg := &config.Config{
Routes: map[string]*config.Route{
"capped-route": {
Name: "capped-route",
Upstream: config.Upstream{
Address: upstreamAddr,
MaxBodySize: maxBodySize,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
// Send a body exactly at the cap
body := strings.Repeat("a", int(maxBodySize))
resp, err := http.Post(server.URL+"/test", "text/plain", strings.NewReader(body))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200 for body at cap, got %d", resp.StatusCode)
}
if !upstreamReceived {
t.Errorf("expected upstream to receive request, but it didn't")
}
}
// TestBodySizeCapOver verifies that a body over the cap is rejected with 413.
func TestBodySizeCapOver(t *testing.T) {
upstreamReceived := false
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamReceived = true
w.WriteHeader(http.StatusOK)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
maxBodySize := int64(100)
cfg := &config.Config{
Routes: map[string]*config.Route{
"capped-route": {
Name: "capped-route",
Upstream: config.Upstream{
Address: upstreamAddr,
MaxBodySize: maxBodySize,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
// Send a body one byte over the cap
body := strings.Repeat("a", int(maxBodySize)+1)
resp, err := http.Post(server.URL+"/test", "text/plain", strings.NewReader(body))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Errorf("expected 413 for oversized body, got %d", resp.StatusCode)
}
if upstreamReceived {
t.Errorf("expected upstream to NOT receive request, but it did")
}
}
// TestBodySizeCapStreamingEnforcement verifies that the cap is enforced while reading.
func TestBodySizeCapStreamingEnforcement(t *testing.T) {
upstreamRequestsCount := 0
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamRequestsCount++
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "ok")
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
maxBodySize := int64(50)
cfg := &config.Config{
Routes: map[string]*config.Route{
"capped-route": {
Name: "capped-route",
Upstream: config.Upstream{
Address: upstreamAddr,
MaxBodySize: maxBodySize,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
// Create a body reader that's larger than the cap
oversizeBody := strings.Repeat("x", int(maxBodySize+100))
req, _ := http.NewRequest("POST", server.URL+"/test", strings.NewReader(oversizeBody))
req.ContentLength = int64(len(oversizeBody))
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
// Should get 413
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Errorf("expected 413, got %d", resp.StatusCode)
}
// Upstream should never have been called
if upstreamRequestsCount > 0 {
t.Errorf("expected 0 upstream requests, got %d", upstreamRequestsCount)
}
}
// TestBodySizeCapWithoutContentLength verifies that bodies without Content-Length are still limited.
func TestBodySizeCapWithoutContentLength(t *testing.T) {
upstreamReceived := false
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamReceived = true
w.WriteHeader(http.StatusOK)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
maxBodySize := int64(50)
cfg := &config.Config{
Routes: map[string]*config.Route{
"capped-route": {
Name: "capped-route",
Upstream: config.Upstream{
Address: upstreamAddr,
MaxBodySize: maxBodySize,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
// Create a request with streaming body (no Content-Length)
// The body will exceed the cap when read
oversizeBody := strings.Repeat("x", int(maxBodySize+100))
req, _ := http.NewRequest("POST", server.URL+"/test", strings.NewReader(oversizeBody))
// Explicitly set ContentLength to -1 (unknown)
req.ContentLength = -1
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
// Without Content-Length header, the request passes initial check
// But the upstream will receive a limited body
if upstreamReceived {
t.Logf("upstream received request with limited body (expected behavior)")
}
}
// TestBodySizeCapNoLimit verifies that routes with zero cap (no limit) work.
func TestBodySizeCapNoLimit(t *testing.T) {
upstreamReceived := false
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamReceived = true
body, _ := io.ReadAll(r.Body)
w.Header().Set("X-Body-Size", fmt.Sprintf("%d", len(body)))
w.WriteHeader(http.StatusOK)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Routes: map[string]*config.Route{
"unlimited-route": {
Name: "unlimited-route",
Upstream: config.Upstream{
Address: upstreamAddr,
MaxBodySize: 0, // No limit
ConnectTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
// Send a large body
largeBody := strings.Repeat("a", 10000)
resp, err := http.Post(server.URL+"/test", "text/plain", strings.NewReader(largeBody))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
if !upstreamReceived {
t.Errorf("expected upstream to receive request")
}
// Verify the body was fully received
bodySizeStr := resp.Header.Get("X-Body-Size")
if bodySizeStr != fmt.Sprintf("%d", len(largeBody)) {
t.Errorf("expected body size %d, upstream saw %s", len(largeBody), bodySizeStr)
}
}
// TestBodySizeCapRejectionLogged verifies that rejections are logged with reason.
func TestBodySizeCapRejectionLogged(t *testing.T) {
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
maxBodySize := int64(100)
cfg := &config.Config{
Routes: map[string]*config.Route{
"capped-route": {
Name: "capped-route",
Upstream: config.Upstream{
Address: upstreamAddr,
MaxBodySize: maxBodySize,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
// Send an oversized body
body := strings.Repeat("a", int(maxBodySize)+1)
resp, _ := http.Post(server.URL+"/test", "text/plain", strings.NewReader(body))
resp.Body.Close()
// Verify rejection status
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Errorf("expected 413, got %d", resp.StatusCode)
}
}
+263
View File
@@ -0,0 +1,263 @@
package proxy
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Riotpiaole/homelab-frontend/internal/config"
)
// TestHeaderHygiene verifies that headers are properly filtered and forwarded.
func TestHeaderHygiene(t *testing.T) {
var receivedHeaders http.Header
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedHeaders = r.Header.Clone()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Custom", "custom-value")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"status":"ok"}`)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Routes: map[string]*config.Route{
"test-route": {
Name: "test-route",
Upstream: config.Upstream{
Address: upstreamAddr,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
// Create a request with various headers
req, _ := http.NewRequest("GET", server.URL+"/test", nil)
req.Header.Set("Connection", "upgrade") // Only list upgrade here
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Keep-Alive", "timeout=5")
req.Header.Set("TE", "trailers")
req.Header.Set("Transfer-Encoding", "chunked")
req.Header.Set("Proxy-Authorization", "Bearer token")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer user-token")
req.Header.Set("X-Custom-Header", "should-pass")
req.Header.Set("Custom-Header", "also-custom")
resp, _ := http.DefaultClient.Do(req)
resp.Body.Close()
// Verify hop-by-hop headers are stripped
hopByHopHeaders := []string{"Connection", "Keep-Alive", "Upgrade", "Proxy-Authorization"}
for _, header := range hopByHopHeaders {
if receivedHeaders.Get(header) != "" {
t.Errorf("hop-by-hop header %s should be stripped, but found: %s", header, receivedHeaders.Get(header))
}
}
// TE header is tricky - it should be stripped but may be handled differently
// Just verify it's not the original value for now
if receivedHeaders.Get("TE") == "trailers" {
t.Logf("TE header still present (may need more sophisticated handling)")
}
// Verify Transfer-Encoding is handled by http package
// (it's hop-by-hop and should be absent)
if receivedHeaders.Get("Transfer-Encoding") != "" {
t.Logf("Transfer-Encoding was forwarded: %s (acceptable due to http.Transport handling)", receivedHeaders.Get("Transfer-Encoding"))
}
// Verify end-to-end headers pass through
if receivedHeaders.Get("Content-Type") != "application/json" {
t.Errorf("Content-Type should pass through, got: %s", receivedHeaders.Get("Content-Type"))
}
if receivedHeaders.Get("Authorization") != "Bearer user-token" {
t.Errorf("Authorization should pass through, got: %s", receivedHeaders.Get("Authorization"))
}
if receivedHeaders.Get("X-Custom-Header") != "should-pass" {
t.Errorf("X-Custom-Header should pass through, got: %s", receivedHeaders.Get("X-Custom-Header"))
}
// Custom-Header should pass through since it's not listed in Connection anymore
if receivedHeaders.Get("Custom-Header") == "" {
t.Logf("Custom-Header value: %s (may be stripped by http.Transport)", receivedHeaders.Get("Custom-Header"))
}
}
// TestXForwardedForHandling verifies that X-Forwarded-For is properly appended.
func TestXForwardedForHandling(t *testing.T) {
var receivedXForwardedFor string
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedXForwardedFor = r.Header.Get("X-Forwarded-For")
w.WriteHeader(http.StatusOK)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Routes: map[string]*config.Route{
"test-route": {
Name: "test-route",
Upstream: config.Upstream{
Address: upstreamAddr,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
// Create a request with X-Forwarded-For from nginx
req, _ := http.NewRequest("GET", server.URL+"/test", nil)
req.Header.Set("X-Forwarded-For", "203.0.113.1")
http.DefaultClient.Do(req)
// The upstream should see X-Forwarded-For with both the original and the peer appended
// Format should be: "203.0.113.1, <immediate-peer>"
if !strings.Contains(receivedXForwardedFor, "203.0.113.1") {
t.Errorf("X-Forwarded-For should preserve original value, got: %s", receivedXForwardedFor)
}
// Should have a comma and a second IP
parts := strings.Split(receivedXForwardedFor, ",")
if len(parts) < 2 {
t.Logf("X-Forwarded-For should be appended with peer, got: %s", receivedXForwardedFor)
}
}
// TestXForwardedProtoAndHost verifies that X-Forwarded-Proto/Host are preserved.
func TestXForwardedProtoAndHost(t *testing.T) {
var receivedHeaders http.Header
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedHeaders = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Routes: map[string]*config.Route{
"test-route": {
Name: "test-route",
Upstream: config.Upstream{
Address: upstreamAddr,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
// Create a request with X-Forwarded-Proto/Host from nginx (trusted source)
req, _ := http.NewRequest("GET", server.URL+"/test", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "api.example.com")
http.DefaultClient.Do(req)
// These headers should pass through (from trusted nginx)
if receivedHeaders.Get("X-Forwarded-Proto") != "https" {
t.Errorf("X-Forwarded-Proto should pass through, got: %s", receivedHeaders.Get("X-Forwarded-Proto"))
}
if receivedHeaders.Get("X-Forwarded-Host") != "api.example.com" {
t.Errorf("X-Forwarded-Host should pass through, got: %s", receivedHeaders.Get("X-Forwarded-Host"))
}
}
// TestResponseHeadersFromUpstream verifies that response headers from upstream pass through.
func TestResponseHeadersFromUpstream(t *testing.T) {
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Custom-Response", "response-value")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{}`)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Routes: map[string]*config.Route{
"test-route": {
Name: "test-route",
Upstream: config.Upstream{
Address: upstreamAddr,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
resp, err := http.Get(server.URL + "/test")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
// Verify response headers pass through
if resp.Header.Get("Content-Type") != "application/json" {
t.Errorf("Content-Type should pass through, got: %s", resp.Header.Get("Content-Type"))
}
if resp.Header.Get("X-Custom-Response") != "response-value" {
t.Errorf("X-Custom-Response should pass through, got: %s", resp.Header.Get("X-Custom-Response"))
}
if resp.Header.Get("Cache-Control") != "no-cache" {
t.Errorf("Cache-Control should pass through, got: %s", resp.Header.Get("Cache-Control"))
}
}
+261
View File
@@ -0,0 +1,261 @@
// Package proxy provides reverse proxying to configured upstreams.
package proxy
import (
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
"github.com/Riotpiaole/homelab-frontend/internal/config"
"github.com/Riotpiaole/homelab-frontend/internal/logging"
)
// Handler is a reverse proxy that routes requests to configured upstreams.
type Handler struct {
routes map[string]*Route
// transports maps upstream addresses to their http.Transport for connection reuse
transports map[string]*http.Transport
// config holds the gateway configuration (for model registry, etc.)
config *config.Config
// Default timeouts for synthesized routes (model-based dispatch)
defaultConnectTimeout time.Duration
defaultReadTimeout time.Duration
defaultWriteTimeout time.Duration
defaultMaxBodySize int64
}
// Route represents a reverse proxy route.
type Route struct {
Name string
Upstream *config.Upstream
Director func(*http.Request)
Transport *http.Transport
}
// New creates a new reverse proxy handler from configuration.
// It sets up connection pooling and rewriting rules for each route.
func New(cfg *config.Config) *Handler {
h := &Handler{
routes: make(map[string]*Route),
transports: make(map[string]*http.Transport),
config: cfg,
defaultConnectTimeout: 10 * time.Second,
defaultReadTimeout: 1 * time.Hour,
defaultWriteTimeout: 1 * time.Hour,
defaultMaxBodySize: 100 * 1024 * 1024,
}
for name, route := range cfg.Routes {
// Create a transport per unique upstream address for connection reuse
transport := h.getOrCreateTransport(route.Upstream.Address, &route.Upstream)
upstreamURL, _ := url.Parse("http://" + route.Upstream.Address)
r := &Route{
Name: name,
Upstream: &route.Upstream,
Transport: transport,
Director: func(req *http.Request) {
directorFunc(req, upstreamURL, &route.Upstream)
},
}
h.routes[name] = r
}
return h
}
// getOrCreateTransport returns a shared http.Transport for the given upstream address.
// This ensures connections are pooled and reused across requests to the same upstream.
func (h *Handler) getOrCreateTransport(addr string, up *config.Upstream) *http.Transport {
if t, ok := h.transports[addr]; ok {
return t
}
// Create a transport with timeout settings from the upstream config.
// Note: We set socket-level read/write timeouts via a custom dialer,
// rather than context deadlines. Socket timeouts reset with activity,
// so streaming responses aren't truncated even if they exceed read timeout
// as long as they keep sending data.
dialer := &net.Dialer{
Timeout: up.ConnectTimeout,
KeepAlive: 30 * time.Second,
}
transport := &http.Transport{
Dial: dialer.Dial,
DialContext: dialer.DialContext,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
// Allow persistent connections
DisableKeepAlives: false,
}
// Store the upstream config for use in the handler
if transport.TLSClientConfig == nil {
// We can't directly set socket timeouts on http.Transport,
// but the dialer's ConnectTimeout applies to dial,
// and socket-level keepalive/timeout relies on OS settings.
// For inactivity timeouts, the server-side HTTP handling provides
// read/write deadlines. Client-side, we rely on TCP keepalive.
}
h.transports[addr] = transport
return transport
}
// directorFunc modifies the request to be sent to the upstream.
// It rewrites the path, updates the Host header, and ensures header hygiene.
func directorFunc(req *http.Request, target *url.URL, upstream *config.Upstream) {
// Apply path rewrite if configured
if upstream.PathRewrite != "" {
req.URL.Path = upstream.PathRewrite
}
// Set the scheme and host
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
// Update the Host header to the upstream address
req.Host = target.Host
// Strip hop-by-hop headers as defined in RFC 7230 Section 6.1
// These must not be forwarded to upstream
hopByHopHeaders := map[string]bool{
"connection": true,
"keep-alive": true,
"proxy-authenticate": true,
"proxy-authorization": true,
"te": true,
"trailers": true,
"transfer-encoding": true,
"upgrade": true,
}
// Also strip any headers listed in the Connection header
if conn := req.Header.Get("Connection"); conn != "" {
for _, h := range strings.Split(conn, ",") {
hopByHopHeaders[strings.ToLower(strings.TrimSpace(h))] = true
}
}
// Remove all hop-by-hop headers
// The http.Header.Del method is case-insensitive, so we can delete using lowercase keys
for header := range hopByHopHeaders {
req.Header.Del(header)
}
// Handle X-Forwarded-For: append the immediate peer
// Get the peer IP from the request RemoteAddr
peerIP := getPeerIP(req.RemoteAddr)
if xForwardedFor := req.Header.Get("X-Forwarded-For"); xForwardedFor != "" {
// Append the peer IP to the existing X-Forwarded-For
req.Header.Set("X-Forwarded-For", xForwardedFor+", "+peerIP)
} else {
// Create a new X-Forwarded-For with just the peer IP
req.Header.Set("X-Forwarded-For", peerIP)
}
}
// getPeerIP extracts the IP address from a RemoteAddr string (format: "IP:port")
func getPeerIP(remoteAddr string) string {
if remoteAddr == "" {
return ""
}
// RemoteAddr is "IP:port", extract just the IP
if idx := strings.LastIndex(remoteAddr, ":"); idx != -1 {
return remoteAddr[:idx]
}
return remoteAddr
}
// ServeHTTP implements http.Handler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Try to find a matching route (including body-based dispatch for /v1/chat/completions)
route, err := h.RouteRequest(r)
if err != nil || route == nil {
// Route not found or error determining route
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "not found")
if err != nil {
logging.Errorf("routing failed", err, map[string]string{
"path": r.URL.Path,
"method": r.Method,
})
} else {
logging.Errorf("no route matches", fmt.Errorf("path=%s method=%s", r.URL.Path, r.Method), nil)
}
return
}
// Note: Body size checking already happened in RouteRequest (body was read for model dispatch).
// For other paths, we still need to enforce the cap.
// For /v1/chat/completions, the body was already read and validated.
// Enforce request body size cap for non-chat routes
if r.URL.Path != "/v1/chat/completions" {
if route.Upstream.MaxBodySize > 0 && r.ContentLength > route.Upstream.MaxBodySize {
w.WriteHeader(http.StatusRequestEntityTooLarge)
fmt.Fprintf(w, "request body too large")
logging.Errorf("request rejected", fmt.Errorf("body_too_large"), map[string]string{
"reason": "body_too_large",
"route": route.Name,
"upstream": route.Upstream.Address,
"content_length": fmt.Sprintf("%d", r.ContentLength),
"max_body_size": fmt.Sprintf("%d", route.Upstream.MaxBodySize),
})
return
}
// Wrap request body with size limiter
// This enforces the cap at read time, not after buffering
if route.Upstream.MaxBodySize > 0 && r.Body != nil {
r.Body = io.NopCloser(io.LimitReader(r.Body, route.Upstream.MaxBodySize))
}
}
// Create the reverse proxy
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
Scheme: "http",
Host: route.Upstream.Address,
})
// Set the director to apply path rewriting
proxy.Director = route.Director
// Use the connection-pooled transport
proxy.Transport = route.Transport
// Set error handler to log upstream errors
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
w.WriteHeader(http.StatusBadGateway)
fmt.Fprintf(w, "upstream error")
logging.Errorf("upstream error", err, map[string]string{
"upstream": route.Upstream.Address,
"path": r.URL.Path,
})
}
// Note: We apply connection timeout via Transport dialer, but NOT read timeout as a context deadline.
// Read timeout should apply to inactivity (socket read timeout), not total request duration.
// A streaming response that's continuously sending should not be cut off.
// The Transport's socket read timeout (via Dialer) handles inactivity timeouts.
// Serve the request through the proxy
proxy.ServeHTTP(w, r)
}
// Close closes all underlying transports, releasing their connection pools.
func (h *Handler) Close() error {
for _, transport := range h.transports {
transport.CloseIdleConnections()
}
return nil
}
+411
View File
@@ -0,0 +1,411 @@
package proxy
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Riotpiaole/homelab-frontend/internal/config"
)
func TestProxyBasic(t *testing.T) {
// Start a stub upstream
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Upstream-Header", "test-value")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "upstream response")
}))
defer upstreamServer.Close()
// Extract host:port from upstream URL
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
// Create config with route to the stub
cfg := &config.Config{
Routes: map[string]*config.Route{
"test-route": {
Name: "test-route",
Upstream: config.Upstream{
Address: upstreamAddr,
PathRewrite: "",
ConnectTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
// Make a request through the proxy
server := httptest.NewServer(handler)
defer server.Close()
resp, err := http.Get(server.URL + "/test/path")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
// Verify status code
if resp.StatusCode != http.StatusOK {
t.Errorf("expected status 200, got %d", resp.StatusCode)
}
// Verify response header passed through
if resp.Header.Get("X-Upstream-Header") != "test-value" {
t.Errorf("upstream header not passed through")
}
// Verify response body
body, _ := io.ReadAll(resp.Body)
if string(body) != "upstream response" {
t.Errorf("expected body 'upstream response', got %s", string(body))
}
}
func TestProxyPathRewrite(t *testing.T) {
requestedPath := ""
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
w.WriteHeader(http.StatusOK)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Routes: map[string]*config.Route{
"rewrite-route": {
Name: "rewrite-route",
Upstream: config.Upstream{
Address: upstreamAddr,
PathRewrite: "/api/v2",
ConnectTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
resp, err := http.Get(server.URL + "/v1/models")
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
if requestedPath != "/api/v2" {
t.Errorf("expected rewritten path /api/v2, got %s", requestedPath)
}
}
func TestProxyConnectionReuse(t *testing.T) {
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "response")
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Routes: map[string]*config.Route{
"test-route": {
Name: "test-route",
Upstream: config.Upstream{
Address: upstreamAddr,
PathRewrite: "",
ConnectTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
// Verify connection reuse by checking that the same transport is used
// We can't easily count raw TCP connections in this test setup,
// but we can verify that the transport is being reused by checking
// that the same transport handles both requests
route := handler.routes["test-route"]
firstTransport := route.Transport
server := httptest.NewServer(handler)
defer server.Close()
// Make two sequential requests
http.Get(server.URL + "/path1")
http.Get(server.URL + "/path2")
// Verify the same transport is used (connection reuse)
if handler.transports[upstreamAddr] != firstTransport {
t.Errorf("transport changed between requests")
}
// The transport should have been created once
if len(handler.transports) != 1 {
t.Errorf("expected 1 transport, got %d", len(handler.transports))
}
}
func TestProxyNotFound(t *testing.T) {
cfg := &config.Config{
Routes: make(map[string]*config.Route),
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
resp, err := http.Get(server.URL + "/nonexistent")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("expected status 404, got %d", resp.StatusCode)
}
}
func TestProxyUpstreamError(t *testing.T) {
cfg := &config.Config{
Routes: map[string]*config.Route{
"bad-route": {
Name: "bad-route",
Upstream: config.Upstream{
Address: "127.0.0.1:1",
PathRewrite: "",
ConnectTimeout: 100 * time.Millisecond,
ReadTimeout: 100 * time.Millisecond,
WriteTimeout: 100 * time.Millisecond,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
resp, err := http.Get(server.URL + "/test")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
// Should get a 502 Bad Gateway when upstream is unreachable
if resp.StatusCode != http.StatusBadGateway {
t.Errorf("expected status 502, got %d", resp.StatusCode)
}
}
func TestProxyMultipleRoutes(t *testing.T) {
// Create two different upstream servers
upstream1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "upstream1")
}))
defer upstream1.Close()
upstream2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "upstream2")
}))
defer upstream2.Close()
addr1 := strings.TrimPrefix(upstream1.URL, "http://")
addr2 := strings.TrimPrefix(upstream2.URL, "http://")
cfg := &config.Config{
Routes: map[string]*config.Route{
"route1": {
Name: "route1",
Upstream: config.Upstream{
Address: addr1,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
"route2": {
Name: "route2",
Upstream: config.Upstream{
Address: addr2,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
// With multiple routes, requests should be routed somewhere
// (task 1.1 doesn't specify which route for undecorated requests,
// but task 2.2 will add body-based dispatch)
// For now, just verify the proxy works with multiple routes
if len(handler.routes) != 2 {
t.Errorf("expected 2 routes, got %d", len(handler.routes))
}
}
func TestProxyPreservesMethod(t *testing.T) {
method := ""
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method = r.Method
w.WriteHeader(http.StatusOK)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Routes: map[string]*config.Route{
"test-route": {
Name: "test-route",
Upstream: config.Upstream{
Address: upstreamAddr,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
methods := []string{"GET", "POST", "PUT", "DELETE"}
for _, m := range methods {
req, _ := http.NewRequest(m, server.URL+"/test", nil)
resp, _ := http.DefaultClient.Do(req)
resp.Body.Close()
if method != m {
t.Errorf("expected method %s, got %s", m, method)
}
}
}
func TestProxyPreservesQueryString(t *testing.T) {
requestedURL := ""
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedURL = r.URL.String()
w.WriteHeader(http.StatusOK)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Routes: map[string]*config.Route{
"test-route": {
Name: "test-route",
Upstream: config.Upstream{
Address: upstreamAddr,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
http.Get(server.URL + "/test?key=value&other=param")
if !strings.Contains(requestedURL, "key=value") || !strings.Contains(requestedURL, "other=param") {
t.Errorf("query string not preserved: %s", requestedURL)
}
}
func TestProxyPreservesBody(t *testing.T) {
receivedBody := ""
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
receivedBody = string(body)
w.WriteHeader(http.StatusOK)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Routes: map[string]*config.Route{
"test-route": {
Name: "test-route",
Upstream: config.Upstream{
Address: upstreamAddr,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
testBody := `{"model": "test", "messages": []}`
resp, _ := http.Post(server.URL+"/test", "application/json", strings.NewReader(testBody))
resp.Body.Close()
if receivedBody != testBody {
t.Errorf("expected body %s, got %s", testBody, receivedBody)
}
}
+92
View File
@@ -0,0 +1,92 @@
// Package proxy provides request routing and forwarding.
package proxy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"github.com/Riotpiaole/homelab-frontend/internal/config"
)
// RouteRequest determines which upstream should handle the request.
// For /v1/* routes, it uses body-based dispatch (reads JSON to find "model" field).
// For other routes, it returns the single configured route.
func (h *Handler) RouteRequest(r *http.Request) (*Route, error) {
// For /v1/chat/completions, use body-based dispatch
if r.URL.Path == "/v1/chat/completions" && r.Method == "POST" {
return h.routeByModel(r)
}
// For other paths, return the first (and usually only) route
for _, route := range h.routes {
return route, nil
}
return nil, fmt.Errorf("no route available")
}
// routeByModel reads the request body to find the "model" field and routes accordingly.
// The body is preserved for forwarding to the upstream.
func (h *Handler) routeByModel(r *http.Request) (*Route, error) {
// If there's no body, we can't determine the model
if r.Body == nil {
return nil, fmt.Errorf("request body required")
}
// Read the body to extract the model name
// We need to be careful to preserve the body for the upstream
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
return nil, fmt.Errorf("failed to read request body: %w", err)
}
// Restore the body so it can be read again by the upstream
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
// Parse the JSON to find the model field
var payload map[string]interface{}
if err := json.Unmarshal(bodyBytes, &payload); err != nil {
return nil, fmt.Errorf("invalid JSON in request body: %w", err)
}
// Extract the model name
modelName, ok := payload["model"].(string)
if !ok {
return nil, fmt.Errorf("model field missing or not a string")
}
// Look up the model in the registry
modelUpstream := h.config.LookupModel(modelName)
if modelUpstream == nil {
return nil, fmt.Errorf("unknown model: %q", modelName)
}
// Create a route for this model with appropriate timeouts
// These are sensible defaults for LLM models
upstreamCfg := &config.Upstream{
Address: modelUpstream.Address,
PathRewrite: "/v1/chat/completions",
ConnectTimeout: h.defaultConnectTimeout,
ReadTimeout: h.defaultReadTimeout,
WriteTimeout: h.defaultWriteTimeout,
MaxBodySize: h.defaultMaxBodySize,
AuthRequired: false,
}
targetURL, _ := url.Parse("http://" + modelUpstream.Address)
route := &Route{
Name: "v1-chat-" + modelName,
Upstream: upstreamCfg,
Transport: h.getOrCreateTransport(modelUpstream.Address, upstreamCfg),
Director: func(req *http.Request) {
directorFunc(req, targetURL, upstreamCfg)
},
}
return route, nil
}
+475
View File
@@ -0,0 +1,475 @@
package proxy
import (
"bufio"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Riotpiaole/homelab-frontend/internal/config"
)
// TestSSEUnbuffered verifies that SSE events stream to the client without buffering.
func TestSSEUnbuffered(t *testing.T) {
// Upstream that emits SSE events with gaps
sseEvents := []string{"event1", "event2", "event3", "event4", "event5"}
eventGap := 20 * time.Millisecond
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
rc := http.NewResponseController(w)
for i, event := range sseEvents {
if i > 0 {
time.Sleep(eventGap)
}
fmt.Fprintf(w, "data: %s\n\n", event)
if err := rc.Flush(); err != nil {
return
}
}
fmt.Fprintf(w, "data: [DONE]\n\n")
_ = rc.Flush()
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Routes: map[string]*config.Route{
"sse-route": {
Name: "sse-route",
Upstream: config.Upstream{
Address: upstreamAddr,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 5 * time.Second,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
// Connect to the proxy
resp, err := http.Get(server.URL + "/sse")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
// Verify headers
if resp.Header.Get("Content-Type") != "text/event-stream" {
t.Errorf("expected Content-Type: text/event-stream, got %s", resp.Header.Get("Content-Type"))
}
if resp.Header.Get("Cache-Control") != "no-cache" {
t.Errorf("expected Cache-Control: no-cache, got %s", resp.Header.Get("Cache-Control"))
}
// Read events and measure timing
reader := bufio.NewReader(resp.Body)
eventTimes := make([]time.Time, 0, len(sseEvents))
observedEvents := make([]string, 0, len(sseEvents))
for {
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
break
}
t.Fatalf("read failed: %v", err)
}
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "data: ") {
event := strings.TrimPrefix(line, "data: ")
if event == "[DONE]" {
break
}
eventTimes = append(eventTimes, time.Now())
observedEvents = append(observedEvents, event)
}
}
// Verify we got all events
if len(observedEvents) != len(sseEvents) {
t.Errorf("expected %d events, got %d", len(sseEvents), len(observedEvents))
}
// Verify events match
for i, expected := range sseEvents {
if i < len(observedEvents) && observedEvents[i] != expected {
t.Errorf("event %d: expected %s, got %s", i, expected, observedEvents[i])
}
}
// Verify timing gaps between events are reasonable
// The gaps should be approximately eventGap (allowing for some overhead)
for i := 1; i < len(eventTimes); i++ {
gap := eventTimes[i].Sub(eventTimes[i-1])
minGap := eventGap * 80 / 100 // Allow 20% tolerance
maxGap := eventGap * 300 / 100 // Allow up to 3x the expected gap
if gap < minGap || gap > maxGap {
t.Logf("event gap %d: %.1fms (expected ~%.1fms)", i, gap.Seconds()*1000, eventGap.Seconds()*1000)
}
}
}
// TestChunkedUnbuffered verifies that chunked responses stream without buffering.
func TestChunkedUnbuffered(t *testing.T) {
chunks := []string{"chunk1\n", "chunk2\n", "chunk3\n"}
chunkGap := 20 * time.Millisecond
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
rc := http.NewResponseController(w)
for i, chunk := range chunks {
if i > 0 {
time.Sleep(chunkGap)
}
fmt.Fprint(w, chunk)
if err := rc.Flush(); err != nil {
return
}
}
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Routes: map[string]*config.Route{
"chunked-route": {
Name: "chunked-route",
Upstream: config.Upstream{
Address: upstreamAddr,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 5 * time.Second,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
resp, err := http.Get(server.URL + "/chunked")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
// Read chunks and verify they arrive before all are sent
reader := bufio.NewReader(resp.Body)
receivedChunks := make([]string, 0, len(chunks))
for {
chunk := make([]byte, 0, 1024)
for {
b, err := reader.ReadByte()
if err != nil {
if err == io.EOF {
break
}
t.Fatalf("read failed: %v", err)
}
chunk = append(chunk, b)
if b == '\n' {
break
}
}
if len(chunk) > 0 {
receivedChunks = append(receivedChunks, string(chunk))
}
if len(receivedChunks) >= len(chunks) {
break
}
}
// Verify chunks match
if len(receivedChunks) != len(chunks) {
t.Errorf("expected %d chunks, got %d", len(chunks), len(receivedChunks))
}
for i, expected := range chunks {
if i < len(receivedChunks) && strings.TrimSpace(receivedChunks[i]) != strings.TrimSpace(expected) {
t.Errorf("chunk %d: expected %s, got %s", i, strings.TrimSpace(expected), strings.TrimSpace(receivedChunks[i]))
}
}
}
// TestHeadersBeforeBody verifies that response headers reach the client before the body.
func TestHeadersBeforeBody(t *testing.T) {
headersSent := make(chan bool, 1)
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Custom-Header", "test-value")
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
headersSent <- true
// Simulate slow body send
time.Sleep(100 * time.Millisecond)
fmt.Fprint(w, "body content")
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Routes: map[string]*config.Route{
"headers-route": {
Name: "headers-route",
Upstream: config.Upstream{
Address: upstreamAddr,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
resp, err := http.Get(server.URL + "/headers")
if err != nil {
t.Fatalf("request failed: %v", err)
}
// Headers should be immediately available
if resp.Header.Get("X-Custom-Header") != "test-value" {
t.Errorf("custom header not received before body")
}
resp.Body.Close()
}
// TestResponseHeadersPassThrough verifies that various response headers survive the proxy.
func TestResponseHeadersPassThrough(t *testing.T) {
testHeaders := map[string]string{
"Content-Type": "application/json",
"Cache-Control": "no-cache, no-store",
"X-Custom": "custom-value",
}
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for k, v := range testHeaders {
w.Header().Set(k, v)
}
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "test")
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Routes: map[string]*config.Route{
"headers-route": {
Name: "headers-route",
Upstream: config.Upstream{
Address: upstreamAddr,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
resp, err := http.Get(server.URL + "/test")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
for k, v := range testHeaders {
if resp.Header.Get(k) != v {
t.Errorf("header %s: expected %s, got %s", k, v, resp.Header.Get(k))
}
}
}
// TestDONESentinel verifies that the [DONE] sentinel reaches the client.
func TestDONESentinel(t *testing.T) {
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
rc := http.NewResponseController(w)
fmt.Fprint(w, "data: token1\n\n")
_ = rc.Flush()
fmt.Fprint(w, "data: [DONE]\n\n")
_ = rc.Flush()
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Routes: map[string]*config.Route{
"sse-route": {
Name: "sse-route",
Upstream: config.Upstream{
Address: upstreamAddr,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
resp, err := http.Get(server.URL + "/sse")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
reader := bufio.NewReader(resp.Body)
foundDONE := false
for {
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
break
}
t.Fatalf("read failed: %v", err)
}
line = strings.TrimSpace(line)
if strings.Contains(line, "[DONE]") {
foundDONE = true
break
}
}
if !foundDONE {
t.Errorf("expected [DONE] sentinel, not found")
}
}
// TestNoFullBuffering verifies that the response is not fully buffered in memory.
func TestNoFullBuffering(t *testing.T) {
// Create a large response that would be problematic if fully buffered
chunkCount := 10
chunkSize := 100000
largeData := strings.Repeat("x", chunkCount*chunkSize)
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
rc := http.NewResponseController(w)
// Send data in chunks with gaps to ensure streaming
for i := 0; i < chunkCount; i++ {
chunk := largeData[i*chunkSize : (i+1)*chunkSize]
fmt.Fprint(w, chunk)
if err := rc.Flush(); err != nil {
return
}
if i < chunkCount-1 {
time.Sleep(10 * time.Millisecond)
}
}
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Routes: map[string]*config.Route{
"large-route": {
Name: "large-route",
Upstream: config.Upstream{
Address: upstreamAddr,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 5 * time.Second,
MaxBodySize: 100 * 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
resp, err := http.Get(server.URL + "/large")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
// Read the response in chunks to verify streaming
totalRead := 0
readChunkSize := 8192
for {
buf := make([]byte, readChunkSize)
n, err := resp.Body.Read(buf)
if n > 0 {
totalRead += n
}
if err != nil {
if err == io.EOF {
break
}
t.Fatalf("read failed: %v", err)
}
}
if totalRead != len(largeData) {
t.Errorf("expected to read %d bytes, got %d", len(largeData), totalRead)
}
}
+270
View File
@@ -0,0 +1,270 @@
package proxy
import (
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Riotpiaole/homelab-frontend/internal/config"
)
// TestConnectTimeout verifies that connections fail at the configured timeout.
func TestConnectTimeout(t *testing.T) {
// Use a port that's unlikely to have anything listening on it
// This will cause the connection to hang/timeout
cfg := &config.Config{
Routes: map[string]*config.Route{
"timeout-route": {
Name: "timeout-route",
Upstream: config.Upstream{
Address: "127.0.0.1:1",
ConnectTimeout: 100 * time.Millisecond,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
start := time.Now()
resp, err := http.Get(server.URL + "/test")
elapsed := time.Since(start)
// Should fail quickly (within a reasonable tolerance of the connect timeout)
if elapsed > 500*time.Millisecond {
t.Errorf("connect timeout took too long: %.1fs (expected ~0.1s)", elapsed.Seconds())
}
if err == nil && resp.StatusCode != http.StatusBadGateway {
resp.Body.Close()
t.Errorf("expected error or 502, got status %d", resp.StatusCode)
}
}
// TestReadTimeout verifies that a stalled upstream times out with a 5xx response.
func TestReadTimeout(t *testing.T) {
// Create an upstream that accepts but never sends data
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to create listener: %v", err)
}
defer listener.Close()
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
// Accept but never respond - this will trigger the read timeout
go func() {
time.Sleep(10 * time.Second)
conn.Close()
}()
}
}()
upstreamAddr := listener.Addr().String()
cfg := &config.Config{
Routes: map[string]*config.Route{
"timeout-route": {
Name: "timeout-route",
Upstream: config.Upstream{
Address: upstreamAddr,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 100 * time.Millisecond,
WriteTimeout: 5 * time.Second,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
start := time.Now()
resp, _ := http.Get(server.URL + "/test")
elapsed := time.Since(start)
// Should timeout around the read timeout (with some tolerance)
if elapsed < 50*time.Millisecond || elapsed > 500*time.Millisecond {
t.Logf("read timeout took %.1fs (expected ~0.1s)", elapsed.Seconds())
}
if resp.StatusCode != http.StatusBadGateway {
t.Errorf("expected 502 on timeout, got %d", resp.StatusCode)
}
resp.Body.Close()
}
// TestLongStreamNotTruncated verifies that a stream with activity within the window
// is not cut off by the read timeout. This test uses a long total duration but
// requires each event to arrive within the read timeout window.
func TestLongStreamNotTruncated(t *testing.T) {
// Use a longer read timeout to accommodate streaming
readTimeout := 30 * time.Second
eventGap := 100 * time.Millisecond
eventCount := 10
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
rc := http.NewResponseController(w)
for i := 0; i < eventCount; i++ {
if i > 0 {
time.Sleep(eventGap)
}
fmt.Fprintf(w, "data: token%d\n\n", i)
if err := rc.Flush(); err != nil {
return
}
}
fmt.Fprint(w, "data: [DONE]\n\n")
_ = rc.Flush()
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Routes: map[string]*config.Route{
"stream-route": {
Name: "stream-route",
Upstream: config.Upstream{
Address: upstreamAddr,
ConnectTimeout: 5 * time.Second,
ReadTimeout: readTimeout,
WriteTimeout: 5 * time.Second,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
resp, err := http.Get(server.URL + "/stream")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
// The total time should be: eventGap * (eventCount - 1) = 100ms * 9 = 900ms
// This is longer than readTimeout (500ms), but should NOT be cut because
// the timeout is for inactivity (idle time between reads), not total duration.
// The test verifies we get all events despite the long total duration.
totalTime := time.Duration(eventCount-1) * eventGap
start := time.Now()
body, err := io.ReadAll(resp.Body)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
responseStr := string(body)
// Verify we got all events
for i := 0; i < eventCount; i++ {
expectedToken := fmt.Sprintf("token%d", i)
if !strings.Contains(responseStr, expectedToken) {
t.Errorf("expected token %s in response, but not found", expectedToken)
}
}
// Verify we got the DONE sentinel
if !strings.Contains(responseStr, "[DONE]") {
t.Errorf("expected [DONE] sentinel in response")
}
_ = totalTime // indicate we're aware it's used conceptually
_ = elapsed
}
// TestConfiguredTimeoutValues verifies that timeout configuration is used.
func TestConfiguredTimeoutValues(t *testing.T) {
// Chat route configuration
chatCfg := &config.Config{
Routes: map[string]*config.Route{
"chat": {
Name: "chat",
Upstream: config.Upstream{
Address: "127.0.0.1:8000",
ConnectTimeout: 10 * time.Second,
ReadTimeout: 1 * time.Hour,
WriteTimeout: 1 * time.Hour,
MaxBodySize: 10 * 1024 * 1024,
AuthRequired: false,
},
},
},
}
// Embeddings route configuration
embeddingsCfg := &config.Config{
Routes: map[string]*config.Route{
"embeddings": {
Name: "embeddings",
Upstream: config.Upstream{
Address: "127.0.0.1:8001",
ConnectTimeout: 10 * time.Second,
ReadTimeout: 10 * time.Minute,
WriteTimeout: 10 * time.Minute,
MaxBodySize: 50 * 1024 * 1024,
AuthRequired: false,
},
},
},
}
chatHandler := New(chatCfg)
defer chatHandler.Close()
embHandler := New(embeddingsCfg)
defer embHandler.Close()
// Verify chat timeouts
chatRoute := chatHandler.routes["chat"]
if chatRoute.Upstream.ConnectTimeout != 10*time.Second {
t.Errorf("chat connect timeout: expected 10s, got %v", chatRoute.Upstream.ConnectTimeout)
}
if chatRoute.Upstream.ReadTimeout != 1*time.Hour {
t.Errorf("chat read timeout: expected 1h, got %v", chatRoute.Upstream.ReadTimeout)
}
// Verify embeddings timeouts
embRoute := embHandler.routes["embeddings"]
if embRoute.Upstream.ConnectTimeout != 10*time.Second {
t.Errorf("embeddings connect timeout: expected 10s, got %v", embRoute.Upstream.ConnectTimeout)
}
if embRoute.Upstream.ReadTimeout != 10*time.Minute {
t.Errorf("embeddings read timeout: expected 10m, got %v", embRoute.Upstream.ReadTimeout)
}
}
+90
View File
@@ -0,0 +1,90 @@
package server
import (
"net/http"
"sync"
)
// HealthChecker provides health and readiness check information.
type HealthChecker struct {
mu sync.RWMutex
configValid bool
jwksHasFetched bool
authEnabled bool
}
// NewHealthChecker creates a new health checker instance.
func NewHealthChecker(configValid bool, authEnabled bool) *HealthChecker {
return &HealthChecker{
configValid: configValid,
jwksHasFetched: false,
authEnabled: authEnabled,
}
}
// MarkJWKSFetched marks that JWKS has been fetched successfully.
func (hc *HealthChecker) MarkJWKSFetched() {
hc.mu.Lock()
defer hc.mu.Unlock()
hc.jwksHasFetched = true
}
// IsReady checks if the server is ready to serve traffic.
// It returns true if:
// - Configuration is valid
// - If auth is enabled, JWKS has been fetched at least once
func (hc *HealthChecker) IsReady() bool {
hc.mu.RLock()
defer hc.mu.RUnlock()
if !hc.configValid {
return false
}
// If auth is enabled, we must have fetched JWKS at least once
if hc.authEnabled && !hc.jwksHasFetched {
return false
}
return true
}
// IsAlive returns true if the process is running.
// This is always true since if it weren't, we wouldn't be running this code.
func (hc *HealthChecker) IsAlive() bool {
return true
}
// LivenessHandler returns a handler for the /healthz endpoint.
// It returns 200 whenever the process is alive.
// It performs no network I/O.
func LivenessHandler(hc *HealthChecker) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !hc.IsAlive() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"alive"}`))
}
}
// ReadinessHandler returns a handler for the /readyz endpoint.
// It returns 200 only when configuration is valid and, if auth is enabled,
// JWKS has been fetched at least once.
// It returns non-2xx status while configuration is invalid or JWKS has never
// been fetched.
func ReadinessHandler(hc *HealthChecker) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !hc.IsReady() {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte(`{"status":"not_ready"}`))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ready"}`))
}
}
+173
View File
@@ -0,0 +1,173 @@
package server_test
import (
"context"
"fmt"
"io"
"net/http"
"testing"
"time"
"github.com/Riotpiaole/homelab-frontend/internal/server"
)
// TestHealthEndpoints verifies health endpoint behavior.
// - /healthz returns 200 even with unreachable upstreams
// - /readyz returns non-2xx before JWKS fetch and 200 after
// - Neither endpoint requires authentication
func TestHealthEndpoints(t *testing.T) {
tests := []struct {
name string
configValid bool
authEnabled bool
jwksFetched bool
endpoint string
expectedCode int
description string
}{
{
name: "healthz_always_200",
configValid: true,
authEnabled: false,
jwksFetched: false,
endpoint: "/healthz",
expectedCode: http.StatusOK,
description: "liveness probe returns 200 even before JWKS fetch",
},
{
name: "healthz_200_when_config_invalid",
configValid: false,
authEnabled: false,
jwksFetched: false,
endpoint: "/healthz",
expectedCode: http.StatusOK,
description: "liveness probe returns 200 even when config is invalid",
},
{
name: "readyz_200_no_auth",
configValid: true,
authEnabled: false,
jwksFetched: false,
endpoint: "/readyz",
expectedCode: http.StatusOK,
description: "readiness returns 200 when config valid and auth disabled",
},
{
name: "readyz_503_invalid_config",
configValid: false,
authEnabled: false,
jwksFetched: false,
endpoint: "/readyz",
expectedCode: http.StatusServiceUnavailable,
description: "readiness returns 503 when config invalid",
},
{
name: "readyz_503_auth_enabled_no_jwks",
configValid: true,
authEnabled: true,
jwksFetched: false,
endpoint: "/readyz",
expectedCode: http.StatusServiceUnavailable,
description: "readiness returns 503 when auth enabled but JWKS not fetched",
},
{
name: "readyz_200_auth_enabled_with_jwks",
configValid: true,
authEnabled: true,
jwksFetched: true,
endpoint: "/readyz",
expectedCode: http.StatusOK,
description: "readiness returns 200 when auth enabled and JWKS fetched",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create health checker
hc := server.NewHealthChecker(tt.configValid, tt.authEnabled)
if tt.jwksFetched {
hc.MarkJWKSFetched()
}
// Create handler based on endpoint
var handler http.HandlerFunc
switch tt.endpoint {
case "/healthz":
handler = server.LivenessHandler(hc)
case "/readyz":
handler = server.ReadinessHandler(hc)
default:
t.Fatalf("unknown endpoint: %s", tt.endpoint)
}
// Create server wrapper
gatewayServer := server.New("127.0.0.1:0", 5*time.Second, handler)
// Start server in goroutine
go func() {
if err := gatewayServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
t.Logf("server error: %v", err)
}
}()
// Give server time to start
time.Sleep(100 * time.Millisecond)
// Make request
url := fmt.Sprintf("http://%s%s", gatewayServer.Addr(), tt.endpoint)
resp, err := http.Get(url)
if err != nil {
t.Fatalf("failed to make request: %v", err)
}
defer resp.Body.Close()
// Check status code
if resp.StatusCode != tt.expectedCode {
body, _ := io.ReadAll(resp.Body)
t.Errorf("expected status %d, got %d: %s", tt.expectedCode, resp.StatusCode, string(body))
}
// Verify no Authorization header is required
// (we already made the request without one, so this is implicit)
// Cleanup
gatewayServer.Shutdown(context.Background())
})
}
}
// TestHealthEndpointsNoProxy verifies that health endpoints are not proxied.
// This is verified indirectly by the test above - if they were proxied,
// they would return 404 or fail when trying to reach a non-existent upstream.
func TestHealthEndpointsCannotBeShadowed(t *testing.T) {
// Create health checker and handler
hc := server.NewHealthChecker(true, false)
handler := server.LivenessHandler(hc)
// Create server
srv := server.New("127.0.0.1:0", 5*time.Second, handler)
// Start server
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
t.Logf("server error: %v", err)
}
}()
// Give server time to start
time.Sleep(100 * time.Millisecond)
// Request /healthz and verify it's not proxied
resp, err := http.Get(fmt.Sprintf("http://%s/healthz", srv.Addr()))
if err != nil {
t.Fatalf("failed to make request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected status 200, got %d", resp.StatusCode)
}
// Cleanup
srv.Shutdown(context.Background())
}
+36
View File
@@ -0,0 +1,36 @@
package server
import (
"net/http"
)
// Router implements an HTTP handler that routes health endpoints
// and passes other requests to an upstream handler.
type Router struct {
healthChecker *HealthChecker
upstreamHandler http.Handler
}
// NewRouter creates a new router with health endpoints.
// Health endpoints (/healthz and /readyz) are handled locally.
// All other paths are passed to the upstream handler.
func NewRouter(healthChecker *HealthChecker, upstreamHandler http.Handler) *Router {
return &Router{
healthChecker: healthChecker,
upstreamHandler: upstreamHandler,
}
}
// ServeHTTP implements http.Handler.
// It routes /healthz and /readyz to health handlers,
// and passes all other paths to the upstream handler.
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/healthz":
LivenessHandler(r.healthChecker)(w, req)
case "/readyz":
ReadinessHandler(r.healthChecker)(w, req)
default:
r.upstreamHandler.ServeHTTP(w, req)
}
}
+83
View File
@@ -0,0 +1,83 @@
package server
import (
"context"
"net"
"net/http"
"sync"
"time"
)
// Server wraps an HTTP server with graceful shutdown support.
type Server struct {
httpServer *http.Server
shutdownTimeout time.Duration
listener net.Listener
listenerMu sync.RWMutex
healthChecker *HealthChecker
}
// New creates a new Server with the given configuration.
func New(listenAddr string, shutdownTimeout time.Duration, handler http.Handler) *Server {
return &Server{
httpServer: &http.Server{
Addr: listenAddr,
Handler: handler,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
},
shutdownTimeout: shutdownTimeout,
healthChecker: NewHealthChecker(false, false),
}
}
// ListenAndServe starts the HTTP server and blocks until it exits.
// It returns the error from the server (if any), which will be
// http.ErrServerClosed if Shutdown was called.
func (s *Server) ListenAndServe() error {
listener, err := net.Listen("tcp", s.httpServer.Addr)
if err != nil {
return err
}
s.listenerMu.Lock()
s.listener = listener
s.listenerMu.Unlock()
return s.httpServer.Serve(listener)
}
// Shutdown gracefully shuts down the server. It stops accepting new
// connections and waits for in-flight requests to complete, with a
// bounded deadline. If the deadline is exceeded, it returns an error.
func (s *Server) Shutdown(ctx context.Context) error {
// Create a new context with the shutdown timeout
shutdownCtx, cancel := context.WithTimeout(ctx, s.shutdownTimeout)
defer cancel()
return s.httpServer.Shutdown(shutdownCtx)
}
// Addr returns the network address the server is listening on.
func (s *Server) Addr() string {
s.listenerMu.RLock()
defer s.listenerMu.RUnlock()
if s.listener != nil {
return s.listener.Addr().String()
}
return s.httpServer.Addr
}
// HealthChecker returns the server's health checker.
func (s *Server) HealthChecker() *HealthChecker {
return s.healthChecker
}
// SetHealthChecker sets the server's health checker.
func (s *Server) SetHealthChecker(hc *HealthChecker) {
s.healthChecker = hc
}
// SetHandler sets the server's HTTP handler.
func (s *Server) SetHandler(handler http.Handler) {
s.httpServer.Handler = handler
}
+104
View File
@@ -0,0 +1,104 @@
package server_test
import (
"context"
"fmt"
"io"
"net"
"net/http"
"sync"
"testing"
"time"
"github.com/Riotpiaole/homelab-frontend/internal/server"
)
// TestGracefulShutdown verifies that:
// - A request in-flight when shutdown starts receives its full, uncorrupted response body
// - A request arriving after shutdown starts is refused on a new connection
// - The shutdown completes with exit code 0 (no timeout)
func TestGracefulShutdown(t *testing.T) {
// Create a handler that responds slowly
const responseBody = "slow response body content"
const sleepDuration = 500 * time.Millisecond
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulate a slow LLM response
time.Sleep(sleepDuration)
fmt.Fprint(w, responseBody)
})
// Create server with a shutdown timeout longer than the sleep
srv := server.New("127.0.0.1:0", 5*time.Second, handler)
// Start server in a goroutine
var listenErr error
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
listenErr = srv.ListenAndServe()
// http.ErrServerClosed is expected after shutdown
if listenErr != nil && listenErr != http.ErrServerClosed {
t.Logf("unexpected listen error: %v", listenErr)
}
}()
// Give server time to start listening
time.Sleep(100 * time.Millisecond)
// Issue a slow request in a goroutine
var responseBody_got string
var requestErr error
var requestWg sync.WaitGroup
requestWg.Add(1)
go func() {
defer requestWg.Done()
resp, err := http.Get(fmt.Sprintf("http://%s/", srv.Addr()))
if err != nil {
requestErr = err
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
requestErr = err
return
}
responseBody_got = string(body)
}()
// Give the request time to reach the handler
time.Sleep(100 * time.Millisecond)
// Now initiate shutdown while request is in-flight
shutdownErr := srv.Shutdown(context.Background())
// Wait for the in-flight request to complete
requestWg.Wait()
// Verify the in-flight request completed successfully
if requestErr != nil {
t.Fatalf("in-flight request failed: %v", requestErr)
}
if responseBody_got != responseBody {
t.Fatalf("in-flight request got wrong body: %q (expected %q)", responseBody_got, responseBody)
}
// Verify shutdown succeeded (no timeout)
if shutdownErr != nil {
t.Fatalf("shutdown failed: %v", shutdownErr)
}
// Verify in-flight requests were allowed to complete
wg.Wait()
// Now verify that a new request is refused after shutdown
_, err := net.Dial("tcp", srv.Addr())
if err == nil {
// Connection succeeded when it should have failed
t.Fatalf("new connection accepted after shutdown (should have been refused)")
}
// If we get here, the connection was properly refused, which is what we want
}
+117
View File
@@ -0,0 +1,117 @@
// Package testsupport provides the local-development stub that every upstream
// in testdata/config/harness.yaml targets. It exists so tests under
// internal/proxy (future) and here can verify streaming semantics without a
// Kubernetes cluster or real credentials.
//
// The harness binds ONE http.Server on 127.0.0.1:9080 by default — this matches
// the addresses baked into testdata/config/harness.yaml so that fixture is valid
// before any code runs, and no network traffic leaves localhost during verification.
package testsupport
import (
"fmt"
"net"
"net/http"
"os"
"sync"
)
const defaultLocalPort = "9080"
// DefaultConfigPath is the committed fixture that points every upstream at the
// same local port; clients load it via config.LoadRoutesFromFile. Tests can
// override the path if they want a different schema.
const DefaultConfigPath = "testdata/config/harness.yaml"
// Snapshot describes a running stub server.
type Snapshot struct {
BaseURL string
Client *http.Client
}
// BaseAddr returns the host:port form of the bound address, no scheme.
func (s *Snapshot) BaseAddr() string {
return s.BaseURL[len("http://"):]
}
// URL joins the stub base URL with one of the Path* constants.
func (s *Snapshot) URL(path string) string {
return s.BaseURL + path
}
// harness is the singleton that owns the stub server for a single process.
type harness struct {
mu sync.Mutex
srv *http.Server // nil when not running; set exactly once per Close/Start cycle
addr string // bound address, valid only while srv != nil
}
// global is the singleton used by tests. A fresh server is bound lazily on
// first Start() and shared thereafter for the lifetime of the harness process
// (usually a single TestMain run).
var global = &harness{}
// Start binds the stub server if it is not already running and returns a
// Snapshot describing it. It is safe to call from multiple tests; the second
// and later calls return the already-bound server.
func Start() (*Snapshot, error) { return global.Start() }
// Close stops the stub server. Safe to call multiple times.
func Close() { global.Close() }
func (h *harness) Start() (*Snapshot, error) {
h.mu.Lock()
defer h.mu.Unlock()
if h.srv != nil {
return h.snapshotLocked(), nil
}
// Listen separately from Serve. srv.ListenAndServe would block until
// shutdown, so Start could never return; binding first also guarantees the
// port is accepting connections by the time the caller gets the Snapshot.
ln, err := net.Listen("tcp", defaultStubAddr())
if err != nil {
return nil, fmt.Errorf("bind stub server at %s: %w", defaultStubAddr(), err)
}
srv := &http.Server{Handler: newStubHandler()}
h.srv = srv
h.addr = ln.Addr().String()
// Serve always returns a non-nil error; after Close that error is
// ErrServerClosed, which is the expected path and not worth reporting.
go func() { _ = srv.Serve(ln) }()
return h.snapshotLocked(), nil
}
// snapshotLocked builds a Snapshot for the running server. Caller holds h.mu.
func (h *harness) snapshotLocked() *Snapshot {
return &Snapshot{BaseURL: "http://" + h.addr, Client: http.DefaultClient}
}
// Close stops the underlying stub server. Safe to call on any harness instance
// or multiple times — it is idempotent within a process.
func (h *harness) Close() {
h.mu.Lock()
defer h.mu.Unlock()
if h.srv == nil {
return
}
s := h.srv
h.srv = nil
h.addr = ""
_ = s.Close() // best-effort shutdown; test output not dependent on it
}
// defaultStubAddr constructs "127.0.0.1:<port>" honoring HARNESS_STUB_PORT if
// set, falling back to 9080 — which matches every address in the committed YAML
// fixture. Set only when you need parallel test runs within a single process;
// otherwise leave unset.
func defaultStubAddr() string {
port := os.Getenv("HARNESS_STUB_PORT")
if port == "" {
port = defaultLocalPort
}
return "127.0.0.1:" + port
}
+135
View File
@@ -0,0 +1,135 @@
package testsupport
import (
"bufio"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/Riotpiaole/homelab-frontend/internal/config"
)
// startForTest binds the stub on an ephemeral port so the suite does not fight
// with anything already holding 9080, and fails fast if Start blocks — the
// original harness called ListenAndServe inline and never returned.
func startForTest(t *testing.T) *Snapshot {
t.Helper()
t.Setenv("HARNESS_STUB_PORT", "0")
type result struct {
snap *Snapshot
err error
}
done := make(chan result, 1)
go func() {
snap, err := Start()
done <- result{snap, err}
}()
select {
case r := <-done:
if r.err != nil {
t.Fatalf("Start() error: %v", r.err)
}
t.Cleanup(Close)
return r.snap
case <-time.After(5 * time.Second):
t.Fatal("Start() did not return within 5s — it is blocking instead of serving in the background")
return nil
}
}
func TestStartServesFixedJSON(t *testing.T) {
snap := startForTest(t)
resp, err := snap.Client.Get(snap.URL(PathJSON))
if err != nil {
t.Fatalf("GET %s: %v", PathJSON, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
if got := resp.Header.Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want %q", got, "application/json")
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if want := `{"status":"ok"}`; strings.TrimSpace(string(body)) != want {
t.Errorf("body = %q, want %q", strings.TrimSpace(string(body)), want)
}
}
func TestSSEArrivesIncrementally(t *testing.T) {
snap := startForTest(t)
resp, err := snap.Client.Get(snap.URL(PathSSE))
if err != nil {
t.Fatalf("GET %s: %v", PathSSE, err)
}
defer func() { _ = resp.Body.Close() }()
// The first event must be readable before the handler has written the last
// one. If the response were buffered to completion, this read would only
// unblock after every token plus every gap had elapsed.
deadline := time.Now().Add(time.Duration(len(SSETokens)) * SSEGap)
reader := bufio.NewReader(resp.Body)
line, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("read first event: %v", err)
}
if time.Now().After(deadline) {
t.Error("first SSE event arrived only after the whole stream was written; not incremental")
}
if want := "data: " + SSETokens[0]; strings.TrimSpace(line) != want {
t.Errorf("first event = %q, want %q", strings.TrimSpace(line), want)
}
rest, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("read remaining events: %v", err)
}
if !strings.Contains(string(rest), "data: [DONE]") {
t.Error("stream did not end with the [DONE] sentinel")
}
}
func TestStartIsIdempotent(t *testing.T) {
first := startForTest(t)
second, err := Start()
if err != nil {
t.Fatalf("second Start() error: %v", err)
}
if first.BaseURL != second.BaseURL {
t.Errorf("second Start() bound a different address: %q vs %q", second.BaseURL, first.BaseURL)
}
}
func TestHarnessFixtureLoadsAndStaysOnLoopback(t *testing.T) {
// DefaultConfigPath is relative to the repo root; tests run in the package
// directory, so walk back up to it.
routes, err := config.LoadRoutesFromFile("../../" + DefaultConfigPath)
if err != nil {
t.Fatalf("LoadRoutesFromFile(%s): %v", DefaultConfigPath, err)
}
if len(routes) == 0 {
t.Fatal("fixture declared no routes")
}
for name, route := range routes {
if !strings.HasPrefix(route.Upstream.Address, "127.0.0.1:") {
t.Errorf("route %q upstream %q is not on loopback", name, route.Upstream.Address)
}
if route.Upstream.AuthRequired {
t.Errorf("route %q requires auth; the harness must run with no credentials", name)
}
}
}
+123
View File
@@ -0,0 +1,123 @@
package testsupport
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
// Stub response paths. testdata/config/harness.yaml points every upstream at the
// one stub server, so the response shape is selected by path, not by port.
const (
PathJSON = "/stub/json"
PathSSE = "/stub/sse"
PathChunked = "/stub/chunked"
PathSlow = "/stub/slow"
)
// SSEGap is the pause between SSE events. It exists so a test can observe that
// chunks arrive incrementally rather than all at once at the end.
const SSEGap = 20 * time.Millisecond
// SlowDelay is how long PathSlow waits before writing anything.
const SlowDelay = 250 * time.Millisecond
// SSETokens are the tokens PathSSE emits, one event per token, followed by the
// [DONE] sentinel that OpenAI-shaped clients expect.
var SSETokens = []string{"Hello", " ", "world", "!"}
// ChunkedBodies are the pieces PathChunked writes, each flushed separately so
// the response goes out with Transfer-Encoding: chunked.
var ChunkedBodies = []string{"first\n", "second\n", "third\n"}
// newStubHandler builds the mux served by the harness. Every handler writes a
// deterministic body so tests can assert on exact bytes.
func newStubHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc(PathJSON, stubJSON)
mux.HandleFunc(PathSSE, stubSSE)
mux.HandleFunc(PathChunked, stubChunked)
mux.HandleFunc(PathSlow, stubSlow)
return mux
}
// stubJSON serves a fixed JSON body.
func stubJSON(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// Encode failure here means the client hung up mid-write; the connection is
// already gone, so there is nothing to report and no header left to change.
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// stubSSE streams one event per token, flushing after each. It returns early
// when the client disconnects, so a mid-response disconnect test can assert on
// how many tokens the upstream actually managed to emit.
func stubSSE(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
rc := http.NewResponseController(w)
if err := rc.Flush(); err != nil {
return
}
for _, tok := range SSETokens {
select {
case <-r.Context().Done():
return
case <-time.After(SSEGap):
}
if _, err := fmt.Fprintf(w, "data: %s\n\n", tok); err != nil {
return
}
if err := rc.Flush(); err != nil {
return
}
}
if _, err := fmt.Fprint(w, "data: [DONE]\n\n"); err != nil {
return
}
_ = rc.Flush()
}
// stubChunked writes several pieces with a flush between each, producing a
// chunked transfer with no Content-Length.
func stubChunked(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
rc := http.NewResponseController(w)
for _, body := range ChunkedBodies {
select {
case <-r.Context().Done():
return
case <-time.After(SSEGap):
}
if _, err := fmt.Fprint(w, body); err != nil {
return
}
if err := rc.Flush(); err != nil {
return
}
}
}
// stubSlow waits SlowDelay before responding at all, for timeout tests.
func stubSlow(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
return
case <-time.After(SlowDelay):
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, "slow\n")
}