diff --git a/config.go b/config.go index 4a42018..aa7869e 100644 --- a/config.go +++ b/config.go @@ -8,12 +8,9 @@ import ( "sync" ) -// Config holds all Firewall plugin settings. type Config struct { Enabled bool `json:"enabled"` WAFURL string `json:"waf_url"` - BackendURL string `json:"backend_url"` // where to proxy allowed traffic - ReturnPort int `json:"return_port"` HealthInterval int `json:"health_interval"` TimeoutMs int `json:"timeout_ms"` } @@ -35,8 +32,6 @@ func defaultConfig() Config { return Config{ Enabled: false, WAFURL: "http://127.0.0.1:8080", - BackendURL: "http://127.0.0.1:80", - ReturnPort: 8080, HealthInterval: 5, TimeoutMs: 500, } diff --git a/hosts.go b/hosts.go deleted file mode 100644 index 41d1311..0000000 --- a/hosts.go +++ /dev/null @@ -1,76 +0,0 @@ -package main - -import ( - "encoding/json" - "log" - "os" - "path/filepath" - "strings" - "sync" -) - -// proxyRule is the minimal structure we need from Zoraxy's proxy config files. -type proxyRule struct { - Domain string `json:"RootOrMatchingDomain"` - ActiveOrigins []struct { - Origin string `json:"OriginIpOrDomain"` - } `json:"ActiveOrigins"` - Disabled bool `json:"Disabled"` -} - -// hostMap maps request Host headers to backend URLs. -var ( - hostMap = make(map[string]string) - hostMapMu sync.RWMutex -) - -// loadHostMap reads Zoraxy proxy configs and builds a host→backend map. -func loadHostMap() { - hostMapMu.Lock() - defer hostMapMu.Unlock() - - hostMap = make(map[string]string) - proxyDir := "/opt/zoraxy/conf/proxy" - - entries, err := os.ReadDir(proxyDir) - if err != nil { - log.Printf("WARN: cannot read proxy configs: %v", err) - return - } - - for _, entry := range entries { - if !strings.HasSuffix(entry.Name(), ".config") { - continue - } - path := filepath.Join(proxyDir, entry.Name()) - data, err := os.ReadFile(path) - if err != nil { - continue - } - var rule proxyRule - if err := json.Unmarshal(data, &rule); err != nil { - continue - } - if rule.Disabled || len(rule.ActiveOrigins) == 0 { - continue - } - hostMap[strings.ToLower(rule.Domain)] = rule.ActiveOrigins[0].Origin - } - log.Printf("Firewall: loaded %d proxy backends", len(hostMap)) -} - -// resolveBackend finds the backend URL for a given Host header. -// Falls back to the configured BackendURL if no match. -func resolveBackend(host string) string { - hostMapMu.RLock() - defer hostMapMu.RUnlock() - - if backend, ok := hostMap[strings.ToLower(host)]; ok { - return backend - } - if backend, ok := hostMap[strings.ToLower(strings.Split(host, ":")[0])]; ok { - return backend - } - // Fallback - return getConfig().BackendURL -} diff --git a/main.go b/main.go index 57d438a..c04568e 100644 --- a/main.go +++ b/main.go @@ -15,14 +15,13 @@ var webFS embed.FS func main() { loadConfig() - loadHostMap() // build domain→backend mapping from Zoraxy proxy rules spec := &zoraxy_plugin.IntroSpect{ ID: "zoraxy-firewall", Name: "Firewall", Author: "Zoraxy Community", AuthorContact: "", - Description: "Inline WAF proxy with health checks and fail-open bypass.", + Description: "Transparent WAF proxy — forwards traffic to firewall for inline inspection.", URL: "", Type: zoraxy_plugin.PluginType_Router, VersionMajor: 1, @@ -39,33 +38,13 @@ func main() { log.Fatalf("failed to receive config: %v", err) } - // Start background health checker. startHealthCheck() - // Return port listener for WAF async callbacks. - cfg := getConfig() - if cfg.ReturnPort > 0 { - go func() { - rmux := http.NewServeMux() - rmux.HandleFunc("/verdict", handleReturnVerdict) - addr := fmt.Sprintf(":%d", cfg.ReturnPort) - log.Printf("Firewall: return listener on %s", addr) - if err := http.ListenAndServe(addr, rmux); err != nil { - log.Printf("Firewall: return listener error: %v", err) - } - }() - } - mux := http.NewServeMux() - - // Static capture handler — must match sub-paths (Zoraxy sends /inspect/some/page). mux.Handle("/inspect/", http.HandlerFunc(handleInspect)) mux.HandleFunc("/inspect", handleInspect) - // UI + config API. - uiRouter := zoraxy_plugin.NewPluginEmbedUIRouter( - spec.ID, &webFS, "web", spec.UIPath, - ) + uiRouter := zoraxy_plugin.NewPluginEmbedUIRouter(spec.ID, &webFS, "web", spec.UIPath) uiRouter.HandleFunc("/api/config", handleGetConfig, mux) uiRouter.HandleFunc("/api/config/save", handleSaveConfig, mux) uiRouter.HandleFunc("/api/stats", handleGetStats, mux) @@ -76,16 +55,13 @@ func main() { uiRouter.AttachHandlerToMux(mux) addr := fmt.Sprintf("127.0.0.1:%d", config.Port) - log.Printf("firewall v%d.%d.%d listening on %s", - spec.VersionMajor, spec.VersionMinor, spec.VersionPatch, addr) + 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 { log.Fatalf("server error: %v", err) } } -// --- Config API --- - func handleGetConfig(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, getConfig()) } @@ -101,12 +77,6 @@ func handleSaveConfig(w http.ResponseWriter, r *http.Request) { if u := r.FormValue("waf_url"); u != "" { cfg.WAFURL = u } - if u := r.FormValue("back_url"); u != "" { - cfg.BackendURL = u - } - if v := r.FormValue("return_port"); v != "" { - fmt.Sscanf(v, "%d", &cfg.ReturnPort) - } if v := r.FormValue("health_interval"); v != "" { fmt.Sscanf(v, "%d", &cfg.HealthInterval) } @@ -127,21 +97,6 @@ func handleGetStats(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, metricsSnapshot()) } -func handleReturnVerdict(w http.ResponseWriter, r *http.Request) { - var v struct { - Verdict string `json:"verdict"` - Reason string `json:"reason"` - } - if err := json.NewDecoder(r.Body).Decode(&v); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad payload"}) - return - } - log.Printf("Firewall: async verdict %s (reason=%s)", v.Verdict, v.Reason) - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) -} - -// --- Helpers --- - func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/middleware.go b/middleware.go index bf76d95..9d4097e 100644 --- a/middleware.go +++ b/middleware.go @@ -4,8 +4,6 @@ import ( "io" "log" "net/http" - "net/url" - "strings" "sync/atomic" "time" ) @@ -43,62 +41,47 @@ func handleInspect(w http.ResponseWriter, r *http.Request) { atomic.AddUint64(&requestsTotal, 1) cfg := getConfig() - if !cfg.Enabled { + if !cfg.Enabled || breaker.State() == StateOpen { atomic.AddUint64(&requestsBypassed, 1) - proxyToBackend(w, r) + if breaker.State() == StateOpen { + log.Printf("Firewall: breaker OPEN — bypass %s %s", r.Method, r.URL.Path) + } + // Bypass: forward to WAF (WAF sends back to Zoraxy for routing). + forwardToWAF(w, r) return } - if breaker.State() == StateOpen { - atomic.AddUint64(&requestsBypassed, 1) - log.Printf("Firewall: breaker OPEN — fail-open %s %s", r.Method, r.URL.Path) - proxyToBackend(w, r) - return - } - - status, err := inspect(r) + resp, err := forwardToWAF(w, r) if err != nil { atomic.AddUint64(&requestsBypassed, 1) - log.Printf("Firewall: inspection error — fail-open: %v", err) breaker.RecordFailure() - proxyToBackend(w, r) + return + } + + if resp.StatusCode == 403 { + breaker.RecordSuccess() + atomic.AddUint64(&requestsBlocked, 1) + log.Printf("Firewall: BLOCKED %s %s", r.Method, r.URL.Path) return } breaker.RecordSuccess() - - if status == 403 { - atomic.AddUint64(&requestsBlocked, 1) - log.Printf("Firewall: BLOCKED %s %s", r.Method, r.URL.Path) - w.WriteHeader(403) - w.Write([]byte("

403 Forbidden

Blocked by Firewall

")) - return - } - atomic.AddUint64(&requestsAllowed, 1) - proxyToBackend(w, r) } -func proxyToBackend(w http.ResponseWriter, r *http.Request) { - backend := resolveBackend(r.Host) - if !strings.HasPrefix(backend, "http") { - backend = "http://" + backend - } - target, err := url.Parse(backend) - if err != nil { - http.Error(w, "invalid backend URL", http.StatusInternalServerError) - return - } +// forwardToWAF proxies the request to the WAF and copies the response back. +func forwardToWAF(w http.ResponseWriter, r *http.Request) (*http.Response, error) { + cfg := getConfig() originalPath := r.Header.Get("X-Zoraxy-Uri") if originalPath == "" { originalPath = r.URL.Path } - proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, target.String()+originalPath, r.Body) + proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, cfg.WAFURL+originalPath, r.Body) if err != nil { http.Error(w, "proxy error", http.StatusInternalServerError) - return + return nil, err } for key, vals := range r.Header { @@ -106,13 +89,12 @@ func proxyToBackend(w http.ResponseWriter, r *http.Request) { proxyReq.Header.Add(key, v) } } - proxyReq.Header.Set("X-Forwarded-For", r.RemoteAddr) - client := &http.Client{Timeout: time.Duration(getConfig().TimeoutMs) * time.Millisecond} + 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 + http.Error(w, "WAF unreachable", http.StatusBadGateway) + return nil, err } defer resp.Body.Close() @@ -123,4 +105,6 @@ func proxyToBackend(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(resp.StatusCode) io.Copy(w, resp.Body) + + return resp, nil } diff --git a/proxy.go b/proxy.go deleted file mode 100644 index 5079b14..0000000 --- a/proxy.go +++ /dev/null @@ -1,58 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "time" -) - -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() - - // 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 with the original URI. - originalPath := r.Header.Get("X-Zoraxy-Uri") - if originalPath == "" { - originalPath = r.URL.String() - } - payload, err := json.Marshal(map[string]interface{}{ - "method": r.Method, - "url": originalPath, - "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 0, fmt.Errorf("marshal: %w", err) - } - - client := &http.Client{Timeout: time.Duration(cfg.TimeoutMs) * time.Millisecond} - resp, err := client.Post(cfg.WAFURL+"/inspect", "application/json", bytes.NewReader(payload)) - if err != nil { - return 0, fmt.Errorf("unreachable: %w", err) - } - defer resp.Body.Close() - - 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 64ba9b5..c30acb1 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); - $('#back-url').val(data.backend_url); $('#return-port').val(data.return_port); $('#health-interval').val(data.health_interval); $('#timeout-ms').val(data.timeout_ms); @@ -33,7 +32,6 @@ function saveConfig() { data: { enabled: $('#fw-enabled').is(':checked'), waf_url: $('#waf-url').val().trim(), - back_url: $('#back-url').val().trim(), return_port: $('#return-port').val(), health_interval: $('#health-interval').val(), timeout_ms: $('#timeout-ms').val() diff --git a/web/index.html b/web/index.html index 9bd18b7..dcff1bb 100644 --- a/web/index.html +++ b/web/index.html @@ -31,12 +31,6 @@ The firewall application URL (e.g. Wallarm, ModSecurity, Coraza). -
- - - Traffic is proxied here when the WAF allows it. -
-