From fdab765cbde62e5c5f48a694b7d08c42cf761379 Mon Sep 17 00:00:00 2001 From: CI Date: Sat, 29 Nov 2025 15:52:27 +0000 Subject: [PATCH] chore: update internal files for consistency and maintainability --- .../api/handlers/proxy_host_handler.go | 33 +++ backend/internal/caddy/config.go | 83 +++++++ backend/internal/caddy/config_extra_test.go | 60 +++++ backend/internal/caddy/normalize_test.go | 225 ++++++++++++++++++ .../internal/services/proxyhost_service.go | 31 +++ 5 files changed, 432 insertions(+) create mode 100644 backend/internal/caddy/normalize_test.go diff --git a/backend/internal/api/handlers/proxy_host_handler.go b/backend/internal/api/handlers/proxy_host_handler.go index 852243e0..1469fabb 100644 --- a/backend/internal/api/handlers/proxy_host_handler.go +++ b/backend/internal/api/handlers/proxy_host_handler.go @@ -6,6 +6,7 @@ import ( "log" "net/http" "strconv" + "encoding/json" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -62,6 +63,22 @@ func (h *ProxyHostHandler) Create(c *gin.Context) { return } + // Validate and normalize advanced config if present + if host.AdvancedConfig != "" { + var parsed interface{} + if err := json.Unmarshal([]byte(host.AdvancedConfig), &parsed); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config JSON: " + err.Error()}) + return + } + parsed = caddy.NormalizeAdvancedConfig(parsed) + if norm, err := json.Marshal(parsed); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config after normalization: " + err.Error()}) + return + } else { + host.AdvancedConfig = string(norm) + } + } + host.UUID = uuid.NewString() // Assign UUIDs to locations @@ -132,6 +149,22 @@ func (h *ProxyHostHandler) Update(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + // Validate and normalize advanced config if present and changed + if incoming.AdvancedConfig != "" && incoming.AdvancedConfig != host.AdvancedConfig { + var parsed interface{} + if err := json.Unmarshal([]byte(incoming.AdvancedConfig), &parsed); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config JSON: " + err.Error()}) + return + } + parsed = caddy.NormalizeAdvancedConfig(parsed) + if norm, err := json.Marshal(parsed); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config after normalization: " + err.Error()}) + return + } else { + incoming.AdvancedConfig = string(norm) + } + } + // Backup advanced config if changed if incoming.AdvancedConfig != host.AdvancedConfig { incoming.AdvancedConfigBackup = host.AdvancedConfig diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go index 774510bc..11174ecc 100644 --- a/backend/internal/caddy/config.go +++ b/backend/internal/caddy/config.go @@ -255,6 +255,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin // Append as a handler // Ensure it has a "handler" key if _, ok := v["handler"]; ok { + normalizeHandlerHeaders(v) handlers = append(handlers, Handler(v)) } else { fmt.Printf("Warning: advanced_config for host %s is not a handler object\n", host.UUID) @@ -262,6 +263,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin case []interface{}: for _, it := range v { if m, ok := it.(map[string]interface{}); ok { + normalizeHandlerHeaders(m) if _, ok2 := m["handler"]; ok2 { handlers = append(handlers, Handler(m)) } @@ -313,6 +315,87 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin return config, nil } +// normalizeHandlerHeaders ensures header values in handlers are arrays of strings +// Caddy's JSON schema expects header values to be an array of strings (e.g. ["websocket"]) rather than a single string. +func normalizeHandlerHeaders(h map[string]interface{}) { + // normalize top-level headers key + if headersRaw, ok := h["headers"].(map[string]interface{}); ok { + normalizeHeaderOps(headersRaw) + } + // also normalize in nested request/response if present explicitly + for _, side := range []string{"request", "response"} { + if sideRaw, ok := h[side].(map[string]interface{}); ok { + normalizeHeaderOps(sideRaw) + } + } +} + +func normalizeHeaderOps(headerOps map[string]interface{}) { + if setRaw, ok := headerOps["set"].(map[string]interface{}); ok { + for k, v := range setRaw { + switch vv := v.(type) { + case string: + setRaw[k] = []string{vv} + case []interface{}: + // convert to []string + arr := make([]string, 0, len(vv)) + for _, it := range vv { + arr = append(arr, fmt.Sprintf("%v", it)) + } + setRaw[k] = arr + case []string: + // nothing to do + default: + // coerce anything else to string slice + setRaw[k] = []string{fmt.Sprintf("%v", vv)} + } + } + headerOps["set"] = setRaw + } +} + +// NormalizeAdvancedConfig traverses a parsed JSON advanced config (map or array) +// and normalizes any headers blocks so that header values are arrays of strings. +// It returns the modified config object which can be JSON marshaled again. +func NormalizeAdvancedConfig(parsed interface{}) interface{} { + switch v := parsed.(type) { + case map[string]interface{}: + // This might be a handler object + normalizeHandlerHeaders(v) + // Also inspect nested 'handle' or 'routes' arrays for nested handlers + if handles, ok := v["handle"].([]interface{}); ok { + for _, it := range handles { + if m, ok := it.(map[string]interface{}); ok { + NormalizeAdvancedConfig(m) + } + } + } + if routes, ok := v["routes"].([]interface{}); ok { + for _, rit := range routes { + if rm, ok := rit.(map[string]interface{}); ok { + if handles, ok := rm["handle"].([]interface{}); ok { + for _, it := range handles { + if m, ok := it.(map[string]interface{}); ok { + NormalizeAdvancedConfig(m) + } + } + } + } + } + } + return v + case []interface{}: + for _, it := range v { + if m, ok := it.(map[string]interface{}); ok { + NormalizeAdvancedConfig(m) + } + } + return v + default: + return parsed + } +} + // buildACLHandler creates access control handlers based on the AccessList configuration func buildACLHandler(acl *models.AccessList) (Handler, error) { // For geo-blocking, we use CEL (Common Expression Language) matcher with caddy-geoip2 placeholders diff --git a/backend/internal/caddy/config_extra_test.go b/backend/internal/caddy/config_extra_test.go index 2e35f43a..2cce5de3 100644 --- a/backend/internal/caddy/config_extra_test.go +++ b/backend/internal/caddy/config_extra_test.go @@ -2,6 +2,7 @@ package caddy import ( "encoding/json" + "fmt" "testing" "github.com/Wikid82/charon/backend/internal/models" @@ -99,6 +100,65 @@ func TestGenerateConfig_AdvancedObjectHandler(t *testing.T) { require.Equal(t, "headers", first["handler"]) } +func TestGenerateConfig_AdvancedHeadersStringToArray(t *testing.T) { + host := models.ProxyHost{ + UUID: "advheaders", + DomainNames: "hdr.example.com", + ForwardHost: "app", + ForwardPort: 8080, + Enabled: true, + AdvancedConfig: `{"handler":"headers","request":{"set":{"Upgrade":"websocket"}},"response":{"set":{"X-Obj":"1"}}}`, + } + cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false) + require.NoError(t, err) + route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0] + first := route.Handle[0] + require.Equal(t, "headers", first["handler"]) + + // request.set.Upgrade should be an array + if req, ok := first["request"].(map[string]interface{}); ok { + if set, ok := req["set"].(map[string]interface{}); ok { + if val, ok := set["Upgrade"].([]string); ok { + require.Equal(t, []string{"websocket"}, val) + } else if arr, ok := set["Upgrade"].([]interface{}); ok { + // Convert to string arr for assertion + var out []string + for _, v := range arr { + out = append(out, fmt.Sprintf("%v", v)) + } + require.Equal(t, []string{"websocket"}, out) + } else { + t.Fatalf("Upgrade header not normalized to array: %#v", set["Upgrade"]) + } + } else { + t.Fatalf("request.set not found in handler: %#v", first["request"]) + } + } else { + t.Fatalf("request not found in handler: %#v", first) + } + + // response.set.X-Obj should be an array + if resp, ok := first["response"].(map[string]interface{}); ok { + if set, ok := resp["set"].(map[string]interface{}); ok { + if val, ok := set["X-Obj"].([]string); ok { + require.Equal(t, []string{"1"}, val) + } else if arr, ok := set["X-Obj"].([]interface{}); ok { + var out []string + for _, v := range arr { + out = append(out, fmt.Sprintf("%v", v)) + } + require.Equal(t, []string{"1"}, out) + } else { + t.Fatalf("X-Obj header not normalized to array: %#v", set["X-Obj"]) + } + } else { + t.Fatalf("response.set not found in handler: %#v", first["response"]) + } + } else { + t.Fatalf("response not found in handler: %#v", first) + } +} + func TestGenerateConfig_ACLWhitelistIncluded(t *testing.T) { // Create a host with a whitelist ACL ipRules := `[{"cidr":"192.168.1.0/24"}]` diff --git a/backend/internal/caddy/normalize_test.go b/backend/internal/caddy/normalize_test.go new file mode 100644 index 00000000..80d5fe31 --- /dev/null +++ b/backend/internal/caddy/normalize_test.go @@ -0,0 +1,225 @@ +package caddy + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeAdvancedConfig_MapWithNestedHandles(t *testing.T) { + // Build a map with nested 'handle' array containing headers with string values + raw := map[string]interface{}{ + "handler": "subroute", + "routes": []interface{}{ + map[string]interface{}{ + "handle": []interface{}{ + map[string]interface{}{ + "handler": "headers", + "request": map[string]interface{}{ + "set": map[string]interface{}{"Upgrade": "websocket"}, + }, + "response": map[string]interface{}{ + "set": map[string]interface{}{"X-Obj": "1"}, + }, + }, + }, + }, + }, + } + + out := NormalizeAdvancedConfig(raw) + // Verify nested header values normalized + outMap, ok := out.(map[string]interface{}) + require.True(t, ok) + routes := outMap["routes"].([]interface{}) + require.Len(t, routes, 1) + r := routes[0].(map[string]interface{}) + handles := r["handle"].([]interface{}) + require.Len(t, handles, 1) + hdr := handles[0].(map[string]interface{}) + + // request.set.Upgrade + req := hdr["request"].(map[string]interface{}) + set := req["set"].(map[string]interface{}) + // Could be []interface{} or []string depending on code path; normalize to []string representation + switch v := set["Upgrade"].(type) { + case []interface{}: + var outArr []string + for _, it := range v { + outArr = append(outArr, fmt.Sprintf("%v", it)) + } + require.Equal(t, []string{"websocket"}, outArr) + case []string: + require.Equal(t, []string{"websocket"}, v) + default: + t.Fatalf("unexpected type for Upgrade: %T", v) + } + + // response.set.X-Obj + resp := hdr["response"].(map[string]interface{}) + rset := resp["set"].(map[string]interface{}) + switch v := rset["X-Obj"].(type) { + case []interface{}: + var outArr []string + for _, it := range v { + outArr = append(outArr, fmt.Sprintf("%v", it)) + } + require.Equal(t, []string{"1"}, outArr) + case []string: + require.Equal(t, []string{"1"}, v) + default: + t.Fatalf("unexpected type for X-Obj: %T", v) + } +} + +func TestNormalizeAdvancedConfig_ArrayTopLevel(t *testing.T) { + // Top-level array containing a headers handler with array value as []interface{} + raw := []interface{}{ + map[string]interface{}{ + "handler": "headers", + "response": map[string]interface{}{ + "set": map[string]interface{}{"X-Obj": []interface{}{"1"}}, + }, + }, + } + out := NormalizeAdvancedConfig(raw) + outArr := out.([]interface{}) + require.Len(t, outArr, 1) + hdr := outArr[0].(map[string]interface{}) + resp := hdr["response"].(map[string]interface{}) + set := resp["set"].(map[string]interface{}) + switch v := set["X-Obj"].(type) { + case []interface{}: + var outArr2 []string + for _, it := range v { + outArr2 = append(outArr2, fmt.Sprintf("%v", it)) + } + require.Equal(t, []string{"1"}, outArr2) + case []string: + require.Equal(t, []string{"1"}, v) + default: + t.Fatalf("unexpected type for X-Obj: %T", v) + } +} + +func TestNormalizeAdvancedConfig_DefaultPrimitives(t *testing.T) { + // Ensure primitive values remain unchanged + v := NormalizeAdvancedConfig(42) + require.Equal(t, 42, v) + v2 := NormalizeAdvancedConfig("hello") + require.Equal(t, "hello", v2) +} + +func TestNormalizeAdvancedConfig_CoerceNonStandardTypes(t *testing.T) { + // Use a header value that is numeric and ensure it's coerced to string + raw := map[string]interface{}{"handler": "headers", "response": map[string]interface{}{"set": map[string]interface{}{"X-Num": 1}}} + out := NormalizeAdvancedConfig(raw).(map[string]interface{}) + resp := out["response"].(map[string]interface{}) + set := resp["set"].(map[string]interface{}) + // Should be a []string with "1" + switch v := set["X-Num"].(type) { + case []interface{}: + var outArr []string + for _, it := range v { + outArr = append(outArr, fmt.Sprintf("%v", it)) + } + require.Equal(t, []string{"1"}, outArr) + case []string: + require.Equal(t, []string{"1"}, v) + default: + t.Fatalf("unexpected type for X-Num: %T", v) + } +} + +func TestNormalizeAdvancedConfig_JSONRoundtrip(t *testing.T) { + // Ensure normalized config can be marshaled back to JSON and unmarshaled + raw := map[string]interface{}{"handler": "headers", "request": map[string]interface{}{"set": map[string]interface{}{"Upgrade": "websocket"}}} + out := NormalizeAdvancedConfig(raw) + b, err := json.Marshal(out) + require.NoError(t, err) + // Marshal back and read result + var parsed interface{} + require.NoError(t, json.Unmarshal(b, &parsed)) +} + +func TestNormalizeAdvancedConfig_TopLevelHeaders(t *testing.T) { + // Top-level 'headers' key should be normalized similar to request/response + raw := map[string]interface{}{ + "handler": "headers", + "headers": map[string]interface{}{ + "set": map[string]interface{}{"Upgrade": "websocket"}, + }, + } + out := NormalizeAdvancedConfig(raw).(map[string]interface{}) + hdrs := out["headers"].(map[string]interface{}) + set := hdrs["set"].(map[string]interface{}) + switch v := set["Upgrade"].(type) { + case []interface{}: + var outArr []string + for _, it := range v { + outArr = append(outArr, fmt.Sprintf("%v", it)) + } + require.Equal(t, []string{"websocket"}, outArr) + case []string: + require.Equal(t, []string{"websocket"}, v) + default: + t.Fatalf("unexpected type for Upgrade: %T", v) + } +} + +func TestNormalizeAdvancedConfig_HeadersAlreadyArray(t *testing.T) { + // If the header value is already a []string it should be left as-is + raw := map[string]interface{}{ + "handler": "headers", + "headers": map[string]interface{}{ + "set": map[string]interface{}{"X-Test": []string{"a", "b"}}, + }, + } + out := NormalizeAdvancedConfig(raw).(map[string]interface{}) + hdrs := out["headers"].(map[string]interface{}) + set := hdrs["set"].(map[string]interface{}) + switch v := set["X-Test"].(type) { + case []interface{}: + var outArr []string + for _, it := range v { + outArr = append(outArr, fmt.Sprintf("%v", it)) + } + require.Equal(t, []string{"a", "b"}, outArr) + case []string: + require.Equal(t, []string{"a", "b"}, v) + default: + t.Fatalf("unexpected type for X-Test: %T", v) + } +} + +func TestNormalizeAdvancedConfig_MapWithTopLevelHandle(t *testing.T) { + raw := map[string]interface{}{ + "handler": "subroute", + "handle": []interface{}{ + map[string]interface{}{ + "handler": "headers", + "request": map[string]interface{}{"set": map[string]interface{}{"Upgrade": "websocket"}}, + }, + }, + } + out := NormalizeAdvancedConfig(raw).(map[string]interface{}) + handles := out["handle"].([]interface{}) + require.Len(t, handles, 1) + hdr := handles[0].(map[string]interface{}) + req := hdr["request"].(map[string]interface{}) + set := req["set"].(map[string]interface{}) + switch v := set["Upgrade"].(type) { + case []interface{}: + var outArr []string + for _, it := range v { + outArr = append(outArr, fmt.Sprintf("%v", it)) + } + require.Equal(t, []string{"websocket"}, outArr) + case []string: + require.Equal(t, []string{"websocket"}, v) + default: + t.Fatalf("unexpected type for Upgrade: %T", v) + } +} diff --git a/backend/internal/services/proxyhost_service.go b/backend/internal/services/proxyhost_service.go index d707297c..9fe32e46 100644 --- a/backend/internal/services/proxyhost_service.go +++ b/backend/internal/services/proxyhost_service.go @@ -6,6 +6,9 @@ import ( "net" "strconv" "time" + "encoding/json" + + "github.com/Wikid82/charon/backend/internal/caddy" "gorm.io/gorm" @@ -48,6 +51,20 @@ func (s *ProxyHostService) Create(host *models.ProxyHost) error { return err } + // Normalize and validate advanced config (if present) + if host.AdvancedConfig != "" { + var parsed interface{} + if err := json.Unmarshal([]byte(host.AdvancedConfig), &parsed); err != nil { + return fmt.Errorf("invalid advanced_config JSON: %w", err) + } + parsed = caddy.NormalizeAdvancedConfig(parsed) + if norm, err := json.Marshal(parsed); err != nil { + return fmt.Errorf("invalid advanced_config after normalization: %w", err) + } else { + host.AdvancedConfig = string(norm) + } + } + return s.db.Create(host).Error } @@ -57,6 +74,20 @@ func (s *ProxyHostService) Update(host *models.ProxyHost) error { return err } + // Normalize and validate advanced config (if present) + if host.AdvancedConfig != "" { + var parsed interface{} + if err := json.Unmarshal([]byte(host.AdvancedConfig), &parsed); err != nil { + return fmt.Errorf("invalid advanced_config JSON: %w", err) + } + parsed = caddy.NormalizeAdvancedConfig(parsed) + if norm, err := json.Marshal(parsed); err != nil { + return fmt.Errorf("invalid advanced_config after normalization: %w", err) + } else { + host.AdvancedConfig = string(norm) + } + } + return s.db.Save(host).Error }