simplify: transparent proxy — plugin just forwards to WAF, copies response. Remove backend routing, return port listener, hosts.go

This commit is contained in:
Claus Lohmar 2026-08-08 06:37:30 +00:00
parent d2fd6edd3a
commit f40971b5bb
7 changed files with 27 additions and 235 deletions

View file

@ -8,12 +8,9 @@ import (
"sync" "sync"
) )
// 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"` 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"` HealthInterval int `json:"health_interval"`
TimeoutMs int `json:"timeout_ms"` TimeoutMs int `json:"timeout_ms"`
} }
@ -35,8 +32,6 @@ func defaultConfig() Config {
return Config{ return Config{
Enabled: false, Enabled: false,
WAFURL: "http://127.0.0.1:8080", WAFURL: "http://127.0.0.1:8080",
BackendURL: "http://127.0.0.1:80",
ReturnPort: 8080,
HealthInterval: 5, HealthInterval: 5,
TimeoutMs: 500, TimeoutMs: 500,
} }

View file

@ -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
}

51
main.go
View file

@ -15,14 +15,13 @@ var webFS embed.FS
func main() { func main() {
loadConfig() loadConfig()
loadHostMap() // build domain→backend mapping from Zoraxy proxy rules
spec := &zoraxy_plugin.IntroSpect{ spec := &zoraxy_plugin.IntroSpect{
ID: "zoraxy-firewall", ID: "zoraxy-firewall",
Name: "Firewall", Name: "Firewall",
Author: "Zoraxy Community", Author: "Zoraxy Community",
AuthorContact: "", AuthorContact: "",
Description: "Inline WAF proxy with health checks and fail-open bypass.", Description: "Transparent WAF proxy — forwards traffic to firewall for inline inspection.",
URL: "", URL: "",
Type: zoraxy_plugin.PluginType_Router, Type: zoraxy_plugin.PluginType_Router,
VersionMajor: 1, VersionMajor: 1,
@ -39,33 +38,13 @@ func main() {
log.Fatalf("failed to receive config: %v", err) log.Fatalf("failed to receive config: %v", err)
} }
// Start background health checker.
startHealthCheck() 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() mux := http.NewServeMux()
// Static capture handler — must match sub-paths (Zoraxy sends /inspect/some/page).
mux.Handle("/inspect/", http.HandlerFunc(handleInspect)) mux.Handle("/inspect/", http.HandlerFunc(handleInspect))
mux.HandleFunc("/inspect", 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", handleGetConfig, mux)
uiRouter.HandleFunc("/api/config/save", handleSaveConfig, mux) uiRouter.HandleFunc("/api/config/save", handleSaveConfig, mux)
uiRouter.HandleFunc("/api/stats", handleGetStats, mux) uiRouter.HandleFunc("/api/stats", handleGetStats, mux)
@ -76,16 +55,13 @@ func main() {
uiRouter.AttachHandlerToMux(mux) uiRouter.AttachHandlerToMux(mux)
addr := fmt.Sprintf("127.0.0.1:%d", config.Port) addr := fmt.Sprintf("127.0.0.1:%d", config.Port)
log.Printf("firewall v%d.%d.%d listening on %s", log.Printf("firewall v%d.%d.%d listening on %s", spec.VersionMajor, spec.VersionMinor, spec.VersionPatch, addr)
spec.VersionMajor, spec.VersionMinor, spec.VersionPatch, addr)
if err := http.ListenAndServe(addr, mux); err != nil { if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("server error: %v", err) log.Fatalf("server error: %v", err)
} }
} }
// --- Config API ---
func handleGetConfig(w http.ResponseWriter, r *http.Request) { func handleGetConfig(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, getConfig()) writeJSON(w, http.StatusOK, getConfig())
} }
@ -101,12 +77,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("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 != "" { if v := r.FormValue("health_interval"); v != "" {
fmt.Sscanf(v, "%d", &cfg.HealthInterval) fmt.Sscanf(v, "%d", &cfg.HealthInterval)
} }
@ -127,21 +97,6 @@ func handleGetStats(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, metricsSnapshot()) 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{}) { func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)

View file

@ -4,8 +4,6 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"net/url"
"strings"
"sync/atomic" "sync/atomic"
"time" "time"
) )
@ -43,62 +41,47 @@ func handleInspect(w http.ResponseWriter, r *http.Request) {
atomic.AddUint64(&requestsTotal, 1) atomic.AddUint64(&requestsTotal, 1)
cfg := getConfig() cfg := getConfig()
if !cfg.Enabled { if !cfg.Enabled || breaker.State() == StateOpen {
atomic.AddUint64(&requestsBypassed, 1) 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 return
} }
if breaker.State() == StateOpen { resp, err := forwardToWAF(w, r)
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)
if err != nil { if err != nil {
atomic.AddUint64(&requestsBypassed, 1) atomic.AddUint64(&requestsBypassed, 1)
log.Printf("Firewall: inspection error — fail-open: %v", err)
breaker.RecordFailure() 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 return
} }
breaker.RecordSuccess() 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("<html><body><h1>403 Forbidden</h1><p>Blocked by Firewall</p></body></html>"))
return
}
atomic.AddUint64(&requestsAllowed, 1) atomic.AddUint64(&requestsAllowed, 1)
proxyToBackend(w, r)
} }
func proxyToBackend(w http.ResponseWriter, r *http.Request) { // forwardToWAF proxies the request to the WAF and copies the response back.
backend := resolveBackend(r.Host) func forwardToWAF(w http.ResponseWriter, r *http.Request) (*http.Response, error) {
if !strings.HasPrefix(backend, "http") { cfg := getConfig()
backend = "http://" + backend
}
target, err := url.Parse(backend)
if err != nil {
http.Error(w, "invalid backend URL", http.StatusInternalServerError)
return
}
originalPath := r.Header.Get("X-Zoraxy-Uri") originalPath := r.Header.Get("X-Zoraxy-Uri")
if originalPath == "" { if originalPath == "" {
originalPath = r.URL.Path 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 { if err != nil {
http.Error(w, "proxy error", http.StatusInternalServerError) http.Error(w, "proxy error", http.StatusInternalServerError)
return return nil, err
} }
for key, vals := range r.Header { 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.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) resp, err := client.Do(proxyReq)
if err != nil { if err != nil {
http.Error(w, "backend unreachable", http.StatusBadGateway) http.Error(w, "WAF unreachable", http.StatusBadGateway)
return return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
@ -123,4 +105,6 @@ func proxyToBackend(w http.ResponseWriter, r *http.Request) {
} }
w.WriteHeader(resp.StatusCode) w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body) io.Copy(w, resp.Body)
return resp, nil
} }

View file

@ -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
}

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);
$('#back-url').val(data.backend_url);
$('#return-port').val(data.return_port); $('#return-port').val(data.return_port);
$('#health-interval').val(data.health_interval); $('#health-interval').val(data.health_interval);
$('#timeout-ms').val(data.timeout_ms); $('#timeout-ms').val(data.timeout_ms);
@ -33,7 +32,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(),
back_url: $('#back-url').val().trim(),
return_port: $('#return-port').val(), return_port: $('#return-port').val(),
health_interval: $('#health-interval').val(), health_interval: $('#health-interval').val(),
timeout_ms: $('#timeout-ms').val() timeout_ms: $('#timeout-ms').val()

View file

@ -31,12 +31,6 @@
<small>The firewall application URL (e.g. Wallarm, ModSecurity, Coraza).</small> <small>The firewall application URL (e.g. Wallarm, ModSecurity, Coraza).</small>
</div> </div>
<div class="field">
<label for="back-url">Backend URL (allowed traffic)</label>
<input type="text" id="back-url" placeholder="http://10.1.1.10:8001">
<small>Traffic is proxied here when the WAF allows it.</small>
</div>
<div class="field"> <div class="field">
<label for="return-port">Return Port</label> <label for="return-port">Return Port</label>
<input type="number" id="return-port" placeholder="8080" min="1" max="65535"> <input type="number" id="return-port" placeholder="8080" min="1" max="65535">