diff --git a/config.go b/config.go index 0b5e407..c991a2f 100644 --- a/config.go +++ b/config.go @@ -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, } diff --git a/main.go b/main.go index b672804..6c83da5 100644 --- a/main.go +++ b/main.go @@ -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) } diff --git a/middleware.go b/middleware.go index a18b869..3cf4024 100644 --- a/middleware.go +++ b/middleware.go @@ -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)) } diff --git a/proxy.go b/proxy.go index 5c45e10..e95f3a5 100644 --- a/proxy.go +++ b/proxy.go @@ -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 } diff --git a/web/app.js b/web/app.js index 9299d05..263946e 100644 --- a/web/app.js +++ b/web/app.js @@ -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() }, diff --git a/web/index.html b/web/index.html index 3cd738b..83d4520 100644 --- a/web/index.html +++ b/web/index.html @@ -26,15 +26,9 @@