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
This commit is contained in:
Claus Lohmar 2026-08-08 14:53:12 +00:00
parent fd5c09f9e2
commit 16761613bb
8 changed files with 103 additions and 77 deletions

View file

@ -1,12 +1,12 @@
# Firewall Plugin — Verified Architecture # Firewall Plugin — Verified Architecture
## WAF Loop Test (2026-08-08) ## Wallarm Integration (2026-08-08)
- **WAF at 10.1.0.11:8081** forwards traffic correctly to return port - Wallarm active on 10.1.0.11:8081 — full chain working
- **Plugin return listener on 10.1.0.10:8081** proxies to backends - Nginx requires `proxy_set_header Host $http_host` to preserve Host for backend routing
- Verified with test-web.py: WAF → return port → Hello World response (200) - Plugin redirect preservation fixed — login Set-Cookie and 302 now pass through
- Full chain: Zoraxy → Plugin → WAF → Return Listener → Backend works - Health check: any HTTP response = reachable (connection error only = failure)
- UI: simplified to 3-state indicator (DISABLED / ENABLED / FAILED)
## Known ## Known
- 502 from WAF when plugin return listener proxies real backend (52KB response) - Forgejo/Gitea login: requires redirect preservation + nginx Host header fix
- Short test-web.py response (small) works fine - X-Forwarded-For chain preserved through Zoraxy → Plugin → Wallarm chain
- Wallarm configuration may need buffer/timeout adjustment

View file

@ -28,9 +28,9 @@ Open the Firewall panel at `/plugin.ui/zoraxy-firewall/`:
|---------|---------|-------------| |---------|---------|-------------|
| **Enable** | Off | Toggle WAF inspection on/off | | **Enable** | Off | Toggle WAF inspection on/off |
| **WAF URL** | `http://127.0.0.1:8080` | The firewall application itself (e.g., Wallarm, ModSecurity, Coraza) | | **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 | | **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 ### 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
- **200299** → Traffic is clean, returned to Zoraxy for routing - **200299** → Traffic is clean, returned to Zoraxy for routing
- **403** → Request is malicious, plugin blocks it - **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 - **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 | | `GET` | `/ui/api/config` | Get current configuration |
| `POST` | `/ui/api/config/save` | Save 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` | `/inspect` | Static capture — Zoraxy sends traffic here |
| `POST` | `/verdict` | Return port — WAF sends async callbacks 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) ├── waf_config.json # Runtime configuration (auto-created)
├── config.go # Config load/save (thread-safe) ├── config.go # Config load/save (thread-safe)
├── main.go # Plugin entry, UI router, return listener ├── main.go # Plugin entry, UI router, return listener
├── middleware.go # Inspection handler + metrics ├── middleware.go # Inspection handler, WAF proxy, metrics
├── proxy.go # WAF HTTP client (body sample + request forwarding) ├── hosts.go # Backend resolution from Zoraxy proxy configs
├── circuit_breaker.go # Fail-open state machine ├── circuit_breaker.go # Fail-open state machine
├── health.go # Background health check goroutine ├── health.go # Background health check goroutine
├── web/ # Config panel UI ├── web/ # Config panel UI — WAF status indicator
│ ├── index.html │ ├── index.html
│ ├── style.css │ ├── style.css
│ └── app.js │ └── app.js
├── cmd/mockwallarm/ # Test mock WAF server
└── zoraxy_plugin/ # Vendored Zoraxy plugin SDK (LGPL) └── zoraxy_plugin/ # Vendored Zoraxy plugin SDK (LGPL)
``` ```

View file

@ -24,15 +24,8 @@ func startHealthCheck() {
breaker.RecordFailure() breaker.RecordFailure()
} else { } else {
resp.Body.Close() 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) time.Sleep(interval)
} }

View file

@ -135,7 +135,12 @@ func handleReturn(w http.ResponseWriter, r *http.Request) {
proxyReq.Header.Add(k, v) 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) resp, err := client.Do(proxyReq)
if err != nil { if err != nil {
http.Error(w, "backend unreachable", http.StatusBadGateway) http.Error(w, "backend unreachable", http.StatusBadGateway)

View file

@ -1,6 +1,7 @@
package main package main
import ( import (
"bytes"
"fmt" "fmt"
"io" "io"
"log" "log"
@ -79,11 +80,26 @@ func forwardToWAF(w http.ResponseWriter, r *http.Request) (*http.Response, error
originalPath = r.URL.Path 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 { if err != nil {
http.Error(w, "proxy error", http.StatusInternalServerError) http.Error(w, "proxy error", http.StatusInternalServerError)
return nil, err return nil, err
} }
if bodyLen > 0 {
proxyReq.ContentLength = bodyLen
}
proxyReq.Host = r.Host proxyReq.Host = r.Host
for key, vals := range r.Header { for key, vals := range r.Header {
for _, v := range vals { 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) resp, err := client.Do(proxyReq)
if err != nil { if err != nil {
http.Error(w, "WAF unreachable", http.StatusBadGateway) http.Error(w, "WAF unreachable", http.StatusBadGateway)
@ -127,7 +148,12 @@ func forwardToReturn(w http.ResponseWriter, r *http.Request) {
proxyReq.Header.Add(key, v) 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) resp, err := client.Do(proxyReq)
if err != nil { if err != nil {
http.Error(w, "return listener unreachable", http.StatusBadGateway) http.Error(w, "return listener unreachable", http.StatusBadGateway)

View file

@ -11,16 +11,20 @@ function loadConfig() {
}); });
} }
function loadStats() { function loadStatus() {
$.get('./api/stats', function (data) { $.get('./api/stats', function (data) {
$('#circuit-state').text(data.circuit).attr('class', 'stat-value ' + var state, text, cls;
(data.circuit === 'open' ? 'warn' : data.circuit === 'closed' ? 'ok' : 'warn')); if (!data.enabled) {
$('#fw-status').text(data.enabled ? 'Active' : 'Disabled').attr('class', 'stat-value ' + state = 'DISABLED'; text = 'Disabled'; cls = 'off';
(data.enabled ? 'ok' : 'off')); } else if (data.circuit === 'open') {
$('#stat-total').text(data.total || 0); state = 'FAILED'; text = 'Failed — WAF unreachable'; cls = 'warn';
$('#stat-allowed').text(data.allowed || 0); } else {
$('#stat-blocked').text(data.blocked || 0); state = 'ENABLED'; text = 'Enabled — WAF protecting'; cls = 'ok';
$('#stat-bypassed').text(data.bypassed || 0); }
$('#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 () { success: function () {
$('#save-status').text('Saved ✓'); $('#save-status').text('Saved ✓');
loadConfig(); loadConfig();
loadStats(); loadStatus();
setTimeout(function () { $('#save-status').text(''); }, 2000); setTimeout(function () { $('#save-status').text(''); }, 2000);
}, },
error: function () { error: function () {
@ -56,6 +60,6 @@ $('#save-btn').on('click', saveConfig);
$(document).ready(function () { $(document).ready(function () {
loadConfig(); loadConfig();
loadStats(); loadStatus();
setInterval(loadStats, 5000); setInterval(loadStatus, 10000);
}); });

View file

@ -51,31 +51,11 @@
<span id="save-status"></span> <span id="save-status"></span>
</section> </section>
<section class="stats-panel"> <section class="status-panel">
<h2>Status</h2> <h2>WAF Status</h2>
<div class="stat-row"> <div class="status-indicator" id="waf-indicator">
<span class="stat-label">Circuit Breaker:</span> <span class="status-dot"></span>
<span id="circuit-state" class="stat-value">--</span> <span class="status-text">--</span>
</div>
<div class="stat-row">
<span class="stat-label">Status:</span>
<span id="fw-status" class="stat-value">--</span>
</div>
<div class="stat-row">
<span class="stat-label">Total Inspected:</span>
<span id="stat-total" class="stat-value">0</span>
</div>
<div class="stat-row">
<span class="stat-label">Allowed:</span>
<span id="stat-allowed" class="stat-value ok">0</span>
</div>
<div class="stat-row">
<span class="stat-label">Blocked:</span>
<span id="stat-blocked" class="stat-value warn">0</span>
</div>
<div class="stat-row">
<span class="stat-label">Bypassed (fail-open):</span>
<span id="stat-bypassed" class="stat-value">0</span>
</div> </div>
</section> </section>
</main> </main>

View file

@ -121,15 +121,33 @@ section {
color: #00b894; color: #00b894;
} }
.stat-row { /* WAF Status Indicator */
display: flex; .status-panel h2 {
justify-content: space-between; margin-top: 0;
padding: 6px 0;
border-bottom: 1px solid #f0f0f5;
} }
.stat-row:last-child { border-bottom: none; }
.stat-label { color: #636e72; } .status-indicator {
.stat-value { font-weight: 600; } display: flex;
.stat-value.ok { color: #00b894; } align-items: center;
.stat-value.warn { color: #e17055; } gap: 10px;
.stat-value.off { color: #b2bec3; } 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; }