simplify: inspection-only model — plugin inspects via WAF API, Zoraxy handles routing

This commit is contained in:
Claus Lohmar 2026-08-02 06:49:58 +00:00
parent 6b851057b7
commit 32d60a3583
6 changed files with 49 additions and 112 deletions

View file

@ -11,10 +11,9 @@ import (
// Config holds all Firewall plugin settings.
type Config struct {
Enabled bool `json:"enabled"`
WAFURL string `json:"waf_url"` // e.g. http://127.0.0.1:8081
BackendURL string `json:"backend_url"` // e.g. http://10.1.0.20:5000
HealthInterval int `json:"health_interval"` // seconds between health checks
TimeoutMs int `json:"timeout_ms"` // proxy timeout in ms
WAFURL string `json:"waf_url"`
HealthInterval int `json:"health_interval"`
TimeoutMs int `json:"timeout_ms"`
}
func configPath() string {
@ -33,8 +32,7 @@ var (
func defaultConfig() Config {
return Config{
Enabled: false,
WAFURL: "http://127.0.0.1:8081",
BackendURL: "http://127.0.0.1:80",
WAFURL: "http://10.1.0.11:8081",
HealthInterval: 5,
TimeoutMs: 3000,
}

View file

@ -85,9 +85,6 @@ func handleSaveConfig(w http.ResponseWriter, r *http.Request) {
if u := r.FormValue("waf_url"); u != "" {
cfg.WAFURL = u
}
if u := r.FormValue("backend_url"); u != "" {
cfg.BackendURL = u
}
if v := r.FormValue("health_interval"); v != "" {
fmt.Sscanf(v, "%d", &cfg.HealthInterval)
}

View file

@ -1,8 +1,6 @@
package main
import (
"fmt"
"io"
"log"
"net/http"
"sync/atomic"
@ -12,7 +10,6 @@ import (
var breaker = newCircuitBreaker()
// Metrics
var (
requestsTotal uint64
requestsAllowed uint64
@ -43,7 +40,6 @@ func circuitStateStr(s CircuitState) string {
return "unknown"
}
// handleInspect is the static capture handler.
func handleInspect(w http.ResponseWriter, r *http.Request) {
atomic.AddUint64(&requestsTotal, 1)
cfg := getConfig()
@ -54,48 +50,31 @@ func handleInspect(w http.ResponseWriter, r *http.Request) {
return
}
state := breaker.State()
if state == StateOpen {
// WAF is down — bypass directly to backend.
if breaker.State() == StateOpen {
atomic.AddUint64(&requestsBypassed, 1)
log.Printf("Firewall: breaker OPEN — bypassing %s %s", r.Method, r.URL.Path)
bypassToBackend(w, r)
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_CAPTURED))
log.Printf("Firewall: breaker OPEN — fail-open, passing %s %s", r.Method, r.URL.Path)
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED))
return
}
// Proxy through WAF.
resp, err := proxyThroughWAF(w, r)
status, err := inspect(r)
if err != nil {
atomic.AddUint64(&requestsBypassed, 1)
log.Printf("Firewall: WAF unreachable — bypassing %s %s: %v", r.Method, r.URL.Path, err)
log.Printf("Firewall: inspection error — fail-open: %v", err)
breaker.RecordFailure()
bypassToBackend(w, r)
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_CAPTURED))
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED))
return
}
defer resp.Body.Close()
breaker.RecordSuccess()
if resp.StatusCode == 403 {
if status == 403 {
atomic.AddUint64(&requestsBlocked, 1)
log.Printf("Firewall: BLOCKED %s %s", r.Method, r.URL.Path)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_CAPTURED))
io.Copy(w, resp.Body)
return
}
// Pass-through: copy the WAF's response back.
atomic.AddUint64(&requestsAllowed, 1)
for key, vals := range resp.Header {
for _, v := range vals {
w.Header().Add(key, v)
}
}
w.WriteHeader(resp.StatusCode)
fmt.Fprintf(w, " ") // ensure WriteHeader is sent
io.Copy(w, resp.Body)
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_CAPTURED))
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED))
}

View file

@ -1,83 +1,54 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"time"
)
// proxyThroughWAF forwards the request to the WAF, returns the response.
// The WAF is expected to either block (403) or forward to the backend (2xx).
func proxyThroughWAF(w http.ResponseWriter, r *http.Request) (*http.Response, error) {
const maxBodySample = 4096
// inspect sends the request metadata + body sample to the WAF for inspection.
// Returns the WAF's HTTP status code or an error if unreachable.
func inspect(r *http.Request) (int, error) {
cfg := getConfig()
target, err := url.Parse(cfg.WAFURL)
// Sample body.
var bodySample []byte
if r.Body != nil {
bodySample = make([]byte, maxBodySample)
n, _ := io.ReadFull(r.Body, bodySample)
bodySample = bodySample[:n]
// Restore body so downstream can read it.
r.Body = io.NopCloser(io.MultiReader(bytes.NewReader(bodySample), r.Body))
}
// Build inspection payload.
payload, err := json.Marshal(map[string]interface{}{
"method": r.Method,
"url": r.URL.String(),
"host": r.Host,
"remote_addr": r.RemoteAddr,
"content_type": r.Header.Get("Content-Type"),
"content_length": r.ContentLength,
"headers": r.Header,
"body_sample": bodySample,
})
if err != nil {
return nil, fmt.Errorf("invalid WAF URL: %w", err)
}
// Clone the request for the WAF.
proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, target.String()+r.URL.Path, r.Body)
if err != nil {
return nil, fmt.Errorf("create proxy request: %w", err)
}
// Copy headers.
for key, vals := range r.Header {
for _, v := range vals {
proxyReq.Header.Add(key, v)
}
}
proxyReq.Header.Set("X-Forwarded-For", r.RemoteAddr)
proxyReq.Header.Set("X-Real-IP", r.RemoteAddr)
client := &http.Client{Timeout: time.Duration(cfg.TimeoutMs) * time.Millisecond}
resp, err := client.Do(proxyReq)
if err != nil {
return nil, fmt.Errorf("WAF unreachable: %w", err)
}
return resp, nil
}
// bypassToBackend forwards the request directly to the backend (fail-open path).
func bypassToBackend(w http.ResponseWriter, r *http.Request) {
cfg := getConfig()
target, err := url.Parse(cfg.BackendURL)
if err != nil {
http.Error(w, "backend unavailable", http.StatusServiceUnavailable)
return
}
proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, target.String()+r.URL.Path, r.Body)
if err != nil {
http.Error(w, "proxy error", http.StatusInternalServerError)
return
}
for key, vals := range r.Header {
for _, v := range vals {
proxyReq.Header.Add(key, v)
}
return 0, fmt.Errorf("marshal: %w", err)
}
client := &http.Client{Timeout: time.Duration(cfg.TimeoutMs) * time.Millisecond}
resp, err := client.Do(proxyReq)
resp, err := client.Post(cfg.WAFURL+"/inspect", "application/json", bytes.NewReader(payload))
if err != nil {
http.Error(w, "backend unreachable", http.StatusBadGateway)
return
return 0, fmt.Errorf("unreachable: %w", err)
}
defer resp.Body.Close()
// Copy response back.
for key, vals := range resp.Header {
for _, v := range vals {
w.Header().Add(key, v)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
log.Printf("Firewall: %s %s → status %d", r.Method, r.URL.Path, resp.StatusCode)
return resp.StatusCode, nil
}

View file

@ -5,7 +5,6 @@ function loadConfig() {
$('#fw-enabled').prop('checked', data.enabled);
$('#enabled-label').text(data.enabled ? 'Firewall Enabled' : 'Firewall Disabled');
$('#waf-url').val(data.waf_url);
$('#backend-url').val(data.backend_url);
$('#health-interval').val(data.health_interval);
$('#timeout-ms').val(data.timeout_ms);
});
@ -32,7 +31,6 @@ function saveConfig() {
data: {
enabled: $('#fw-enabled').is(':checked'),
waf_url: $('#waf-url').val().trim(),
backend_url: $('#backend-url').val().trim(),
health_interval: $('#health-interval').val(),
timeout_ms: $('#timeout-ms').val()
},

View file

@ -26,15 +26,9 @@
</div>
<div class="field">
<label for="waf-url">WAF Upstream URL</label>
<input type="text" id="waf-url" placeholder="http://127.0.0.1:8081">
<small>Requests are proxied through this WAF for inspection.</small>
</div>
<div class="field">
<label for="backend-url">Backend Fallback URL</label>
<input type="text" id="backend-url" placeholder="http://10.1.0.20:5000">
<small>Bypassed to directly when WAF is down (fail-open).</small>
<label for="waf-url">WAF Inspection URL</label>
<input type="text" id="waf-url" placeholder="http://10.1.0.11:8081">
<small>The firewall service that inspects each request and returns a verdict.</small>
</div>
<div class="field">