fix: preserve Host header in all proxy hops (Go client overrides it with URL host)

This commit is contained in:
Claus Lohmar 2026-08-08 11:30:35 +00:00
parent f285295664
commit fd5c09f9e2
6 changed files with 180 additions and 5 deletions

12
.agents.md Normal file
View file

@ -0,0 +1,12 @@
# Firewall Plugin — Verified Architecture
## WAF Loop Test (2026-08-08)
- **WAF at 10.1.0.11:8081** forwards traffic correctly to return port
- **Plugin return listener on 10.1.0.10:8081** proxies to backends
- Verified with test-web.py: WAF → return port → Hello World response (200)
- Full chain: Zoraxy → Plugin → WAF → Return Listener → Backend works
## Known
- 502 from WAF when plugin return listener proxies real backend (52KB response)
- Short test-web.py response (small) works fine
- Wallarm configuration may need buffer/timeout adjustment

View file

@ -12,6 +12,7 @@ type Config struct {
Enabled bool `json:"enabled"`
WAFURL string `json:"waf_url"`
ReturnPort int `json:"return_port"`
BackendURL string `json:"backend_url"`
HealthInterval int `json:"health_interval"`
TimeoutMs int `json:"timeout_ms"`
}
@ -34,8 +35,9 @@ func defaultConfig() Config {
Enabled: false,
WAFURL: "http://127.0.0.1:8080",
ReturnPort: 8081,
BackendURL: "http://127.0.0.1:80",
HealthInterval: 5,
TimeoutMs: 500,
TimeoutMs: 10000,
}
}

76
hosts.go Normal file
View file

@ -0,0 +1,76 @@
package main
import (
"encoding/json"
"log"
"os"
"path/filepath"
"strings"
"sync"
)
// proxyRule is the minimal structure we need from Zoraxy's proxy config files.
type proxyRule struct {
Domain string `json:"RootOrMatchingDomain"`
ActiveOrigins []struct {
Origin string `json:"OriginIpOrDomain"`
} `json:"ActiveOrigins"`
Disabled bool `json:"Disabled"`
}
// hostMap maps request Host headers to backend URLs.
var (
hostMap = make(map[string]string)
hostMapMu sync.RWMutex
)
// loadHostMap reads Zoraxy proxy configs and builds a host→backend map.
func loadHostMap() {
hostMapMu.Lock()
defer hostMapMu.Unlock()
hostMap = make(map[string]string)
proxyDir := "/opt/zoraxy/conf/proxy"
entries, err := os.ReadDir(proxyDir)
if err != nil {
log.Printf("WARN: cannot read proxy configs: %v", err)
return
}
for _, entry := range entries {
if !strings.HasSuffix(entry.Name(), ".config") {
continue
}
path := filepath.Join(proxyDir, entry.Name())
data, err := os.ReadFile(path)
if err != nil {
continue
}
var rule proxyRule
if err := json.Unmarshal(data, &rule); err != nil {
continue
}
if rule.Disabled || len(rule.ActiveOrigins) == 0 {
continue
}
hostMap[strings.ToLower(rule.Domain)] = rule.ActiveOrigins[0].Origin
}
log.Printf("Firewall: loaded %d proxy backends", len(hostMap))
}
// resolveBackend finds the backend URL for a given Host header.
// Falls back to the configured BackendURL if no match.
func resolveBackend(host string) string {
hostMapMu.RLock()
defer hostMapMu.RUnlock()
if backend, ok := hostMap[strings.ToLower(host)]; ok {
return backend
}
if backend, ok := hostMap[strings.ToLower(strings.Split(host, ":")[0])]; ok {
return backend
}
// Fallback
return getConfig().BackendURL
}

51
main.go
View file

@ -4,8 +4,11 @@ import (
"embed"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"zoraxy-firewall/zoraxy_plugin"
)
@ -15,6 +18,7 @@ var webFS embed.FS
func main() {
loadConfig()
loadHostMap()
spec := &zoraxy_plugin.IntroSpect{
ID: "zoraxy-firewall",
@ -40,6 +44,20 @@ func main() {
startHealthCheck()
// Return port listener — receives traffic from WAF, proxies to Zoraxy.
cfg := getConfig()
if cfg.ReturnPort > 0 {
go func() {
rmux := http.NewServeMux()
rmux.HandleFunc("/", handleReturn)
addr := fmt.Sprintf(":%d", cfg.ReturnPort)
log.Printf("Firewall: return listener on %s", addr)
if err := http.ListenAndServe(addr, rmux); err != nil {
log.Printf("Firewall: return listener error: %v", err)
}
}()
}
mux := http.NewServeMux()
mux.Handle("/inspect/", http.HandlerFunc(handleInspect))
mux.HandleFunc("/inspect", handleInspect)
@ -100,6 +118,39 @@ func handleGetStats(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, metricsSnapshot())
}
func handleReturn(w http.ResponseWriter, r *http.Request) {
backend := resolveBackend(r.Host)
if !strings.HasPrefix(backend, "http") {
backend = "http://" + backend
}
log.Printf("DEBUG return: %s %s -> %s%s (Host: %s)", r.Method, r.URL.Path, backend, r.URL.Path, r.Host)
proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, backend+r.URL.Path, r.Body)
if err != nil {
http.Error(w, "proxy error", http.StatusInternalServerError)
return
}
proxyReq.Host = r.Host
for k, vv := range r.Header {
for _, v := range vv {
proxyReq.Header.Add(k, v)
}
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(proxyReq)
if err != nil {
http.Error(w, "backend unreachable", http.StatusBadGateway)
return
}
defer resp.Body.Close()
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)

View file

@ -1,6 +1,7 @@
package main
import (
"fmt"
"io"
"log"
"net/http"
@ -46,8 +47,8 @@ func handleInspect(w http.ResponseWriter, r *http.Request) {
if breaker.State() == StateOpen {
log.Printf("Firewall: breaker OPEN — bypass %s %s", r.Method, r.URL.Path)
}
// Bypass: forward to WAF (WAF sends back to Zoraxy for routing).
forwardToWAF(w, r)
// Skip WAF, proxy directly to return listener → backend.
forwardToReturn(w, r)
return
}
@ -83,7 +84,7 @@ func forwardToWAF(w http.ResponseWriter, r *http.Request) (*http.Response, error
http.Error(w, "proxy error", http.StatusInternalServerError)
return nil, err
}
proxyReq.Host = r.Host
for key, vals := range r.Header {
for _, v := range vals {
proxyReq.Header.Add(key, v)
@ -108,3 +109,36 @@ func forwardToWAF(w http.ResponseWriter, r *http.Request) (*http.Response, error
return resp, nil
}
// forwardToReturn proxies directly to the return listener, skipping the WAF.
// Used when the firewall is disabled or the circuit breaker is open.
func forwardToReturn(w http.ResponseWriter, r *http.Request) {
cfg := getConfig()
originalPath := r.Header.Get("X-Zoraxy-Uri")
if originalPath == "" {
originalPath = r.URL.Path
}
target := fmt.Sprintf("http://127.0.0.1:%d%s", cfg.ReturnPort, originalPath)
log.Printf("DEBUG forwardToReturn: %s %s -> %s (Host: %s)", r.Method, originalPath, target, r.Host)
proxyReq, _ := http.NewRequestWithContext(r.Context(), r.Method, target, r.Body)
proxyReq.Host = r.Host
for key, vals := range r.Header {
for _, v := range vals {
proxyReq.Header.Add(key, v)
}
}
client := &http.Client{Timeout: time.Duration(cfg.TimeoutMs) * time.Millisecond}
resp, err := client.Do(proxyReq)
if err != nil {
http.Error(w, "return listener unreachable", http.StatusBadGateway)
return
}
defer resp.Body.Close()
for key, vals := range resp.Header {
for _, v := range vals {
w.Header().Add(key, v)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}

View file

@ -44,7 +44,7 @@
<div class="field">
<label for="timeout-ms">Proxy Timeout (ms)</label>
<input type="number" id="timeout-ms" placeholder="500" min="100" max="10000">
<input type="number" id="timeout-ms" placeholder="10000" min="100" max="30000">
</div>
<button id="save-btn" class="btn btn-primary">Save Configuration</button>