simplify: transparent proxy — plugin just forwards to WAF, copies response. Remove backend routing, return port listener, hosts.go
This commit is contained in:
parent
d2fd6edd3a
commit
f40971b5bb
7 changed files with 27 additions and 235 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
76
hosts.go
76
hosts.go
|
|
@ -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
51
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)
|
||||
|
|
|
|||
|
|
@ -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("<html><body><h1>403 Forbidden</h1><p>Blocked by Firewall</p></body></html>"))
|
||||
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
|
||||
}
|
||||
|
|
|
|||
58
proxy.go
58
proxy.go
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -31,12 +31,6 @@
|
|||
<small>The firewall application URL (e.g. Wallarm, ModSecurity, Coraza).</small>
|
||||
</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">
|
||||
<label for="return-port">Return Port</label>
|
||||
<input type="number" id="return-port" placeholder="8080" min="1" max="65535">
|
||||
|
|
|
|||
Loading…
Reference in a new issue