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
|
# 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
|
|
||||||
|
|
|
||||||
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 |
|
| **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
|
||||||
|
|
||||||
- **200–299** → Traffic is clean, returned to Zoraxy for routing
|
- **200–299** → 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)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,14 +24,7 @@ func startHealthCheck() {
|
||||||
breaker.RecordFailure()
|
breaker.RecordFailure()
|
||||||
} else {
|
} else {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
// Any response (2xx, 3xx, 4xx) means WAF is reachable.
|
breaker.RecordSuccess()
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(interval)
|
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)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
28
web/app.js
28
web/app.js
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue