diff --git a/circuit_breaker.go b/circuit_breaker.go new file mode 100644 index 0000000..794ad52 --- /dev/null +++ b/circuit_breaker.go @@ -0,0 +1,76 @@ +package main + +import ( + "sync" + "time" +) + +// CircuitState represents the breaker's current state. +type CircuitState int + +const ( + StateClosed CircuitState = iota // normal — requests pass through + StateOpen // tripped — fail-open + StateHalfOpen // testing recovery +) + +// CircuitBreaker implements a simple fail-open pattern. +// When failures exceed the threshold within the window, the breaker opens. +// After a cooldown, it moves to half-open to test recovery. +type CircuitBreaker struct { + mu sync.Mutex + state CircuitState + failures int + lastFailure time.Time + maxFailures int + window time.Duration + cooldown time.Duration + openedAt time.Time +} + +func newCircuitBreaker() *CircuitBreaker { + return &CircuitBreaker{ + state: StateClosed, + maxFailures: 5, + window: 30 * time.Second, + cooldown: 10 * time.Second, + } +} + +// state returns the current state, testing for recovery if half-open and cooldown elapsed. +func (cb *CircuitBreaker) State() CircuitState { + cb.mu.Lock() + defer cb.mu.Unlock() + + if cb.state == StateOpen && time.Since(cb.openedAt) > cb.cooldown { + cb.state = StateHalfOpen + } + // Reset failure count if window elapsed. + if time.Since(cb.lastFailure) > cb.window { + cb.failures = 0 + if cb.state == StateHalfOpen { + cb.state = StateClosed + } + } + return cb.state +} + +func (cb *CircuitBreaker) RecordSuccess() { + cb.mu.Lock() + defer cb.mu.Unlock() + if cb.state == StateHalfOpen { + cb.state = StateClosed + } + cb.failures = 0 +} + +func (cb *CircuitBreaker) RecordFailure() { + cb.mu.Lock() + defer cb.mu.Unlock() + cb.failures++ + cb.lastFailure = time.Now() + if cb.failures >= cb.maxFailures { + cb.state = StateOpen + cb.openedAt = time.Now() + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..9e85b3d --- /dev/null +++ b/config.go @@ -0,0 +1,65 @@ +package main + +import ( + "encoding/json" + "log" + "os" + "sync" +) + +// Config holds all WAF plugin settings, persisted as JSON. +type Config struct { + Enabled bool `json:"enabled"` + NodeURL string `json:"node_url"` // e.g. http://192.168.1.50:8080 + ReturnPort int `json:"return_port"` // port for Wallarm async callbacks + TimeoutMs int `json:"timeout_ms"` // inspection timeout in milliseconds +} + +const configFile = "waf_config.json" + +var ( + cfg Config + cfgMu sync.RWMutex +) + +func defaultConfig() Config { + return Config{ + Enabled: false, + NodeURL: "http://127.0.0.1:8080", + ReturnPort: 9090, + TimeoutMs: 3000, + } +} + +func loadConfig() { + cfgMu.Lock() + defer cfgMu.Unlock() + + f, err := os.Open(configFile) + if err != nil { + cfg = defaultConfig() + return + } + defer f.Close() + + if err := json.NewDecoder(f).Decode(&cfg); err != nil { + log.Printf("WARN: corrupt config, using defaults: %v", err) + cfg = defaultConfig() + } +} + +func saveConfig() error { + cfgMu.RLock() + data, err := json.MarshalIndent(cfg, "", " ") + cfgMu.RUnlock() + if err != nil { + return err + } + return os.WriteFile(configFile, data, 0644) +} + +func getConfig() Config { + cfgMu.RLock() + defer cfgMu.RUnlock() + return cfg +} diff --git a/main.go b/main.go index 5670f40..ef9b329 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "embed" + "encoding/json" "fmt" "log" "net/http" @@ -13,18 +14,24 @@ import ( var webFS embed.FS func main() { + loadConfig() + spec := &zoraxy_plugin.IntroSpect{ ID: "zoraxy-waf", Name: "Zoraxy WAF", Author: "Zoraxy Community", AuthorContact: "", - Description: "Web Application Firewall plugin for Zoraxy.", + Description: "Web Application Firewall — synchronous Wallarm inspection with circuit breaker and fail-open protection.", URL: "", - Type: zoraxy_plugin.PluginType_Utilities, + Type: zoraxy_plugin.PluginType_Router, // Type 0 — intercepts traffic VersionMajor: 1, VersionMinor: 0, VersionPatch: 0, UIPath: "/ui", + + // Intercept ALL proxied requests. + StaticCapturePaths: []zoraxy_plugin.StaticCaptureRule{{CapturePath: "/"}}, + StaticCaptureIngress: "/inspect", } config, err := zoraxy_plugin.ServeAndRecvSpec(spec) @@ -34,22 +41,37 @@ func main() { mux := http.NewServeMux() - uiRouter := zoraxy_plugin.NewPluginEmbedUIRouter( - spec.ID, - &webFS, - "web", - spec.UIPath, - ) + // --- Static capture handler (traffic interception) --- + mux.HandleFunc("/inspect", handleInspect) - // Register API endpoints here. - // Example: uiRouter.HandleFunc("/api/status", handleStatus, mux) + // --- UI + config API --- + 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) uiRouter.RegisterTerminateHandler(func() { log.Println("zoraxy-waf shutting down") }, mux) - uiRouter.AttachHandlerToMux(mux) + // --- Return port listener for Wallarm 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) log.Printf("zoraxy-waf v%d.%d.%d listening on %s", spec.VersionMajor, spec.VersionMinor, spec.VersionPatch, addr) @@ -58,3 +80,73 @@ func main() { log.Fatalf("server error: %v", err) } } + +// --- Config API handlers --- + +func handleGetConfig(w http.ResponseWriter, r *http.Request) { + c := getConfig() + writeJSON(w, http.StatusOK, c) +} + +func handleSaveConfig(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid form"}) + return + } + + cfgMu.Lock() + cfg.Enabled = r.FormValue("enabled") == "true" + if u := r.FormValue("node_url"); u != "" { + cfg.NodeURL = u + } + if p := r.FormValue("return_port"); p != "" { + fmt.Sscanf(p, "%d", &cfg.ReturnPort) + } + if t := r.FormValue("timeout_ms"); t != "" { + fmt.Sscanf(t, "%d", &cfg.TimeoutMs) + } + cfgMu.Unlock() + + if err := saveConfig(); err != nil { + log.Printf("ERROR saving config: %v", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "save failed"}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "saved"}) +} + +func handleGetStats(w http.ResponseWriter, r *http.Request) { + state := breaker.State() + var stateStr string + switch state { + case StateClosed: + stateStr = "closed" + case StateOpen: + stateStr = "open" + case StateHalfOpen: + stateStr = "half-open" + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "circuit": stateStr, + "enabled": getConfig().Enabled, + }) +} + +func handleReturnVerdict(w http.ResponseWriter, r *http.Request) { + // Future: Wallarm 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 --- + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..54ffd93 --- /dev/null +++ b/middleware.go @@ -0,0 +1,55 @@ +package main + +import ( + "fmt" + "log" + "net/http" + + "zoraxy-waf/zoraxy_plugin" +) + +var breaker = newCircuitBreaker() + +// handleInspect is the static capture ingress 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) { + cfg := getConfig() + if !cfg.Enabled { + // WAF disabled — pass through. + w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED)) + return + } + + state := breaker.State() + if state == StateOpen { + // Fail-open: Wallarm is down, let traffic through. + log.Printf("WAF: breaker OPEN — fail-open, passing %s %s", r.Method, r.URL.Path) + w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED)) + return + } + + resp, err := inspect(r) + if err != nil { + log.Printf("WAF: inspection error: %v", err) + breaker.RecordFailure() + // Fail-open on error. + w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED)) + return + } + + breaker.RecordSuccess() + + if resp.Verdict == "block" { + log.Printf("WAF: BLOCKED %s %s — reason: %s", r.Method, r.URL.Path, resp.Reason) + // Return the Zoraxy control code for "captured" (blocked). + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_CAPTURED)) + fmt.Fprintf(w, `

403 Forbidden

Request blocked by WAF.

`) + return + } + + // Allowed — let Zoraxy forward normally. + w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED)) +} diff --git a/models.go b/models.go deleted file mode 100644 index 28677a9..0000000 --- a/models.go +++ /dev/null @@ -1,3 +0,0 @@ -package main - -// Data models go here. diff --git a/server.go b/server.go deleted file mode 100644 index bbee632..0000000 --- a/server.go +++ /dev/null @@ -1,3 +0,0 @@ -package main - -// HTTP handlers and core logic go here. diff --git a/wallarm_client.go b/wallarm_client.go new file mode 100644 index 0000000..728d42e --- /dev/null +++ b/wallarm_client.go @@ -0,0 +1,86 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" +) + +// InspectionRequest is sent to the Wallarm node. +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 Wallarm node. +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 Wallarm + +// inspect sends a synchronous inspection request to the Wallarm node. +// 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("wallarm 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 wallarm 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 +} diff --git a/web/app.js b/web/app.js index 39d88b5..9c1fd10 100644 --- a/web/app.js +++ b/web/app.js @@ -1,4 +1,55 @@ -// Zoraxy WAF — client-side logic -$(document).ready(function () { - console.log('Zoraxy WAF plugin loaded'); +// Zoraxy WAF — config panel + +function loadConfig() { + $.get('./api/config', function (data) { + $('#waf-enabled').prop('checked', data.enabled); + $('#enabled-label').text(data.enabled ? 'WAF Enabled' : 'WAF Disabled'); + $('#node-url').val(data.node_url); + $('#return-port').val(data.return_port); + $('#timeout-ms').val(data.timeout_ms); + }); +} + +function loadStats() { + $.get('./api/stats', function (data) { + $('#circuit-state').text(data.circuit).attr('class', 'stat-value ' + + (data.circuit === 'open' ? 'warn' : data.circuit === 'closed' ? 'ok' : 'warn')); + $('#waf-status').text(data.enabled ? 'Active' : 'Disabled').attr('class', 'stat-value ' + + (data.enabled ? 'ok' : 'off')); + }); +} + +function saveConfig() { + $('#save-status').text('Saving...'); + $.cjax({ + url: './api/config/save', + type: 'POST', + data: { + enabled: $('#waf-enabled').is(':checked'), + node_url: $('#node-url').val().trim(), + return_port: $('#return-port').val(), + timeout_ms: $('#timeout-ms').val() + }, + success: function () { + $('#save-status').text('Saved ✓'); + loadConfig(); + loadStats(); + setTimeout(function () { $('#save-status').text(''); }, 2000); + }, + error: function () { + $('#save-status').css('color', '#d63031').text('Save failed'); + } + }); +} + +$('#waf-enabled').on('change', function () { + $('#enabled-label').text(this.checked ? 'WAF Enabled' : 'WAF Disabled'); +}); + +$('#save-btn').on('click', saveConfig); + +$(document).ready(function () { + loadConfig(); + loadStats(); + setInterval(loadStats, 10000); // refresh stats every 10s }); diff --git a/web/index.html b/web/index.html index f1e81d7..3f8b979 100644 --- a/web/index.html +++ b/web/index.html @@ -10,11 +10,53 @@

Zoraxy WAF

-

Web Application Firewall — coming soon.

+

Web Application Firewall with Wallarm inspection.

+
-

Configure firewall rules, rate limits, and IP blocking from this panel.

+
+

Configuration

+ +
+ + WAF Disabled +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + +
+ +
+

Status

+
+ Circuit Breaker: + -- +
+
+ WAF Status: + -- +
+
+ diff --git a/web/style.css b/web/style.css index 0228866..d524464 100644 --- a/web/style.css +++ b/web/style.css @@ -10,9 +10,119 @@ body { color: #1a1a2e; background: #f5f6fa; padding: 20px; - max-width: 960px; + max-width: 720px; margin: 0 auto; } -h1 { font-size: 20px; margin-bottom: 8px; } -p { color: #636e72; } +h1 { font-size: 20px; margin-bottom: 4px; } +header p { color: #636e72; margin-bottom: 20px; } +h2 { font-size: 16px; margin: 20px 0 12px; border-bottom: 1px solid #eee; padding-bottom: 6px; } + +section { + background: #fff; + border-radius: 6px; + padding: 16px 20px; + margin-bottom: 16px; + box-shadow: 0 1px 3px rgba(0,0,0,0.08); +} + +.field { + margin-bottom: 12px; +} + +.field label { + display: block; + font-size: 12px; + font-weight: 600; + color: #636e72; + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.field input[type="text"], +.field input[type="number"] { + width: 100%; + padding: 8px 10px; + border: 1px solid #dcdde1; + border-radius: 4px; + font-size: 14px; +} + +.field input:focus { + outline: none; + border-color: #0984e3; + box-shadow: 0 0 0 2px rgba(9,132,227,0.15); +} + +/* Toggle switch */ +.toggle { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + vertical-align: middle; + margin-right: 10px; +} + +.toggle input { display: none; } + +.slider { + position: absolute; + cursor: pointer; + top: 0; left: 0; right: 0; bottom: 0; + background: #dcdde1; + border-radius: 24px; + transition: 0.2s; +} + +.slider::before { + content: ""; + position: absolute; + height: 18px; width: 18px; + left: 3px; bottom: 3px; + background: #fff; + border-radius: 50%; + transition: 0.2s; +} + +.toggle input:checked + .slider { background: #00b894; } +.toggle input:checked + .slider::before { transform: translateX(20px); } + +#enabled-label { + display: inline-block; + vertical-align: middle; + font-weight: 600; + font-size: 14px; +} + +.btn { + padding: 8px 18px; + border: none; + border-radius: 4px; + font-size: 13px; + cursor: pointer; + font-weight: 600; +} + +.btn-primary { background: #0984e3; color: #fff; } +.btn-primary:hover { background: #0773c5; } + +#save-status { + margin-left: 10px; + font-size: 13px; + color: #00b894; +} + +.stat-row { + display: flex; + justify-content: space-between; + padding: 6px 0; + border-bottom: 1px solid #f0f0f5; +} +.stat-row:last-child { border-bottom: none; } +.stat-label { color: #636e72; } +.stat-value { font-weight: 600; } +.stat-value.ok { color: #00b894; } +.stat-value.warn { color: #e17055; } +.stat-value.off { color: #b2bec3; }