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:
parent
fd5c09f9e2
commit
16761613bb
8 changed files with 103 additions and 77 deletions
16
.agents.md
16
.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
|
||||
|
|
|
|||
18
README.md
18
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)
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
7
main.go
7
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
28
web/app.js
28
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -51,31 +51,11 @@
|
|||
<span id="save-status"></span>
|
||||
</section>
|
||||
|
||||
<section class="stats-panel">
|
||||
<h2>Status</h2>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Circuit Breaker:</span>
|
||||
<span id="circuit-state" class="stat-value">--</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>
|
||||
<section class="status-panel">
|
||||
<h2>WAF Status</h2>
|
||||
<div class="status-indicator" id="waf-indicator">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">--</span>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue