From 6b851057b7467581e4e1e7fe24c407ec07809373 Mon Sep 17 00:00:00 2001 From: cclohmar Date: Sun, 2 Aug 2026 06:44:57 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20inline=20proxy=20model=20=E2=80=94?= =?UTF-8?q?=20WAF=20proxy=20+=20health=20checks=20+=20backend=20fallback?= =?UTF-8?q?=20+=20circuit=20breaker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.go | 21 ++++++------ health.go | 38 +++++++++++++++++++++ main.go | 66 ++++++++++++------------------------ middleware.go | 43 +++++++++++++++--------- proxy.go | 83 +++++++++++++++++++++++++++++++++++++++++++++ wallarm_client.go | 86 ----------------------------------------------- web/app.js | 20 ++++++----- web/index.html | 23 ++++++++----- web/style.css | 7 ++++ 9 files changed, 215 insertions(+), 172 deletions(-) create mode 100644 health.go create mode 100644 proxy.go delete mode 100644 wallarm_client.go diff --git a/config.go b/config.go index f47781b..0b5e407 100644 --- a/config.go +++ b/config.go @@ -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 + 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 } -// configPath resolves to the config file next to the plugin binary. func configPath() string { exe, err := os.Executable() if err != nil { @@ -32,10 +32,11 @@ var ( func defaultConfig() Config { return Config{ - Enabled: false, - NodeURL: "http://127.0.0.1:8080", - ReturnPort: 9090, - TimeoutMs: 3000, + Enabled: false, + WAFURL: "http://127.0.0.1:8081", + BackendURL: "http://127.0.0.1:80", + HealthInterval: 5, + TimeoutMs: 3000, } } diff --git a/health.go b/health.go new file mode 100644 index 0000000..6f67793 --- /dev/null +++ b/health.go @@ -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) + } + }() +} diff --git a/main.go b/main.go index 7bb00af..b672804 100644 --- a/main.go +++ b/main.go @@ -17,19 +17,18 @@ func main() { loadConfig() spec := &zoraxy_plugin.IntroSpect{ - ID: "zoraxy-firewall", - Name: "Firewall", + ID: "zoraxy-firewall", + 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{}) { diff --git a/middleware.go b/middleware.go index e7f3b33..a18b869 100644 --- a/middleware.go +++ b/middleware.go @@ -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, `

403 Forbidden

Request blocked by WAF.

`) + 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)) } diff --git a/proxy.go b/proxy.go new file mode 100644 index 0000000..5c45e10 --- /dev/null +++ b/proxy.go @@ -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) +} diff --git a/wallarm_client.go b/wallarm_client.go deleted file mode 100644 index 68885ee..0000000 --- a/wallarm_client.go +++ /dev/null @@ -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 -} diff --git a/web/app.js b/web/app.js index 8f21bd7..9299d05 100644 --- a/web/app.js +++ b/web/app.js @@ -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); }); diff --git a/web/index.html b/web/index.html index 87e6a2f..3cd738b 100644 --- a/web/index.html +++ b/web/index.html @@ -10,7 +10,7 @@

Firewall

-

Web Application Firewall — inspect traffic against a remote inspection engine.

+

Inline WAF proxy with health checks and fail-open bypass.

@@ -19,24 +19,31 @@
Firewall Disabled
- - + + + Requests are proxied through this WAF for inspection.
- - + + + Bypassed to directly when WAF is down (fail-open).
- + + +
+ +
+
@@ -52,7 +59,7 @@
Status: - -- + --
Total Inspected: diff --git a/web/style.css b/web/style.css index d524464..16577f0 100644 --- a/web/style.css +++ b/web/style.css @@ -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;