feat: Phase 1 — Type 0 Router with synchronous Wallarm inspection, circuit breaker, config UI

This commit is contained in:
Claus Lohmar 2026-08-01 06:20:50 +00:00
parent 9944009ada
commit 85d90bef1e
10 changed files with 596 additions and 25 deletions

76
circuit_breaker.go Normal file
View file

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

65
config.go Normal file
View file

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

114
main.go
View file

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

55
middleware.go Normal file
View file

@ -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, `<html><body><h1>403 Forbidden</h1><p>Request blocked by WAF.</p></body></html>`)
return
}
// Allowed — let Zoraxy forward normally.
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED))
}

View file

@ -1,3 +0,0 @@
package main
// Data models go here.

View file

@ -1,3 +0,0 @@
package main
// HTTP handlers and core logic go here.

86
wallarm_client.go Normal file
View file

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

View file

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

View file

@ -10,11 +10,53 @@
<body>
<header>
<h1>Zoraxy WAF</h1>
<p>Web Application Firewall — coming soon.</p>
<p>Web Application Firewall with Wallarm inspection.</p>
</header>
<main>
<p>Configure firewall rules, rate limits, and IP blocking from this panel.</p>
<section class="config-panel">
<h2>Configuration</h2>
<div class="field">
<label class="toggle">
<input type="checkbox" id="waf-enabled">
<span class="slider"></span>
</label>
<span id="enabled-label">WAF Disabled</span>
</div>
<div class="field">
<label for="node-url">Wallarm Node URL</label>
<input type="text" id="node-url" placeholder="http://192.168.1.50:8080">
</div>
<div class="field">
<label for="return-port">Return Port (async callbacks)</label>
<input type="number" id="return-port" placeholder="9090" min="1" max="65535">
</div>
<div class="field">
<label for="timeout-ms">Inspection Timeout (ms)</label>
<input type="number" id="timeout-ms" placeholder="3000" min="100" max="30000">
</div>
<button id="save-btn" class="btn btn-primary">Save Configuration</button>
<span id="save-status"></span>
</section>
<section class="stats-panel">
<h2>Status</h2>
<div class="stat-row">
<span class="stat-label">Circuit Breaker:</span>
<span id="circuit-state" class="stat-value">--</span>
</div>
<div class="stat-row">
<span class="stat-label">WAF Status:</span>
<span id="waf-status" class="stat-value">--</span>
</div>
</section>
</main>
<script src="/script/jquery-3.6.0.min.js"></script>
<script src="/script/utils.js"></script>
<script src="./app.js"></script>

View file

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