simplify: inspection-only model — plugin inspects via WAF API, Zoraxy handles routing
This commit is contained in:
parent
6b851057b7
commit
32d60a3583
6 changed files with 49 additions and 112 deletions
10
config.go
10
config.go
|
|
@ -11,10 +11,9 @@ import (
|
||||||
// Config holds all Firewall plugin settings.
|
// 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"` // e.g. http://127.0.0.1:8081
|
WAFURL string `json:"waf_url"`
|
||||||
BackendURL string `json:"backend_url"` // e.g. http://10.1.0.20:5000
|
HealthInterval int `json:"health_interval"`
|
||||||
HealthInterval int `json:"health_interval"` // seconds between health checks
|
TimeoutMs int `json:"timeout_ms"`
|
||||||
TimeoutMs int `json:"timeout_ms"` // proxy timeout in ms
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func configPath() string {
|
func configPath() string {
|
||||||
|
|
@ -33,8 +32,7 @@ var (
|
||||||
func defaultConfig() Config {
|
func defaultConfig() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
WAFURL: "http://127.0.0.1:8081",
|
WAFURL: "http://10.1.0.11:8081",
|
||||||
BackendURL: "http://127.0.0.1:80",
|
|
||||||
HealthInterval: 5,
|
HealthInterval: 5,
|
||||||
TimeoutMs: 3000,
|
TimeoutMs: 3000,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
main.go
3
main.go
|
|
@ -85,9 +85,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("backend_url"); u != "" {
|
|
||||||
cfg.BackendURL = u
|
|
||||||
}
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
@ -12,7 +10,6 @@ import (
|
||||||
|
|
||||||
var breaker = newCircuitBreaker()
|
var breaker = newCircuitBreaker()
|
||||||
|
|
||||||
// Metrics
|
|
||||||
var (
|
var (
|
||||||
requestsTotal uint64
|
requestsTotal uint64
|
||||||
requestsAllowed uint64
|
requestsAllowed uint64
|
||||||
|
|
@ -43,7 +40,6 @@ func circuitStateStr(s CircuitState) string {
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleInspect is the static capture handler.
|
|
||||||
func handleInspect(w http.ResponseWriter, r *http.Request) {
|
func handleInspect(w http.ResponseWriter, r *http.Request) {
|
||||||
atomic.AddUint64(&requestsTotal, 1)
|
atomic.AddUint64(&requestsTotal, 1)
|
||||||
cfg := getConfig()
|
cfg := getConfig()
|
||||||
|
|
@ -54,48 +50,31 @@ func handleInspect(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state := breaker.State()
|
if breaker.State() == StateOpen {
|
||||||
if state == StateOpen {
|
|
||||||
// WAF is down — bypass directly to backend.
|
|
||||||
atomic.AddUint64(&requestsBypassed, 1)
|
atomic.AddUint64(&requestsBypassed, 1)
|
||||||
log.Printf("Firewall: breaker OPEN — bypassing %s %s", r.Method, r.URL.Path)
|
log.Printf("Firewall: breaker OPEN — fail-open, passing %s %s", r.Method, r.URL.Path)
|
||||||
bypassToBackend(w, r)
|
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED))
|
||||||
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_CAPTURED))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proxy through WAF.
|
status, err := inspect(r)
|
||||||
resp, err := proxyThroughWAF(w, r)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
atomic.AddUint64(&requestsBypassed, 1)
|
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()
|
breaker.RecordFailure()
|
||||||
bypassToBackend(w, r)
|
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED))
|
||||||
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_CAPTURED))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
breaker.RecordSuccess()
|
breaker.RecordSuccess()
|
||||||
|
|
||||||
if resp.StatusCode == 403 {
|
if status == 403 {
|
||||||
atomic.AddUint64(&requestsBlocked, 1)
|
atomic.AddUint64(&requestsBlocked, 1)
|
||||||
log.Printf("Firewall: BLOCKED %s %s", r.Method, r.URL.Path)
|
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))
|
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_CAPTURED))
|
||||||
io.Copy(w, resp.Body)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass-through: copy the WAF's response back.
|
|
||||||
atomic.AddUint64(&requestsAllowed, 1)
|
atomic.AddUint64(&requestsAllowed, 1)
|
||||||
for key, vals := range resp.Header {
|
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED))
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
97
proxy.go
97
proxy.go
|
|
@ -1,83 +1,54 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// proxyThroughWAF forwards the request to the WAF, returns the response.
|
const maxBodySample = 4096
|
||||||
// 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) {
|
// 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()
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid WAF URL: %w", err)
|
return 0, fmt.Errorf("marshal: %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}
|
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 {
|
if err != nil {
|
||||||
http.Error(w, "backend unreachable", http.StatusBadGateway)
|
return 0, fmt.Errorf("unreachable: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Copy response back.
|
log.Printf("Firewall: %s %s → status %d", r.Method, r.URL.Path, resp.StatusCode)
|
||||||
for key, vals := range resp.Header {
|
return resp.StatusCode, nil
|
||||||
for _, v := range vals {
|
|
||||||
w.Header().Add(key, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.WriteHeader(resp.StatusCode)
|
|
||||||
io.Copy(w, resp.Body)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
$('#backend-url').val(data.backend_url);
|
|
||||||
$('#health-interval').val(data.health_interval);
|
$('#health-interval').val(data.health_interval);
|
||||||
$('#timeout-ms').val(data.timeout_ms);
|
$('#timeout-ms').val(data.timeout_ms);
|
||||||
});
|
});
|
||||||
|
|
@ -32,7 +31,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(),
|
||||||
backend_url: $('#backend-url').val().trim(),
|
|
||||||
health_interval: $('#health-interval').val(),
|
health_interval: $('#health-interval').val(),
|
||||||
timeout_ms: $('#timeout-ms').val()
|
timeout_ms: $('#timeout-ms').val()
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -26,15 +26,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="waf-url">WAF Upstream URL</label>
|
<label for="waf-url">WAF Inspection URL</label>
|
||||||
<input type="text" id="waf-url" placeholder="http://127.0.0.1:8081">
|
<input type="text" id="waf-url" placeholder="http://10.1.0.11:8081">
|
||||||
<small>Requests are proxied through this WAF for inspection.</small>
|
<small>The firewall service that inspects each request and returns a verdict.</small>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="backend-url">Backend Fallback URL</label>
|
|
||||||
<input type="text" id="backend-url" placeholder="http://10.1.0.20:5000">
|
|
||||||
<small>Bypassed to directly when WAF is down (fail-open).</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue