refactor: inline proxy model — WAF proxy + health checks + backend fallback + circuit breaker
This commit is contained in:
parent
2dc01e9aa0
commit
6b851057b7
9 changed files with 215 additions and 172 deletions
15
config.go
15
config.go
|
|
@ -8,15 +8,15 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds all Firewall plugin settings, persisted as JSON.
|
// Config holds all Firewall plugin settings.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
NodeURL string `json:"node_url"` // e.g. http://192.168.1.50:8080
|
WAFURL string `json:"waf_url"` // e.g. http://127.0.0.1:8081
|
||||||
ReturnPort int `json:"return_port"` // port for async callbacks
|
BackendURL string `json:"backend_url"` // e.g. http://10.1.0.20:5000
|
||||||
TimeoutMs int `json:"timeout_ms"` // inspection timeout in milliseconds
|
HealthInterval int `json:"health_interval"` // seconds between health checks
|
||||||
|
TimeoutMs int `json:"timeout_ms"` // proxy timeout in ms
|
||||||
}
|
}
|
||||||
|
|
||||||
// configPath resolves to the config file next to the plugin binary.
|
|
||||||
func configPath() string {
|
func configPath() string {
|
||||||
exe, err := os.Executable()
|
exe, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -33,8 +33,9 @@ var (
|
||||||
func defaultConfig() Config {
|
func defaultConfig() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
NodeURL: "http://127.0.0.1:8080",
|
WAFURL: "http://127.0.0.1:8081",
|
||||||
ReturnPort: 9090,
|
BackendURL: "http://127.0.0.1:80",
|
||||||
|
HealthInterval: 5,
|
||||||
TimeoutMs: 3000,
|
TimeoutMs: 3000,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
health.go
Normal file
38
health.go
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startHealthCheck runs a periodic health check against the WAF.
|
||||||
|
// It updates the circuit breaker based on WAF reachability.
|
||||||
|
func startHealthCheck() {
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
cfg := getConfig()
|
||||||
|
interval := time.Duration(cfg.HealthInterval) * time.Second
|
||||||
|
if interval < 1*time.Second {
|
||||||
|
interval = 5 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 2 * time.Second}
|
||||||
|
resp, err := client.Head(cfg.WAFURL + "/")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WAF health check FAILED: %v", err)
|
||||||
|
breaker.RecordFailure()
|
||||||
|
} else {
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode < 500 {
|
||||||
|
breaker.RecordSuccess()
|
||||||
|
} else {
|
||||||
|
log.Printf("WAF health check: status %d", resp.StatusCode)
|
||||||
|
breaker.RecordFailure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(interval)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
62
main.go
62
main.go
|
|
@ -21,15 +21,14 @@ func main() {
|
||||||
Name: "Firewall",
|
Name: "Firewall",
|
||||||
Author: "Zoraxy Community",
|
Author: "Zoraxy Community",
|
||||||
AuthorContact: "",
|
AuthorContact: "",
|
||||||
Description: "Web Application Firewall with circuit breaker and fail-open protection.",
|
Description: "Inline WAF proxy with health checks and fail-open bypass.",
|
||||||
URL: "",
|
URL: "",
|
||||||
Type: zoraxy_plugin.PluginType_Router, // Type 0 — intercepts traffic
|
Type: zoraxy_plugin.PluginType_Router,
|
||||||
VersionMajor: 1,
|
VersionMajor: 1,
|
||||||
VersionMinor: 0,
|
VersionMinor: 0,
|
||||||
VersionPatch: 0,
|
VersionPatch: 0,
|
||||||
UIPath: "/ui",
|
UIPath: "/ui",
|
||||||
|
|
||||||
// Intercept ALL proxied requests.
|
|
||||||
StaticCapturePaths: []zoraxy_plugin.StaticCaptureRule{{CapturePath: "/"}},
|
StaticCapturePaths: []zoraxy_plugin.StaticCaptureRule{{CapturePath: "/"}},
|
||||||
StaticCaptureIngress: "/inspect",
|
StaticCaptureIngress: "/inspect",
|
||||||
}
|
}
|
||||||
|
|
@ -39,12 +38,15 @@ func main() {
|
||||||
log.Fatalf("failed to receive config: %v", err)
|
log.Fatalf("failed to receive config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start background health checker.
|
||||||
|
startHealthCheck()
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// --- Static capture handler (traffic interception) ---
|
// Static capture handler — all proxied traffic hits this.
|
||||||
mux.HandleFunc("/inspect", handleInspect)
|
mux.HandleFunc("/inspect", handleInspect)
|
||||||
|
|
||||||
// --- UI + config API ---
|
// UI + config API.
|
||||||
uiRouter := zoraxy_plugin.NewPluginEmbedUIRouter(
|
uiRouter := zoraxy_plugin.NewPluginEmbedUIRouter(
|
||||||
spec.ID, &webFS, "web", spec.UIPath,
|
spec.ID, &webFS, "web", spec.UIPath,
|
||||||
)
|
)
|
||||||
|
|
@ -53,27 +55,12 @@ func main() {
|
||||||
uiRouter.HandleFunc("/api/stats", handleGetStats, mux)
|
uiRouter.HandleFunc("/api/stats", handleGetStats, mux)
|
||||||
|
|
||||||
uiRouter.RegisterTerminateHandler(func() {
|
uiRouter.RegisterTerminateHandler(func() {
|
||||||
log.Println("zoraxy-waf shutting down")
|
log.Println("firewall shutting down")
|
||||||
}, mux)
|
}, mux)
|
||||||
uiRouter.AttachHandlerToMux(mux)
|
uiRouter.AttachHandlerToMux(mux)
|
||||||
|
|
||||||
// --- Return port listener for inspection endpoint async callbacks (future) ---
|
|
||||||
cfg := getConfig()
|
|
||||||
if cfg.ReturnPort > 0 {
|
|
||||||
go func() {
|
|
||||||
rmux := http.NewServeMux()
|
|
||||||
rmux.HandleFunc("/verdict", handleReturnVerdict)
|
|
||||||
addr := fmt.Sprintf(":%d", cfg.ReturnPort)
|
|
||||||
log.Printf("WAF return listener on %s", addr)
|
|
||||||
if err := http.ListenAndServe(addr, rmux); err != nil {
|
|
||||||
log.Printf("WAF return listener error: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Main plugin listener ---
|
|
||||||
addr := fmt.Sprintf("127.0.0.1:%d", config.Port)
|
addr := fmt.Sprintf("127.0.0.1:%d", config.Port)
|
||||||
log.Printf("zoraxy-waf 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 {
|
||||||
|
|
@ -81,11 +68,10 @@ func main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Config API handlers ---
|
// --- Config API ---
|
||||||
|
|
||||||
func handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
func handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
c := getConfig()
|
writeJSON(w, http.StatusOK, getConfig())
|
||||||
writeJSON(w, http.StatusOK, c)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSaveConfig(w http.ResponseWriter, r *http.Request) {
|
func handleSaveConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -96,14 +82,17 @@ func handleSaveConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
cfgMu.Lock()
|
cfgMu.Lock()
|
||||||
cfg.Enabled = r.FormValue("enabled") == "true"
|
cfg.Enabled = r.FormValue("enabled") == "true"
|
||||||
if u := r.FormValue("node_url"); u != "" {
|
if u := r.FormValue("waf_url"); u != "" {
|
||||||
cfg.NodeURL = u
|
cfg.WAFURL = u
|
||||||
}
|
}
|
||||||
if p := r.FormValue("return_port"); p != "" {
|
if u := r.FormValue("backend_url"); u != "" {
|
||||||
fmt.Sscanf(p, "%d", &cfg.ReturnPort)
|
cfg.BackendURL = u
|
||||||
}
|
}
|
||||||
if t := r.FormValue("timeout_ms"); t != "" {
|
if v := r.FormValue("health_interval"); v != "" {
|
||||||
fmt.Sscanf(t, "%d", &cfg.TimeoutMs)
|
fmt.Sscanf(v, "%d", &cfg.HealthInterval)
|
||||||
|
}
|
||||||
|
if v := r.FormValue("timeout_ms"); v != "" {
|
||||||
|
fmt.Sscanf(v, "%d", &cfg.TimeoutMs)
|
||||||
}
|
}
|
||||||
cfgMu.Unlock()
|
cfgMu.Unlock()
|
||||||
|
|
||||||
|
|
@ -119,17 +108,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) {
|
|
||||||
// Future: inspection endpoint async callbacks arrive here.
|
|
||||||
var resp InspectionResponse
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&resp); err != nil {
|
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad verdict"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("WAF async verdict: %s (reason=%s)", resp.Verdict, resp.Reason)
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
@ -11,12 +12,12 @@ import (
|
||||||
|
|
||||||
var breaker = newCircuitBreaker()
|
var breaker = newCircuitBreaker()
|
||||||
|
|
||||||
// Metrics counters
|
// Metrics
|
||||||
var (
|
var (
|
||||||
requestsTotal uint64
|
requestsTotal uint64
|
||||||
requestsAllowed uint64
|
requestsAllowed uint64
|
||||||
requestsBlocked uint64
|
requestsBlocked uint64
|
||||||
requestsBypassed uint64 // fail-open or disabled
|
requestsBypassed uint64
|
||||||
)
|
)
|
||||||
|
|
||||||
func metricsSnapshot() map[string]interface{} {
|
func metricsSnapshot() map[string]interface{} {
|
||||||
|
|
@ -42,13 +43,11 @@ func circuitStateStr(s CircuitState) string {
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleInspect is the static capture ingress handler.
|
// handleInspect is the static capture handler.
|
||||||
// Zoraxy proxies matching requests here. The plugin decides:
|
|
||||||
// - Return 280 (CAPTURED) → block the request
|
|
||||||
// - Return 284 (UNHANDLED) → forward as normal
|
|
||||||
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()
|
||||||
|
|
||||||
if !cfg.Enabled {
|
if !cfg.Enabled {
|
||||||
atomic.AddUint64(&requestsBypassed, 1)
|
atomic.AddUint64(&requestsBypassed, 1)
|
||||||
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED))
|
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED))
|
||||||
|
|
@ -57,32 +56,46 @@ func handleInspect(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
state := breaker.State()
|
state := breaker.State()
|
||||||
if state == StateOpen {
|
if state == StateOpen {
|
||||||
|
// WAF is down — bypass directly to backend.
|
||||||
atomic.AddUint64(&requestsBypassed, 1)
|
atomic.AddUint64(&requestsBypassed, 1)
|
||||||
log.Printf("WAF: breaker OPEN — fail-open, passing %s %s", r.Method, r.URL.Path)
|
log.Printf("Firewall: breaker OPEN — bypassing %s %s", r.Method, r.URL.Path)
|
||||||
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED))
|
bypassToBackend(w, r)
|
||||||
|
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_CAPTURED))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := inspect(r)
|
// Proxy through WAF.
|
||||||
|
resp, err := proxyThroughWAF(w, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
atomic.AddUint64(&requestsBypassed, 1)
|
atomic.AddUint64(&requestsBypassed, 1)
|
||||||
log.Printf("WAF: inspection error: %v", err)
|
log.Printf("Firewall: WAF unreachable — bypassing %s %s: %v", r.Method, r.URL.Path, err)
|
||||||
breaker.RecordFailure()
|
breaker.RecordFailure()
|
||||||
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED))
|
bypassToBackend(w, r)
|
||||||
|
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_CAPTURED))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
breaker.RecordSuccess()
|
breaker.RecordSuccess()
|
||||||
|
|
||||||
if resp.Verdict == "block" {
|
if resp.StatusCode == 403 {
|
||||||
atomic.AddUint64(&requestsBlocked, 1)
|
atomic.AddUint64(&requestsBlocked, 1)
|
||||||
log.Printf("WAF: BLOCKED %s %s — reason: %s", r.Method, r.URL.Path, resp.Reason)
|
log.Printf("Firewall: BLOCKED %s %s", r.Method, r.URL.Path)
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_CAPTURED))
|
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_CAPTURED))
|
||||||
fmt.Fprintf(w, `<html><body><h1>403 Forbidden</h1><p>Request blocked by WAF.</p></body></html>`)
|
io.Copy(w, resp.Body)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pass-through: copy the WAF's response back.
|
||||||
atomic.AddUint64(&requestsAllowed, 1)
|
atomic.AddUint64(&requestsAllowed, 1)
|
||||||
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED))
|
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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
83
proxy.go
Normal file
83
proxy.go
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"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) {
|
||||||
|
cfg := getConfig()
|
||||||
|
|
||||||
|
target, err := url.Parse(cfg.WAFURL)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// InspectionRequest is sent to the inspection endpoint.
|
|
||||||
type InspectionRequest struct {
|
|
||||||
Method string `json:"method"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Host string `json:"host"`
|
|
||||||
RemoteAddr string `json:"remote_addr"`
|
|
||||||
ContentType string `json:"content_type"`
|
|
||||||
ContentLength int64 `json:"content_length"`
|
|
||||||
Headers map[string][]string `json:"headers"`
|
|
||||||
BodySample []byte `json:"body_sample"` // first N bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
// InspectionResponse is returned by the inspection endpoint.
|
|
||||||
type InspectionResponse struct {
|
|
||||||
Verdict string `json:"verdict"` // "allow" or "block"
|
|
||||||
Reason string `json:"reason,omitempty"`
|
|
||||||
Score int `json:"score,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxBodySample = 4096 // only send first 4KB to inspection endpoint
|
|
||||||
|
|
||||||
// inspect sends a synchronous inspection request to the inspection endpoint.
|
|
||||||
// Returns nil if allowed, or an error describing the block reason.
|
|
||||||
func inspect(r *http.Request) (*InspectionResponse, error) {
|
|
||||||
c := getConfig()
|
|
||||||
|
|
||||||
// Sample the first N bytes of the body.
|
|
||||||
var bodySample []byte
|
|
||||||
if r.Body != nil {
|
|
||||||
bodySample = make([]byte, maxBodySample)
|
|
||||||
n, _ := io.ReadFull(r.Body, bodySample)
|
|
||||||
bodySample = bodySample[:n]
|
|
||||||
// Restore body for downstream use.
|
|
||||||
r.Body = io.NopCloser(io.MultiReader(
|
|
||||||
bytes.NewReader(bodySample),
|
|
||||||
r.Body,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
inspReq := InspectionRequest{
|
|
||||||
Method: r.Method,
|
|
||||||
URL: r.URL.String(),
|
|
||||||
Host: r.Host,
|
|
||||||
RemoteAddr: r.RemoteAddr,
|
|
||||||
ContentType: r.Header.Get("Content-Type"),
|
|
||||||
ContentLength: r.ContentLength,
|
|
||||||
Headers: map[string][]string(r.Header),
|
|
||||||
BodySample: bodySample,
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(inspReq)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("marshal inspection request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
timeout := time.Duration(c.TimeoutMs) * time.Millisecond
|
|
||||||
client := &http.Client{Timeout: timeout}
|
|
||||||
|
|
||||||
resp, err := client.Post(c.NodeURL+"/inspect", "application/json", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("inspection node unreachable: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var inspResp InspectionResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&inspResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("decode inspection response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("WAF: %s %s → %s (score=%d reason=%s)",
|
|
||||||
r.Method, r.URL.Path, inspResp.Verdict, inspResp.Score, inspResp.Reason)
|
|
||||||
|
|
||||||
return &inspResp, nil
|
|
||||||
}
|
|
||||||
20
web/app.js
20
web/app.js
|
|
@ -2,10 +2,11 @@
|
||||||
|
|
||||||
function loadConfig() {
|
function loadConfig() {
|
||||||
$.get('./api/config', function (data) {
|
$.get('./api/config', function (data) {
|
||||||
$('#waf-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');
|
||||||
$('#node-url').val(data.node_url);
|
$('#waf-url').val(data.waf_url);
|
||||||
$('#return-port').val(data.return_port);
|
$('#backend-url').val(data.backend_url);
|
||||||
|
$('#health-interval').val(data.health_interval);
|
||||||
$('#timeout-ms').val(data.timeout_ms);
|
$('#timeout-ms').val(data.timeout_ms);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -14,7 +15,7 @@ function loadStats() {
|
||||||
$.get('./api/stats', function (data) {
|
$.get('./api/stats', function (data) {
|
||||||
$('#circuit-state').text(data.circuit).attr('class', 'stat-value ' +
|
$('#circuit-state').text(data.circuit).attr('class', 'stat-value ' +
|
||||||
(data.circuit === 'open' ? 'warn' : data.circuit === 'closed' ? 'ok' : 'warn'));
|
(data.circuit === 'open' ? 'warn' : data.circuit === 'closed' ? 'ok' : 'warn'));
|
||||||
$('#waf-status').text(data.enabled ? 'Active' : 'Disabled').attr('class', 'stat-value ' +
|
$('#fw-status').text(data.enabled ? 'Active' : 'Disabled').attr('class', 'stat-value ' +
|
||||||
(data.enabled ? 'ok' : 'off'));
|
(data.enabled ? 'ok' : 'off'));
|
||||||
$('#stat-total').text(data.total || 0);
|
$('#stat-total').text(data.total || 0);
|
||||||
$('#stat-allowed').text(data.allowed || 0);
|
$('#stat-allowed').text(data.allowed || 0);
|
||||||
|
|
@ -29,9 +30,10 @@ function saveConfig() {
|
||||||
url: './api/config/save',
|
url: './api/config/save',
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
data: {
|
data: {
|
||||||
enabled: $('#waf-enabled').is(':checked'),
|
enabled: $('#fw-enabled').is(':checked'),
|
||||||
node_url: $('#node-url').val().trim(),
|
waf_url: $('#waf-url').val().trim(),
|
||||||
return_port: $('#return-port').val(),
|
backend_url: $('#backend-url').val().trim(),
|
||||||
|
health_interval: $('#health-interval').val(),
|
||||||
timeout_ms: $('#timeout-ms').val()
|
timeout_ms: $('#timeout-ms').val()
|
||||||
},
|
},
|
||||||
success: function () {
|
success: function () {
|
||||||
|
|
@ -46,7 +48,7 @@ function saveConfig() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#waf-enabled').on('change', function () {
|
$('#fw-enabled').on('change', function () {
|
||||||
$('#enabled-label').text(this.checked ? 'Firewall Enabled' : 'Firewall Disabled');
|
$('#enabled-label').text(this.checked ? 'Firewall Enabled' : 'Firewall Disabled');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -55,5 +57,5 @@ $('#save-btn').on('click', saveConfig);
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
loadConfig();
|
loadConfig();
|
||||||
loadStats();
|
loadStats();
|
||||||
setInterval(loadStats, 10000); // refresh stats every 10s
|
setInterval(loadStats, 5000);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>Firewall</h1>
|
<h1>Firewall</h1>
|
||||||
<p>Web Application Firewall — inspect traffic against a remote inspection engine.</p>
|
<p>Inline WAF proxy with health checks and fail-open bypass.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
|
@ -19,24 +19,31 @@
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="toggle">
|
<label class="toggle">
|
||||||
<input type="checkbox" id="waf-enabled">
|
<input type="checkbox" id="fw-enabled">
|
||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
<span id="enabled-label">Firewall Disabled</span>
|
<span id="enabled-label">Firewall Disabled</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="node-url">Inspection Endpoint URL</label>
|
<label for="waf-url">WAF Upstream URL</label>
|
||||||
<input type="text" id="node-url" placeholder="http://192.168.1.50:8080">
|
<input type="text" id="waf-url" placeholder="http://127.0.0.1:8081">
|
||||||
|
<small>Requests are proxied through this WAF for inspection.</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="return-port">Return Port (async callbacks)</label>
|
<label for="backend-url">Backend Fallback URL</label>
|
||||||
<input type="number" id="return-port" placeholder="9090" min="1" max="65535">
|
<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">
|
||||||
<label for="timeout-ms">Inspection Timeout (ms)</label>
|
<label for="health-interval">Health Check Interval (s)</label>
|
||||||
|
<input type="number" id="health-interval" placeholder="5" min="1" max="60">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="timeout-ms">Proxy Timeout (ms)</label>
|
||||||
<input type="number" id="timeout-ms" placeholder="3000" min="100" max="30000">
|
<input type="number" id="timeout-ms" placeholder="3000" min="100" max="30000">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -52,7 +59,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label">Status:</span>
|
<span class="stat-label">Status:</span>
|
||||||
<span id="waf-status" class="stat-value">--</span>
|
<span id="fw-status" class="stat-value">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label">Total Inspected:</span>
|
<span class="stat-label">Total Inspected:</span>
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,13 @@ section {
|
||||||
box-shadow: 0 0 0 2px rgba(9,132,227,0.15);
|
box-shadow: 0 0 0 2px rgba(9,132,227,0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field small {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #b2bec3;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Toggle switch */
|
/* Toggle switch */
|
||||||
.toggle {
|
.toggle {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue