refactor: inline proxy model — WAF proxy + health checks + backend fallback + circuit breaker

This commit is contained in:
Claus Lohmar 2026-08-02 06:44:57 +00:00
parent 2dc01e9aa0
commit 6b851057b7
9 changed files with 215 additions and 172 deletions

View file

@ -8,15 +8,15 @@ import (
"sync"
)
// Config holds all Firewall plugin settings, persisted as JSON.
// Config holds all Firewall plugin settings.
type Config struct {
Enabled bool `json:"enabled"`
NodeURL string `json:"node_url"` // e.g. http://192.168.1.50:8080
ReturnPort int `json:"return_port"` // port for async callbacks
TimeoutMs int `json:"timeout_ms"` // inspection timeout in milliseconds
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
}
// configPath resolves to the config file next to the plugin binary.
func configPath() string {
exe, err := os.Executable()
if err != nil {
@ -33,8 +33,9 @@ var (
func defaultConfig() Config {
return Config{
Enabled: false,
NodeURL: "http://127.0.0.1:8080",
ReturnPort: 9090,
WAFURL: "http://127.0.0.1:8081",
BackendURL: "http://127.0.0.1:80",
HealthInterval: 5,
TimeoutMs: 3000,
}
}

38
health.go Normal file
View file

@ -0,0 +1,38 @@
package main
import (
"log"
"net/http"
"time"
)
// startHealthCheck runs a periodic health check against the WAF.
// It updates the circuit breaker based on WAF reachability.
func startHealthCheck() {
go func() {
for {
cfg := getConfig()
interval := time.Duration(cfg.HealthInterval) * time.Second
if interval < 1*time.Second {
interval = 5 * time.Second
}
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Head(cfg.WAFURL + "/")
if err != nil {
log.Printf("WAF health check FAILED: %v", err)
breaker.RecordFailure()
} else {
resp.Body.Close()
if resp.StatusCode < 500 {
breaker.RecordSuccess()
} else {
log.Printf("WAF health check: status %d", resp.StatusCode)
breaker.RecordFailure()
}
}
time.Sleep(interval)
}
}()
}

62
main.go
View file

@ -21,15 +21,14 @@ func main() {
Name: "Firewall",
Author: "Zoraxy Community",
AuthorContact: "",
Description: "Web Application Firewall with circuit breaker and fail-open protection.",
Description: "Inline WAF proxy with health checks and fail-open bypass.",
URL: "",
Type: zoraxy_plugin.PluginType_Router, // Type 0 — intercepts traffic
Type: zoraxy_plugin.PluginType_Router,
VersionMajor: 1,
VersionMinor: 0,
VersionPatch: 0,
UIPath: "/ui",
// Intercept ALL proxied requests.
StaticCapturePaths: []zoraxy_plugin.StaticCaptureRule{{CapturePath: "/"}},
StaticCaptureIngress: "/inspect",
}
@ -39,12 +38,15 @@ func main() {
log.Fatalf("failed to receive config: %v", err)
}
// Start background health checker.
startHealthCheck()
mux := http.NewServeMux()
// --- Static capture handler (traffic interception) ---
// Static capture handler — all proxied traffic hits this.
mux.HandleFunc("/inspect", handleInspect)
// --- UI + config API ---
// UI + config API.
uiRouter := zoraxy_plugin.NewPluginEmbedUIRouter(
spec.ID, &webFS, "web", spec.UIPath,
)
@ -53,27 +55,12 @@ func main() {
uiRouter.HandleFunc("/api/stats", handleGetStats, mux)
uiRouter.RegisterTerminateHandler(func() {
log.Println("zoraxy-waf shutting down")
log.Println("firewall shutting down")
}, mux)
uiRouter.AttachHandlerToMux(mux)
// --- Return port listener for inspection endpoint async callbacks (future) ---
cfg := getConfig()
if cfg.ReturnPort > 0 {
go func() {
rmux := http.NewServeMux()
rmux.HandleFunc("/verdict", handleReturnVerdict)
addr := fmt.Sprintf(":%d", cfg.ReturnPort)
log.Printf("WAF return listener on %s", addr)
if err := http.ListenAndServe(addr, rmux); err != nil {
log.Printf("WAF return listener error: %v", err)
}
}()
}
// --- Main plugin listener ---
addr := fmt.Sprintf("127.0.0.1:%d", config.Port)
log.Printf("zoraxy-waf v%d.%d.%d listening on %s",
log.Printf("firewall v%d.%d.%d listening on %s",
spec.VersionMajor, spec.VersionMinor, spec.VersionPatch, addr)
if err := http.ListenAndServe(addr, mux); err != nil {
@ -81,11 +68,10 @@ func main() {
}
}
// --- Config API handlers ---
// --- Config API ---
func handleGetConfig(w http.ResponseWriter, r *http.Request) {
c := getConfig()
writeJSON(w, http.StatusOK, c)
writeJSON(w, http.StatusOK, getConfig())
}
func handleSaveConfig(w http.ResponseWriter, r *http.Request) {
@ -96,14 +82,17 @@ func handleSaveConfig(w http.ResponseWriter, r *http.Request) {
cfgMu.Lock()
cfg.Enabled = r.FormValue("enabled") == "true"
if u := r.FormValue("node_url"); u != "" {
cfg.NodeURL = u
if u := r.FormValue("waf_url"); u != "" {
cfg.WAFURL = u
}
if p := r.FormValue("return_port"); p != "" {
fmt.Sscanf(p, "%d", &cfg.ReturnPort)
if u := r.FormValue("backend_url"); u != "" {
cfg.BackendURL = u
}
if t := r.FormValue("timeout_ms"); t != "" {
fmt.Sscanf(t, "%d", &cfg.TimeoutMs)
if v := r.FormValue("health_interval"); v != "" {
fmt.Sscanf(v, "%d", &cfg.HealthInterval)
}
if v := r.FormValue("timeout_ms"); v != "" {
fmt.Sscanf(v, "%d", &cfg.TimeoutMs)
}
cfgMu.Unlock()
@ -119,17 +108,6 @@ func handleGetStats(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, metricsSnapshot())
}
func handleReturnVerdict(w http.ResponseWriter, r *http.Request) {
// Future: inspection endpoint async callbacks arrive here.
var resp InspectionResponse
if err := json.NewDecoder(r.Body).Decode(&resp); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad verdict"})
return
}
log.Printf("WAF async verdict: %s (reason=%s)", resp.Verdict, resp.Reason)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// --- Helpers ---
func writeJSON(w http.ResponseWriter, status int, v interface{}) {

View file

@ -2,6 +2,7 @@ package main
import (
"fmt"
"io"
"log"
"net/http"
"sync/atomic"
@ -11,12 +12,12 @@ import (
var breaker = newCircuitBreaker()
// Metrics counters
// Metrics
var (
requestsTotal uint64
requestsAllowed uint64
requestsBlocked uint64
requestsBypassed uint64 // fail-open or disabled
requestsBypassed uint64
)
func metricsSnapshot() map[string]interface{} {
@ -42,13 +43,11 @@ func circuitStateStr(s CircuitState) string {
return "unknown"
}
// handleInspect is the static capture ingress handler.
// Zoraxy proxies matching requests here. The plugin decides:
// - Return 280 (CAPTURED) → block the request
// - Return 284 (UNHANDLED) → forward as normal
// handleInspect is the static capture handler.
func handleInspect(w http.ResponseWriter, r *http.Request) {
atomic.AddUint64(&requestsTotal, 1)
cfg := getConfig()
if !cfg.Enabled {
atomic.AddUint64(&requestsBypassed, 1)
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED))
@ -57,32 +56,46 @@ func handleInspect(w http.ResponseWriter, r *http.Request) {
state := breaker.State()
if state == StateOpen {
// WAF is down — bypass directly to backend.
atomic.AddUint64(&requestsBypassed, 1)
log.Printf("WAF: breaker OPEN — fail-open, passing %s %s", r.Method, r.URL.Path)
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED))
log.Printf("Firewall: breaker OPEN — bypassing %s %s", r.Method, r.URL.Path)
bypassToBackend(w, r)
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_CAPTURED))
return
}
resp, err := inspect(r)
// Proxy through WAF.
resp, err := proxyThroughWAF(w, r)
if err != nil {
atomic.AddUint64(&requestsBypassed, 1)
log.Printf("WAF: inspection error: %v", err)
log.Printf("Firewall: WAF unreachable — bypassing %s %s: %v", r.Method, r.URL.Path, err)
breaker.RecordFailure()
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED))
bypassToBackend(w, r)
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_CAPTURED))
return
}
defer resp.Body.Close()
breaker.RecordSuccess()
if resp.Verdict == "block" {
if resp.StatusCode == 403 {
atomic.AddUint64(&requestsBlocked, 1)
log.Printf("WAF: BLOCKED %s %s — reason: %s", r.Method, r.URL.Path, resp.Reason)
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))
fmt.Fprintf(w, `<html><body><h1>403 Forbidden</h1><p>Request blocked by WAF.</p></body></html>`)
io.Copy(w, resp.Body)
return
}
// Pass-through: copy the WAF's response back.
atomic.AddUint64(&requestsAllowed, 1)
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED))
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))
}

83
proxy.go Normal file
View file

@ -0,0 +1,83 @@
package main
import (
"fmt"
"io"
"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) {
cfg := getConfig()
target, err := url.Parse(cfg.WAFURL)
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)
}
}
client := &http.Client{Timeout: time.Duration(cfg.TimeoutMs) * time.Millisecond}
resp, err := client.Do(proxyReq)
if err != nil {
http.Error(w, "backend unreachable", http.StatusBadGateway)
return
}
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)
}

View file

@ -1,86 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
)
// InspectionRequest is sent to the inspection endpoint.
type InspectionRequest struct {
Method string `json:"method"`
URL string `json:"url"`
Host string `json:"host"`
RemoteAddr string `json:"remote_addr"`
ContentType string `json:"content_type"`
ContentLength int64 `json:"content_length"`
Headers map[string][]string `json:"headers"`
BodySample []byte `json:"body_sample"` // first N bytes
}
// InspectionResponse is returned by the inspection endpoint.
type InspectionResponse struct {
Verdict string `json:"verdict"` // "allow" or "block"
Reason string `json:"reason,omitempty"`
Score int `json:"score,omitempty"`
}
const maxBodySample = 4096 // only send first 4KB to inspection endpoint
// inspect sends a synchronous inspection request to the inspection endpoint.
// Returns nil if allowed, or an error describing the block reason.
func inspect(r *http.Request) (*InspectionResponse, error) {
c := getConfig()
// Sample the first N bytes of the body.
var bodySample []byte
if r.Body != nil {
bodySample = make([]byte, maxBodySample)
n, _ := io.ReadFull(r.Body, bodySample)
bodySample = bodySample[:n]
// Restore body for downstream use.
r.Body = io.NopCloser(io.MultiReader(
bytes.NewReader(bodySample),
r.Body,
))
}
inspReq := InspectionRequest{
Method: r.Method,
URL: r.URL.String(),
Host: r.Host,
RemoteAddr: r.RemoteAddr,
ContentType: r.Header.Get("Content-Type"),
ContentLength: r.ContentLength,
Headers: map[string][]string(r.Header),
BodySample: bodySample,
}
body, err := json.Marshal(inspReq)
if err != nil {
return nil, fmt.Errorf("marshal inspection request: %w", err)
}
timeout := time.Duration(c.TimeoutMs) * time.Millisecond
client := &http.Client{Timeout: timeout}
resp, err := client.Post(c.NodeURL+"/inspect", "application/json", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("inspection node unreachable: %w", err)
}
defer resp.Body.Close()
var inspResp InspectionResponse
if err := json.NewDecoder(resp.Body).Decode(&inspResp); err != nil {
return nil, fmt.Errorf("decode inspection response: %w", err)
}
log.Printf("WAF: %s %s → %s (score=%d reason=%s)",
r.Method, r.URL.Path, inspResp.Verdict, inspResp.Score, inspResp.Reason)
return &inspResp, nil
}

View file

@ -2,10 +2,11 @@
function loadConfig() {
$.get('./api/config', function (data) {
$('#waf-enabled').prop('checked', data.enabled);
$('#fw-enabled').prop('checked', data.enabled);
$('#enabled-label').text(data.enabled ? 'Firewall Enabled' : 'Firewall Disabled');
$('#node-url').val(data.node_url);
$('#return-port').val(data.return_port);
$('#waf-url').val(data.waf_url);
$('#backend-url').val(data.backend_url);
$('#health-interval').val(data.health_interval);
$('#timeout-ms').val(data.timeout_ms);
});
}
@ -14,7 +15,7 @@ function loadStats() {
$.get('./api/stats', function (data) {
$('#circuit-state').text(data.circuit).attr('class', 'stat-value ' +
(data.circuit === 'open' ? 'warn' : data.circuit === 'closed' ? 'ok' : 'warn'));
$('#waf-status').text(data.enabled ? 'Active' : 'Disabled').attr('class', 'stat-value ' +
$('#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);
@ -29,9 +30,10 @@ function saveConfig() {
url: './api/config/save',
type: 'POST',
data: {
enabled: $('#waf-enabled').is(':checked'),
node_url: $('#node-url').val().trim(),
return_port: $('#return-port').val(),
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()
},
success: function () {
@ -46,7 +48,7 @@ function saveConfig() {
});
}
$('#waf-enabled').on('change', function () {
$('#fw-enabled').on('change', function () {
$('#enabled-label').text(this.checked ? 'Firewall Enabled' : 'Firewall Disabled');
});
@ -55,5 +57,5 @@ $('#save-btn').on('click', saveConfig);
$(document).ready(function () {
loadConfig();
loadStats();
setInterval(loadStats, 10000); // refresh stats every 10s
setInterval(loadStats, 5000);
});

View file

@ -10,7 +10,7 @@
<body>
<header>
<h1>Firewall</h1>
<p>Web Application Firewall — inspect traffic against a remote inspection engine.</p>
<p>Inline WAF proxy with health checks and fail-open bypass.</p>
</header>
<main>
@ -19,24 +19,31 @@
<div class="field">
<label class="toggle">
<input type="checkbox" id="waf-enabled">
<input type="checkbox" id="fw-enabled">
<span class="slider"></span>
</label>
<span id="enabled-label">Firewall Disabled</span>
</div>
<div class="field">
<label for="node-url">Inspection Endpoint URL</label>
<input type="text" id="node-url" placeholder="http://192.168.1.50:8080">
<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="return-port">Return Port (async callbacks)</label>
<input type="number" id="return-port" placeholder="9090" min="1" max="65535">
<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 class="field">
<label for="timeout-ms">Inspection Timeout (ms)</label>
<label for="health-interval">Health Check Interval (s)</label>
<input type="number" id="health-interval" placeholder="5" min="1" max="60">
</div>
<div class="field">
<label for="timeout-ms">Proxy Timeout (ms)</label>
<input type="number" id="timeout-ms" placeholder="3000" min="100" max="30000">
</div>
@ -52,7 +59,7 @@
</div>
<div class="stat-row">
<span class="stat-label">Status:</span>
<span id="waf-status" class="stat-value">--</span>
<span id="fw-status" class="stat-value">--</span>
</div>
<div class="stat-row">
<span class="stat-label">Total Inspected:</span>

View file

@ -55,6 +55,13 @@ section {
box-shadow: 0 0 0 2px rgba(9,132,227,0.15);
}
.field small {
display: block;
font-size: 11px;
color: #b2bec3;
margin-top: 3px;
}
/* Toggle switch */
.toggle {
position: relative;