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

View file

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

View file

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

View file

@ -1,83 +1,54 @@
package main package main
import ( import (
"bytes"
"encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"net/url"
"time" "time"
) )
// proxyThroughWAF forwards the request to the WAF, returns the response. const maxBodySample = 4096
// 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) { // 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() 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 { if err != nil {
return nil, fmt.Errorf("invalid WAF URL: %w", err) return 0, fmt.Errorf("marshal: %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)
}
} }
client := &http.Client{Timeout: time.Duration(cfg.TimeoutMs) * time.Millisecond} 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 { if err != nil {
http.Error(w, "backend unreachable", http.StatusBadGateway) return 0, fmt.Errorf("unreachable: %w", err)
return
} }
defer resp.Body.Close() defer resp.Body.Close()
// Copy response back. log.Printf("Firewall: %s %s → status %d", r.Method, r.URL.Path, resp.StatusCode)
for key, vals := range resp.Header { return resp.StatusCode, nil
for _, v := range vals {
w.Header().Add(key, v)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
} }

View file

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

View file

@ -26,15 +26,9 @@
</div> </div>
<div class="field"> <div class="field">
<label for="waf-url">WAF Upstream URL</label> <label for="waf-url">WAF Inspection URL</label>
<input type="text" id="waf-url" placeholder="http://127.0.0.1:8081"> <input type="text" id="waf-url" placeholder="http://10.1.0.11:8081">
<small>Requests are proxied through this WAF for inspection.</small> <small>The firewall service that inspects each request and returns a verdict.</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>
</div> </div>
<div class="field"> <div class="field">