From 16761613bb93fd4309603d4cf0d1206bd98febaf Mon Sep 17 00:00:00 2001 From: cclohmar Date: Sat, 8 Aug 2026 14:53:12 +0000 Subject: [PATCH] fix: redirect preservation, health check, Content-Length, UI simplification - health.go: treat any HTTP response as reachable (only connection errors fail) - main.go: CheckRedirect in handleReturn to preserve 302 + Set-Cookie - middleware.go: Content-Length for POST/PUT/PATCH bodies, CheckRedirect in forwardToWAF and forwardToReturn - web/: replace stats panel with 3-state WAF indicator (DISABLED/ENABLED/FAILED) - README: update defaults, architecture, health check docs - .agents.md: update verified status for Wallarm integration --- .agents.md | 16 ++++++++-------- README.md | 18 +++++++++--------- health.go | 9 +-------- main.go | 7 ++++++- middleware.go | 32 +++++++++++++++++++++++++++++--- web/app.js | 28 ++++++++++++++++------------ web/index.html | 30 +++++------------------------- web/style.css | 40 +++++++++++++++++++++++++++++----------- 8 files changed, 103 insertions(+), 77 deletions(-) diff --git a/.agents.md b/.agents.md index 014dfb7..ad1aba8 100644 --- a/.agents.md +++ b/.agents.md @@ -1,12 +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 +## Wallarm Integration (2026-08-08) +- Wallarm active on 10.1.0.11:8081 — full chain working +- Nginx requires `proxy_set_header Host $http_host` to preserve Host for backend routing +- Plugin redirect preservation fixed — login Set-Cookie and 302 now pass through +- Health check: any HTTP response = reachable (connection error only = failure) +- UI: simplified to 3-state indicator (DISABLED / ENABLED / FAILED) ## 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 +- Forgejo/Gitea login: requires redirect preservation + nginx Host header fix +- X-Forwarded-For chain preserved through Zoraxy → Plugin → Wallarm chain diff --git a/README.md b/README.md index eeeb377..4d8c586 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,9 @@ Open the Firewall panel at `/plugin.ui/zoraxy-firewall/`: |---------|---------|-------------| | **Enable** | Off | Toggle WAF inspection on/off | | **WAF URL** | `http://127.0.0.1:8080` | The firewall application itself (e.g., Wallarm, ModSecurity, Coraza) | -| **Return Port** | `8080` | Port where inspected traffic returns to Zoraxy for routing | +| **Return Port** | `8081` | Port where inspected traffic returns to Zoraxy for routing | | **Health Interval** | `5s` | How often to check if the WAF is reachable | -| **Timeout** | `500ms` | Max wait time for an inspection verdict | +| **Timeout** | `10000ms` | Max wait time for an inspection verdict | --- @@ -58,7 +58,7 @@ If the WAF fails 5 times within a 30-second window, the breaker opens. All traff ### Health Check -The plugin periodically sends a `HEAD` request to the WAF URL. If the firewall application responds, the circuit breaker records success. If unreachable, it records a failure. +The plugin periodically sends a `HEAD` request to the WAF URL. Any HTTP response (regardless of status code) means the WAF is reachable — the circuit breaker records success. Only connection errors (timeout, connection refused) trigger failures. --- @@ -68,9 +68,10 @@ The plugin proxies the full HTTP request to the WAF URL. The firewall applicatio - **200–299** → Traffic is clean, returned to Zoraxy for routing - **403** → Request is malicious, plugin blocks it +- **302** → Redirects are preserved (e.g. login session cookies) — the plugin does not follow redirects - **5xx / timeout** → Plugin fails open, breaker records failure -Any firewall application that accepts proxied HTTP traffic and returns a status code is compatible. +All HTTP response headers and status codes are passed through transparently. Redirects and `Set-Cookie` headers are preserved end-to-end. --- @@ -92,7 +93,7 @@ The plugin uses Zoraxy's **static capture** to intercept all requests matched by |--------|------|---------| | `GET` | `/ui/api/config` | Get current configuration | | `POST` | `/ui/api/config/save` | Save configuration | -| `GET` | `/ui/api/stats` | Inspect/bock/bypass metrics + breaker state | +| `GET` | `/ui/api/stats` | Metrics (total/allowed/blocked/bypassed) + circuit state + enabled flag | | `POST` | `/inspect` | Static capture — Zoraxy sends traffic here | | `POST` | `/verdict` | Return port — WAF sends async callbacks here | @@ -106,15 +107,14 @@ The plugin uses Zoraxy's **static capture** to intercept all requests matched by ├── waf_config.json # Runtime configuration (auto-created) ├── config.go # Config load/save (thread-safe) ├── main.go # Plugin entry, UI router, return listener -├── middleware.go # Inspection handler + metrics -├── proxy.go # WAF HTTP client (body sample + request forwarding) +├── middleware.go # Inspection handler, WAF proxy, metrics +├── hosts.go # Backend resolution from Zoraxy proxy configs ├── circuit_breaker.go # Fail-open state machine ├── health.go # Background health check goroutine -├── web/ # Config panel UI +├── web/ # Config panel UI — WAF status indicator │ ├── index.html │ ├── style.css │ └── app.js -├── cmd/mockwallarm/ # Test mock WAF server └── zoraxy_plugin/ # Vendored Zoraxy plugin SDK (LGPL) ``` diff --git a/health.go b/health.go index a7621d3..190e7c4 100644 --- a/health.go +++ b/health.go @@ -24,14 +24,7 @@ func startHealthCheck() { breaker.RecordFailure() } else { resp.Body.Close() - // Any response (2xx, 3xx, 4xx) means WAF is reachable. - // Only 5xx or connection errors indicate the WAF is unhealthy. - if resp.StatusCode >= 500 { - log.Printf("WAF health check: status %d", resp.StatusCode) - breaker.RecordFailure() - } else { - breaker.RecordSuccess() - } + breaker.RecordSuccess() } time.Sleep(interval) diff --git a/main.go b/main.go index 15e5944..8b0a5c0 100644 --- a/main.go +++ b/main.go @@ -135,7 +135,12 @@ func handleReturn(w http.ResponseWriter, r *http.Request) { proxyReq.Header.Add(k, v) } } - client := &http.Client{Timeout: 30 * time.Second} + client := &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } resp, err := client.Do(proxyReq) if err != nil { http.Error(w, "backend unreachable", http.StatusBadGateway) diff --git a/middleware.go b/middleware.go index 4a21940..891b5ce 100644 --- a/middleware.go +++ b/middleware.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "fmt" "io" "log" @@ -79,11 +80,26 @@ func forwardToWAF(w http.ResponseWriter, r *http.Request) (*http.Response, error originalPath = r.URL.Path } - proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, cfg.WAFURL+originalPath, r.Body) + var bodyReader io.Reader + var bodyLen int64 + if r.Body != nil && (r.Method == "POST" || r.Method == "PUT" || r.Method == "PATCH") { + bodyBytes, readErr := io.ReadAll(r.Body) + if readErr != nil { + http.Error(w, "body read error", http.StatusInternalServerError) + return nil, readErr + } + bodyReader = bytes.NewReader(bodyBytes) + bodyLen = int64(len(bodyBytes)) + } + + proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, cfg.WAFURL+originalPath, bodyReader) if err != nil { http.Error(w, "proxy error", http.StatusInternalServerError) return nil, err } + if bodyLen > 0 { + proxyReq.ContentLength = bodyLen + } proxyReq.Host = r.Host for key, vals := range r.Header { for _, v := range vals { @@ -91,7 +107,12 @@ func forwardToWAF(w http.ResponseWriter, r *http.Request) (*http.Response, error } } - client := &http.Client{Timeout: time.Duration(cfg.TimeoutMs) * time.Millisecond} + client := &http.Client{ + Timeout: time.Duration(cfg.TimeoutMs) * time.Millisecond, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } resp, err := client.Do(proxyReq) if err != nil { http.Error(w, "WAF unreachable", http.StatusBadGateway) @@ -127,7 +148,12 @@ func forwardToReturn(w http.ResponseWriter, r *http.Request) { proxyReq.Header.Add(key, v) } } - client := &http.Client{Timeout: time.Duration(cfg.TimeoutMs) * time.Millisecond} + client := &http.Client{ + Timeout: time.Duration(cfg.TimeoutMs) * time.Millisecond, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } resp, err := client.Do(proxyReq) if err != nil { http.Error(w, "return listener unreachable", http.StatusBadGateway) diff --git a/web/app.js b/web/app.js index c30acb1..eee1a44 100644 --- a/web/app.js +++ b/web/app.js @@ -11,16 +11,20 @@ function loadConfig() { }); } -function loadStats() { +function loadStatus() { $.get('./api/stats', function (data) { - $('#circuit-state').text(data.circuit).attr('class', 'stat-value ' + - (data.circuit === 'open' ? 'warn' : data.circuit === 'closed' ? 'ok' : 'warn')); - $('#fw-status').text(data.enabled ? 'Active' : 'Disabled').attr('class', 'stat-value ' + - (data.enabled ? 'ok' : 'off')); - $('#stat-total').text(data.total || 0); - $('#stat-allowed').text(data.allowed || 0); - $('#stat-blocked').text(data.blocked || 0); - $('#stat-bypassed').text(data.bypassed || 0); + var state, text, cls; + if (!data.enabled) { + state = 'DISABLED'; text = 'Disabled'; cls = 'off'; + } else if (data.circuit === 'open') { + state = 'FAILED'; text = 'Failed — WAF unreachable'; cls = 'warn'; + } else { + state = 'ENABLED'; text = 'Enabled — WAF protecting'; cls = 'ok'; + } + $('#waf-indicator') + .attr('data-state', state) + .find('.status-text').text(text).end() + .find('.status-dot').attr('class', 'status-dot ' + cls); }); } @@ -39,7 +43,7 @@ function saveConfig() { success: function () { $('#save-status').text('Saved ✓'); loadConfig(); - loadStats(); + loadStatus(); setTimeout(function () { $('#save-status').text(''); }, 2000); }, error: function () { @@ -56,6 +60,6 @@ $('#save-btn').on('click', saveConfig); $(document).ready(function () { loadConfig(); - loadStats(); - setInterval(loadStats, 5000); + loadStatus(); + setInterval(loadStatus, 10000); }); diff --git a/web/index.html b/web/index.html index f8635e3..e3e2adf 100644 --- a/web/index.html +++ b/web/index.html @@ -51,31 +51,11 @@ -
-

Status

-
- Circuit Breaker: - -- -
-
- Status: - -- -
-
- Total Inspected: - 0 -
-
- Allowed: - 0 -
-
- Blocked: - 0 -
-
- Bypassed (fail-open): - 0 +
+

WAF Status

+
+ + --
diff --git a/web/style.css b/web/style.css index 16577f0..59ae358 100644 --- a/web/style.css +++ b/web/style.css @@ -121,15 +121,33 @@ section { color: #00b894; } -.stat-row { - display: flex; - justify-content: space-between; - padding: 6px 0; - border-bottom: 1px solid #f0f0f5; +/* WAF Status Indicator */ +.status-panel h2 { + margin-top: 0; } -.stat-row:last-child { border-bottom: none; } -.stat-label { color: #636e72; } -.stat-value { font-weight: 600; } -.stat-value.ok { color: #00b894; } -.stat-value.warn { color: #e17055; } -.stat-value.off { color: #b2bec3; } + +.status-indicator { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 18px; + border-radius: 6px; + font-size: 15px; + font-weight: 600; +} + +.status-dot { + width: 14px; + height: 14px; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; +} + +.status-dot.ok { background: #00b894; box-shadow: 0 0 6px rgba(0,184,148,0.4); } +.status-dot.warn { background: #e17055; box-shadow: 0 0 6px rgba(225,112,85,0.4); } +.status-dot.off { background: #b2bec3; } + +[data-state="ENABLED"] { background: #e6faf3; color: #00b894; } +[data-state="FAILED"] { background: #fde8e2; color: #e17055; } +[data-state="DISABLED"] { background: #f0f0f5; color: #636e72; }