mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 19:43:27 +00:00
Compare commits
8 Commits
e5f0d22dc6
...
c8fe9bb148
| Author | SHA1 | Date | |
|---|---|---|---|
| c8fe9bb148 | |||
| e87d46f6a7 | |||
| efac8cec16 | |||
| 9561cb4bdb | |||
| 344bb0738d | |||
| b54b5383cd | |||
| 23788e304b | |||
| 3b28f84591 |
|
|
@ -557,9 +557,15 @@ async def health():
|
|||
capture_output=True, text=True, timeout=3
|
||||
)
|
||||
nft_output = result.stdout if result.returncode == 0 else ""
|
||||
checks["nftables_crowdsec"] = "ip crowdsec" in nft_output
|
||||
checks["nftables_crowdsec6"] = "ip6 crowdsec6" in nft_output
|
||||
checks["nftables_ok"] = checks["nftables_crowdsec"] and checks["nftables_crowdsec6"]
|
||||
# The SecuBox firewall-bouncer uses a CUSTOM table name (inet
|
||||
# secubox_blacklist), not the upstream default `ip crowdsec` / `ip6
|
||||
# crowdsec6` — so the legacy probe always missed it and reported the
|
||||
# firewall "not OK" even though it was active. Detect both the custom and
|
||||
# the default names, and base nftables_ok on the GENERAL SecuBox firewall
|
||||
# being loaded (inet filter / secubox_blacklist), not on the IPv6 anchor.
|
||||
checks["nftables_crowdsec"] = ("ip crowdsec" in nft_output) or ("secubox_blacklist" in nft_output)
|
||||
checks["nftables_crowdsec6"] = ("ip6 crowdsec6" in nft_output) or ("crowdsec6" in nft_output)
|
||||
checks["nftables_ok"] = ("inet filter" in nft_output) or ("secubox_blacklist" in nft_output)
|
||||
except Exception as e:
|
||||
log.warning("nftables check failed: %s", e)
|
||||
checks["nftables_ok"] = False
|
||||
|
|
|
|||
185
packages/secubox-toolbox-ng/cmd/sbxwaf/injectwidget.go
Normal file
185
packages/secubox-toolbox-ng/cmd/sbxwaf/injectwidget.go
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
// Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
// See LICENCE-CMSD-1.0.md for terms.
|
||||
|
||||
// SecuBox-Deb :: sbxwaf :: WAF-injected health/visit widget (#747)
|
||||
//
|
||||
// On OUR OWN sites (the operator-configured host suffixes), the WAF injects a
|
||||
// tiny "health widget" footer badge into the HTML it serves — a discreet
|
||||
// SecuBox-protected mark carrying the live visit counter. It is the WAF analogue
|
||||
// of the toolbox transparency banner, but for first-party sites: "this site is
|
||||
// behind the SecuBox WAF, and here is its visit count".
|
||||
//
|
||||
// Injection is decompression-aware (gzip/br/zstd via internal/httpcodec),
|
||||
// idempotent (a guard marker), and STRICTLY fail-open: any decode/encode failure
|
||||
// or a missing </body> returns the original bytes untouched — a widget is never
|
||||
// worth breaking a page. Only text/html responses on configured hosts are touched.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/CyberMind-FR/secubox-deb/secubox-toolbox-ng/internal/httpcodec"
|
||||
)
|
||||
|
||||
// widgetMaxBody caps how large an HTML response we will buffer to inject into.
|
||||
// Larger HTML pages (rare) are passed through untouched — never worth the memory.
|
||||
const widgetMaxBody = 4 << 20 // 4 MiB
|
||||
|
||||
// applyWidget injects the SecuBox health banner loader into an upstream HTML
|
||||
// response when (a) injection is enabled (origin + hosts non-empty), (b) the
|
||||
// request host matches a configured first-party suffix, and (c) the response is
|
||||
// text/html under the size cap. STRICTLY fail-open: any issue leaves the response
|
||||
// byte-identical. Called from the reverse-proxy ModifyResponse hook.
|
||||
func applyWidget(resp *http.Response, host string, origin string, hosts []string) {
|
||||
if origin == "" || len(hosts) == 0 || resp == nil || resp.Body == nil {
|
||||
return
|
||||
}
|
||||
if !widgetHostMatch(host, hosts) || !isHTMLResponse(resp.Header.Get("Content-Type")) {
|
||||
return
|
||||
}
|
||||
// Don't try to inject into a body we won't fully buffer.
|
||||
if resp.ContentLength > widgetMaxBody {
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, widgetMaxBody+1))
|
||||
resp.Body.Close()
|
||||
if err != nil || int64(len(body)) > widgetMaxBody {
|
||||
// Restore whatever we read so the client still gets the bytes, fail-open.
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return
|
||||
}
|
||||
out, ok := injectWidgetBody(body, resp.Header.Get("Content-Encoding"), origin)
|
||||
if !ok {
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return
|
||||
}
|
||||
resp.Body = io.NopCloser(bytes.NewReader(out))
|
||||
resp.ContentLength = int64(len(out))
|
||||
resp.Header.Set("Content-Length", strconv.Itoa(len(out)))
|
||||
}
|
||||
|
||||
// widgetGuard marks an already-injected document so a re-proxied response is not
|
||||
// double-stamped.
|
||||
const widgetGuard = "sbxwaf-health-banner-loader"
|
||||
|
||||
// healthBannerSnippet emits the loader for the SHARED SecuBox health banner
|
||||
// (shared/health-banner.js) in its CDN-injected mode: it points the banner's APIs
|
||||
// + asset at the canonical Hub origin (absolute URLs) so the SAME health widget
|
||||
// the dashboard shows also mounts on first-party content sites. The banner script
|
||||
// self-guards against double-init (window.__SBX_HEALTH_BANNER__); IS_CDN_INJECTED
|
||||
// becomes true because window.SECUBOX_HEALTH_API is set.
|
||||
func healthBannerSnippet(origin string) string {
|
||||
o := strings.TrimRight(origin, "/")
|
||||
return `<script id="` + widgetGuard + `">(function(){` +
|
||||
`if(window.__SBX_HEALTH_BANNER__)return;` +
|
||||
`var O=` + jsString(o) + `;` +
|
||||
`window.SECUBOX_HEALTH_API=O+'/api/v1/metrics/health/summary';` +
|
||||
`window.SECUBOX_VISITOR_ORIGIN_API=O+'/api/v1/metrics/visitor-origin';` +
|
||||
`window.SECUBOX_LIVE_HOSTS_API=O+'/api/v1/metrics/live-hosts';` +
|
||||
`window.SECUBOX_CERT_STATUS_API=O+'/api/v1/metrics/cert-status';` +
|
||||
`window.SECUBOX_COOKIE_AUDIT_SUMMARY=O+'/api/v1/cookie-audit/summary';` +
|
||||
`var s=document.createElement('script');s.src=O+'/shared/health-banner.js';s.async=true;` +
|
||||
`document.body.appendChild(s);})();</script>`
|
||||
}
|
||||
|
||||
// jsString returns a safe single-quoted JS string literal for s (escapes the few
|
||||
// metacharacters that matter inside '...'); origins are operator-config hostnames
|
||||
// so this is belt-and-braces, not untrusted input.
|
||||
func jsString(s string) string {
|
||||
r := strings.NewReplacer(`\`, `\\`, `'`, `\'`, "\n", `\n`, "\r", `\r`, "<", `\x3c`)
|
||||
return "'" + r.Replace(s) + "'"
|
||||
}
|
||||
|
||||
// injectWidgetHTML inserts the health-banner loader just before the closing
|
||||
// </body> tag of a decompressed HTML document. Returns the original bytes
|
||||
// unchanged when there is no </body>, the loader was already injected, OR the
|
||||
// page ALREADY ships the health banner itself (a dashboard page) — so we never
|
||||
// double-mount it.
|
||||
func injectWidgetHTML(plain []byte, origin string) []byte {
|
||||
if bytes.Contains(plain, []byte(widgetGuard)) ||
|
||||
bytes.Contains(plain, []byte("health-banner.js")) ||
|
||||
bytes.Contains(plain, []byte("__SBX_HEALTH_BANNER__")) {
|
||||
return plain // already has the banner (loader or first-party include)
|
||||
}
|
||||
// Case-insensitive search for the LAST </body>.
|
||||
low := bytes.ToLower(plain)
|
||||
idx := bytes.LastIndex(low, []byte("</body>"))
|
||||
if idx < 0 {
|
||||
return plain // no body close → nothing safe to do
|
||||
}
|
||||
snippet := []byte(healthBannerSnippet(origin))
|
||||
out := make([]byte, 0, len(plain)+len(snippet))
|
||||
out = append(out, plain[:idx]...)
|
||||
out = append(out, snippet...)
|
||||
out = append(out, plain[idx:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// injectWidgetBody decompresses (per Content-Encoding), injects the widget, and
|
||||
// re-encodes in the SAME codec. Fail-open on any error. Returns (out, true) when
|
||||
// the body was rewritten, (body, false) otherwise — the caller updates
|
||||
// Content-Length to len(out) only when ok.
|
||||
func injectWidgetBody(body []byte, encoding string, origin string) (out []byte, ok bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||
case "":
|
||||
inj := injectWidgetHTML(body, origin)
|
||||
return inj, len(inj) != len(body)
|
||||
case "gzip", "br", "zstd":
|
||||
plain, err := httpcodec.Decode(encoding, body)
|
||||
if err != nil {
|
||||
return body, false // fail open: serve the original compressed bytes
|
||||
}
|
||||
inj := injectWidgetHTML(plain, origin)
|
||||
if len(inj) == len(plain) {
|
||||
return body, false // nothing injected → keep original (avoid re-encode churn)
|
||||
}
|
||||
reenc, err := httpcodec.Encode(encoding, inj)
|
||||
if err != nil {
|
||||
return body, false // never serve a truncated frame
|
||||
}
|
||||
return reenc, true
|
||||
default:
|
||||
return body, false // unknown encoding we cannot decode → pass through
|
||||
}
|
||||
}
|
||||
|
||||
// isHTMLResponse reports whether a Content-Type is an HTML document we may inject
|
||||
// into (text/html, optionally with a charset parameter).
|
||||
func isHTMLResponse(contentType string) bool {
|
||||
ct := strings.ToLower(strings.TrimSpace(contentType))
|
||||
return strings.HasPrefix(ct, "text/html")
|
||||
}
|
||||
|
||||
// splitCSV splits a comma-separated flag value into trimmed, lowercased,
|
||||
// non-empty entries.
|
||||
func splitCSV(s string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if p = strings.TrimSpace(strings.ToLower(p)); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// widgetHostMatch reports whether host (bare, lowercased) ends with one of the
|
||||
// configured first-party suffixes the operator opted into widget injection for.
|
||||
func widgetHostMatch(host string, suffixes []string) bool {
|
||||
for _, s := range suffixes {
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
// Exact host, or a dot-boundary subdomain — NOT a bare suffix match
|
||||
// (which would wrongly match "notsecubox.in" against "secubox.in").
|
||||
if host == s || strings.HasSuffix(host, "."+s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
91
packages/secubox-toolbox-ng/cmd/sbxwaf/injectwidget_test.go
Normal file
91
packages/secubox-toolbox-ng/cmd/sbxwaf/injectwidget_test.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testOrigin = "https://admin.gk2.secubox.in"
|
||||
|
||||
func TestInjectWidgetHTMLBeforeBodyClose(t *testing.T) {
|
||||
in := []byte(`<html><head></head><body><h1>hi</h1></body></html>`)
|
||||
out := injectWidgetHTML(in, testOrigin)
|
||||
s := string(out)
|
||||
if !strings.Contains(s, widgetGuard) {
|
||||
t.Fatalf("loader guard absent:\n%s", s)
|
||||
}
|
||||
// The loader points the banner asset/API at the Hub origin (O+'/path' at runtime).
|
||||
if !strings.Contains(s, "/shared/health-banner.js") {
|
||||
t.Fatalf("health-banner asset path absent:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, testOrigin) {
|
||||
t.Fatalf("Hub origin absent from loader:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, "SECUBOX_HEALTH_API") {
|
||||
t.Fatalf("health API override absent:\n%s", s)
|
||||
}
|
||||
// The loader must land BEFORE the closing </body>.
|
||||
gi := strings.Index(s, widgetGuard)
|
||||
bi := strings.LastIndex(strings.ToLower(s), "</body>")
|
||||
if gi < 0 || bi < 0 || gi > bi {
|
||||
t.Fatalf("loader not before </body> (guard=%d body=%d)", gi, bi)
|
||||
}
|
||||
if !strings.Contains(s, "<h1>hi</h1>") {
|
||||
t.Fatalf("original content displaced:\n%s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectWidgetIdempotent(t *testing.T) {
|
||||
in := []byte(`<body>x</body>`)
|
||||
once := injectWidgetHTML(in, testOrigin)
|
||||
twice := injectWidgetHTML(once, testOrigin)
|
||||
if !bytes.Equal(once, twice) {
|
||||
t.Fatalf("second injection must be a no-op (idempotent)")
|
||||
}
|
||||
if n := strings.Count(string(twice), widgetGuard); n != 1 {
|
||||
t.Fatalf("expected exactly 1 loader, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectWidgetSkipsPageThatAlreadyHasBanner(t *testing.T) {
|
||||
// A dashboard page already including the banner must NOT be double-mounted.
|
||||
in := []byte(`<body><script src="/shared/health-banner.js"></script></body>`)
|
||||
out := injectWidgetHTML(in, testOrigin)
|
||||
if !bytes.Equal(in, out) {
|
||||
t.Fatalf("page already shipping health-banner.js must be left untouched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectWidgetNoBodyPassthrough(t *testing.T) {
|
||||
in := []byte(`{"json":true}`) // no </body>
|
||||
out := injectWidgetHTML(in, testOrigin)
|
||||
if !bytes.Equal(in, out) {
|
||||
t.Fatalf("non-HTML (no </body>) must pass through unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectWidgetBodyGzipRoundTrip(t *testing.T) {
|
||||
html := []byte(`<html><body>content</body></html>`)
|
||||
out, ok := injectWidgetBody(html, "", testOrigin)
|
||||
if !ok || !bytes.Contains(out, []byte(widgetGuard)) {
|
||||
t.Fatalf("identity inject must report ok + contain loader")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWidgetHostMatch(t *testing.T) {
|
||||
hosts := []string{"gk2.secubox.in", "cybermind.fr"}
|
||||
for _, h := range []string{"blog.gk2.secubox.in", "gk2.secubox.in", "www.cybermind.fr"} {
|
||||
if !widgetHostMatch(h, hosts) {
|
||||
t.Fatalf("%q should match first-party suffixes", h)
|
||||
}
|
||||
}
|
||||
for _, h := range []string{"evil.com", "notsecubox.in"} {
|
||||
if widgetHostMatch(h, hosts) {
|
||||
t.Fatalf("%q must NOT match", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -155,6 +155,22 @@ type Server struct {
|
|||
// the upstream); cacheable responses on a miss are stored after proxying.
|
||||
// Nil means caching is disabled (--media-cache-dir="").
|
||||
mediaCache *MediaCache
|
||||
|
||||
// visits is the #747 non-attacker visit-statistics aggregator. When non-nil,
|
||||
// every LEGITIMATE (non-blocked, non-misdirected) response is tallied by
|
||||
// client-type / OS / vhost / status / top-IP and flushed to a JSON snapshot
|
||||
// the WAF API geo-maps for the dashboard Visits panel. Nil disables it.
|
||||
visits *VisitStats
|
||||
|
||||
// widgetHosts are the first-party host suffixes (gk2.secubox.in, …) into whose
|
||||
// HTML responses the WAF injects the SecuBox health banner (#747). Empty
|
||||
// disables injection. Set from --widget-hosts.
|
||||
widgetHosts []string
|
||||
|
||||
// bannerOrigin is the canonical Hub origin (absolute, e.g.
|
||||
// https://admin.gk2.secubox.in) the injected health-banner loads its asset +
|
||||
// metrics APIs from (CDN-injected mode). Empty disables injection.
|
||||
bannerOrigin string
|
||||
}
|
||||
|
||||
// handler returns an http.Handler that:
|
||||
|
|
@ -194,6 +210,20 @@ func (s *Server) handler() http.Handler {
|
|||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// #747 — wrap the writer so we can tally the visit's final status. The
|
||||
// defer records every LEGITIMATE response (excludes the WAF-block 403 and
|
||||
// the unmapped-host 421) into the visit-stats aggregator.
|
||||
var visitHost string
|
||||
if s.visits != nil {
|
||||
sr := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
w = sr
|
||||
defer func() {
|
||||
if sr.status != http.StatusForbidden && sr.status != http.StatusMisdirectedRequest {
|
||||
s.visits.Record(visitHost, r.UserAgent(), clientIP(r), sr.status)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Hot-reload check: stat the routes file and swap the map if mtime changed.
|
||||
// Cheap when nothing changed (throttle=0 means one stat per call, but stat
|
||||
// is O(1) and not on the inner response path).
|
||||
|
|
@ -208,6 +238,7 @@ func (s *Server) handler() http.Handler {
|
|||
host = r.Host
|
||||
}
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
visitHost = host
|
||||
|
||||
ip, port, ok := s.routeLookup(host)
|
||||
if !ok {
|
||||
|
|
@ -238,6 +269,8 @@ func (s *Server) handler() http.Handler {
|
|||
if ca := s.cookieAudit; ca != nil {
|
||||
ca.Record(host, resp.Request, resp)
|
||||
}
|
||||
// #747: inject the SecuBox health/visit widget on first-party HTML.
|
||||
applyWidget(resp, host, s.bannerOrigin, s.widgetHosts)
|
||||
return nil
|
||||
}
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
|
|
@ -534,6 +567,15 @@ func main() {
|
|||
// Task 6.1: response media cache.
|
||||
mediaCacheDir := flag.String("media-cache-dir", "/var/cache/secubox/waf/media",
|
||||
"directory for the response media cache (16 MiB/obj, 2 GiB total); empty disables")
|
||||
// #747: non-attacker visit statistics — aggregate legit traffic (client type,
|
||||
// OS, vhost, status, top IPs) flushed to a JSON snapshot the WAF API geo-maps.
|
||||
visitsStats := flag.String("visits-stats", "/var/log/secubox/waf/visits-stats.json",
|
||||
"path for the non-attacker visit-stats JSON snapshot (client type/OS/vhost/geo); empty disables")
|
||||
// #747: WAF-injected SecuBox health banner on FIRST-PARTY sites (HTML only).
|
||||
widgetHosts := flag.String("widget-hosts", "gk2.secubox.in,secubox.in,cybermind.fr,maegia.tv",
|
||||
"comma-separated first-party host suffixes to inject the SecuBox health banner into; empty disables")
|
||||
bannerOrigin := flag.String("health-banner-origin", "https://admin.gk2.secubox.in",
|
||||
"absolute Hub origin the injected health banner loads its asset + metrics APIs from (CDN-injected); empty disables")
|
||||
// Body inspection cap: only the first N bytes of the request body are scanned.
|
||||
// Payloads beyond this offset are NOT inspected (documented parity gap vs Python full-body scan).
|
||||
// Raise for stricter coverage; truncation events are always audit-logged regardless of this cap.
|
||||
|
|
@ -578,6 +620,13 @@ func main() {
|
|||
log.Printf("sbxwaf: media-cache enabled → %s (maxObj=16MiB, maxTotal=2GiB)", *mediaCacheDir)
|
||||
}
|
||||
|
||||
// #747: non-attacker visit statistics. Disabled when --visits-stats is empty.
|
||||
var visits *VisitStats
|
||||
if *visitsStats != "" {
|
||||
visits = NewVisitStats(*visitsStats)
|
||||
log.Printf("sbxwaf: visit-stats enabled → %s (flush %s)", *visitsStats, visitFlushInterval)
|
||||
}
|
||||
|
||||
srv := &Server{
|
||||
upstreamTimeout: *upstreamTimeout,
|
||||
transport: sharedTransport,
|
||||
|
|
@ -591,6 +640,11 @@ func main() {
|
|||
cookieAudit: cookieAudit,
|
||||
// Task 6.1: response media cache.
|
||||
mediaCache: mediaCache,
|
||||
// #747: non-attacker visit statistics.
|
||||
visits: visits,
|
||||
// #747: first-party host suffixes + Hub origin for the injected health banner.
|
||||
widgetHosts: splitCSV(*widgetHosts),
|
||||
bannerOrigin: strings.TrimSpace(*bannerOrigin),
|
||||
// Body inspection cap (--max-body-inspect).
|
||||
maxBodyInspect: *maxBodyInspectFlag,
|
||||
// Trusted-host skip (--waf-skip-hosts): mirrors Python whitelist.
|
||||
|
|
@ -626,6 +680,8 @@ func main() {
|
|||
r := LoadRoutes(*routesFile, sharedTransport)
|
||||
// Task 5.1: inject cookie audit so Routes-built proxies also record cookies.
|
||||
r.cookieAudit = cookieAudit
|
||||
r.widgetHosts = srv.widgetHosts
|
||||
r.bannerOrigin = srv.bannerOrigin
|
||||
srv.routes = r
|
||||
srv.routeLookup = r.Lookup
|
||||
log.Printf("sbxwaf: routes loaded from %s (%d entries)", *routesFile, func() int {
|
||||
|
|
|
|||
|
|
@ -77,6 +77,11 @@ type Routes struct {
|
|||
// LoadRoutes time; never mutated afterwards.
|
||||
cookieAudit *CookieAudit
|
||||
|
||||
// #747: first-party host suffixes + Hub origin for the injected SecuBox health
|
||||
// banner. Read inside ModifyResponse; set in main() after LoadRoutes.
|
||||
widgetHosts []string
|
||||
bannerOrigin string
|
||||
|
||||
// watcher handles mtime tracking + Apply callbacks (throttle=0 → eager).
|
||||
watcher *reload.Watcher
|
||||
}
|
||||
|
|
@ -167,13 +172,18 @@ func (r *Routes) buildEntries(parsed map[string][2]string) map[string]routeEntry
|
|||
// visible to both startup-built and hot-reload-built proxies.
|
||||
p.ModifyResponse = func(resp *http.Response) error {
|
||||
resp.Header.Set("X-SecuBox-WAF", "inspected")
|
||||
reqHost := ""
|
||||
if resp.Request != nil {
|
||||
reqHost = resp.Request.Host
|
||||
}
|
||||
if ca := r.cookieAudit; ca != nil {
|
||||
reqHost := ""
|
||||
if resp.Request != nil {
|
||||
reqHost = resp.Request.Host
|
||||
}
|
||||
ca.Record(reqHost, resp.Request, resp)
|
||||
}
|
||||
// #747: inject the SecuBox health/visit widget on first-party HTML.
|
||||
if bare, _, e := net.SplitHostPort(reqHost); e == nil {
|
||||
reqHost = bare
|
||||
}
|
||||
applyWidget(resp, strings.ToLower(reqHost), r.bannerOrigin, r.widgetHosts)
|
||||
return nil
|
||||
}
|
||||
p.ErrorHandler = func(w http.ResponseWriter, req *http.Request, err error) {
|
||||
|
|
|
|||
305
packages/secubox-toolbox-ng/cmd/sbxwaf/visitstats.go
Normal file
305
packages/secubox-toolbox-ng/cmd/sbxwaf/visitstats.go
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
// Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
// See LICENCE-CMSD-1.0.md for terms.
|
||||
|
||||
// SecuBox-Deb :: sbxwaf :: non-attacker visit statistics (#747)
|
||||
//
|
||||
// The WAF sees ALL inbound external traffic, so it is the right place to produce
|
||||
// real "who actually visited" statistics — NOT just the threats already captured
|
||||
// in the threat log. This aggregates LEGITIMATE (non-blocked) requests in memory
|
||||
// (lock-guarded, capped maps) and flushes an aggregate JSON snapshot periodically
|
||||
// (the double-caching pattern: hot path only touches counters, never disk). The
|
||||
// WAF API reads the snapshot, geo-maps the top client IPs (it already ships a
|
||||
// GeoIP reader), and serves the dashboard's Visits panel.
|
||||
//
|
||||
// Privacy: the snapshot is AGGREGATE counters (client-type / OS / per-vhost /
|
||||
// status buckets) plus the top-N busiest client IPs for geo-mapping — no
|
||||
// per-request PII, no full request log. UA is classified into coarse buckets, not
|
||||
// stored verbatim. Pure standard library.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// statusRecorder wraps an http.ResponseWriter to remember the status code that
|
||||
// was written, so the handler can tally the visit's outcome (2xx/3xx vs the
|
||||
// blocked 403/421 it filters out) after the proxy has served the response. It
|
||||
// forwards Flush so progressive responses still stream.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (s *statusRecorder) WriteHeader(code int) {
|
||||
s.status = code
|
||||
s.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (s *statusRecorder) Flush() {
|
||||
if f, ok := s.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// visitMapCap bounds each per-key map so a flood of distinct vhosts/IPs cannot
|
||||
// grow memory without bound. NEW keys past the cap are dropped (existing keys
|
||||
// keep counting) until the next flush clears the buffer.
|
||||
const visitMapCap = 5000
|
||||
|
||||
// visitFlushInterval is how often the in-memory counters are snapshotted to disk.
|
||||
const visitFlushInterval = 30 * time.Second
|
||||
|
||||
// VisitStats aggregates legitimate (non-blocked) request counts. All maps are
|
||||
// guarded by mu; the hot path takes mu only for the O(1) increment.
|
||||
type VisitStats struct {
|
||||
mu sync.Mutex
|
||||
total int64
|
||||
byVhost map[string]int64
|
||||
byClientType map[string]int64 // browser | mobile-app | bot | crawler | other
|
||||
byOS map[string]int64 // Windows | macOS | Linux | Android | iOS | other
|
||||
byStatus map[string]int64 // 2xx | 3xx | 4xx | 5xx
|
||||
topIP map[string]int64 // busiest client IPs (geo-mapped by the API)
|
||||
statsPath string
|
||||
}
|
||||
|
||||
// NewVisitStats builds the aggregator and starts the background flusher writing
|
||||
// to statsPath every visitFlushInterval. statsPath == "" disables persistence
|
||||
// (the aggregator still counts, useful for tests).
|
||||
func NewVisitStats(statsPath string) *VisitStats {
|
||||
v := &VisitStats{
|
||||
byVhost: map[string]int64{},
|
||||
byClientType: map[string]int64{},
|
||||
byOS: map[string]int64{},
|
||||
byStatus: map[string]int64{},
|
||||
topIP: map[string]int64{},
|
||||
statsPath: statsPath,
|
||||
}
|
||||
if statsPath != "" {
|
||||
go v.runFlusher()
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Record tallies one legitimate visit. The caller filters out blocked/misdirected
|
||||
// responses (403/421) before calling — this counts real traffic to real backends.
|
||||
func (v *VisitStats) Record(vhost, ua, ip string, status int) {
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
ct, os := classifyUA(ua)
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
v.total++
|
||||
bump(v.byClientType, ct) // small fixed key-set — no cap needed
|
||||
bump(v.byOS, os) // small fixed key-set
|
||||
bump(v.byStatus, statusBucket(status)) // 4 keys
|
||||
capBump(v.byVhost, vhost)
|
||||
if ip != "" {
|
||||
capBump(v.topIP, ip)
|
||||
}
|
||||
}
|
||||
|
||||
// Total returns the current cumulative visit count (lock-guarded) for the
|
||||
// WAF-injected health widget.
|
||||
func (v *VisitStats) Total() int64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
return v.total
|
||||
}
|
||||
|
||||
// bump increments an unbounded-but-tiny key set (client type / OS / status).
|
||||
func bump(m map[string]int64, k string) {
|
||||
if k == "" {
|
||||
k = "other"
|
||||
}
|
||||
m[k]++
|
||||
}
|
||||
|
||||
// capBump increments k, adding a NEW key only while the map is under visitMapCap.
|
||||
func capBump(m map[string]int64, k string) {
|
||||
if k == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := m[k]; ok {
|
||||
m[k]++
|
||||
} else if len(m) < visitMapCap {
|
||||
m[k] = 1
|
||||
}
|
||||
}
|
||||
|
||||
// statusBucket coarsens an HTTP status code into a 2xx/3xx/4xx/5xx bucket.
|
||||
func statusBucket(code int) string {
|
||||
switch {
|
||||
case code >= 200 && code < 300:
|
||||
return "2xx"
|
||||
case code >= 300 && code < 400:
|
||||
return "3xx"
|
||||
case code >= 400 && code < 500:
|
||||
return "4xx"
|
||||
case code >= 500:
|
||||
return "5xx"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
// classifyUA maps a raw User-Agent string into a coarse (clientType, os) pair.
|
||||
// Order matters: bots/crawlers are checked before browsers because crawler UAs
|
||||
// also carry "Mozilla". Heuristic, not exhaustive — good enough for a dashboard
|
||||
// breakdown, and never stores the raw UA.
|
||||
func classifyUA(ua string) (clientType, os string) {
|
||||
l := strings.ToLower(ua)
|
||||
if l == "" {
|
||||
return "other", "other"
|
||||
}
|
||||
|
||||
// ── OS ──────────────────────────────────────────────────────────────────
|
||||
switch {
|
||||
case strings.Contains(l, "android"):
|
||||
os = "Android"
|
||||
case strings.Contains(l, "iphone"), strings.Contains(l, "ipad"), strings.Contains(l, "ios "):
|
||||
os = "iOS"
|
||||
case strings.Contains(l, "windows"):
|
||||
os = "Windows"
|
||||
case strings.Contains(l, "mac os x"), strings.Contains(l, "macintosh"):
|
||||
os = "macOS"
|
||||
case strings.Contains(l, "linux"), strings.Contains(l, "x11"):
|
||||
os = "Linux"
|
||||
default:
|
||||
os = "other"
|
||||
}
|
||||
|
||||
// ── client type ─────────────────────────────────────────────────────────
|
||||
switch {
|
||||
case strings.Contains(l, "bot"), strings.Contains(l, "spider"),
|
||||
strings.Contains(l, "slurp"), strings.Contains(l, "crawl"):
|
||||
clientType = "crawler"
|
||||
case strings.Contains(l, "curl"), strings.Contains(l, "wget"),
|
||||
strings.Contains(l, "python"), strings.Contains(l, "go-http"),
|
||||
strings.Contains(l, "okhttp"), strings.Contains(l, "java/"),
|
||||
strings.Contains(l, "libwww"), strings.Contains(l, "scanner"):
|
||||
clientType = "bot"
|
||||
case (os == "Android" || os == "iOS") &&
|
||||
!strings.Contains(l, "mobile safari") && !strings.Contains(l, "version/"):
|
||||
// A mobile OS UA without a browser marker → native app.
|
||||
clientType = "mobile-app"
|
||||
case strings.Contains(l, "mozilla"):
|
||||
clientType = "browser"
|
||||
default:
|
||||
clientType = "other"
|
||||
}
|
||||
return clientType, os
|
||||
}
|
||||
|
||||
// visitSnapshot is the on-disk JSON shape the WAF API reads.
|
||||
type visitSnapshot struct {
|
||||
Total int64 `json:"total"`
|
||||
ByVhost map[string]int64 `json:"by_vhost"`
|
||||
ByClientType map[string]int64 `json:"by_client_type"`
|
||||
ByOS map[string]int64 `json:"by_os"`
|
||||
ByStatus map[string]int64 `json:"by_status"`
|
||||
TopIP map[string]int64 `json:"top_ips"`
|
||||
UpdatedUnix int64 `json:"updated_unix"`
|
||||
}
|
||||
|
||||
// snapshot copies the counters under the lock. The counters are CUMULATIVE (not
|
||||
// cleared) so the dashboard shows running totals since process start; the flusher
|
||||
// overwrites the file each tick with the latest cumulative state.
|
||||
func (v *VisitStats) snapshot(nowUnix int64) visitSnapshot {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
return visitSnapshot{
|
||||
Total: v.total,
|
||||
ByVhost: copyTopN(v.byVhost, 50),
|
||||
ByClientType: copyMap(v.byClientType),
|
||||
ByOS: copyMap(v.byOS),
|
||||
ByStatus: copyMap(v.byStatus),
|
||||
TopIP: copyTopN(v.topIP, 50),
|
||||
UpdatedUnix: nowUnix,
|
||||
}
|
||||
}
|
||||
|
||||
func copyMap(m map[string]int64) map[string]int64 {
|
||||
out := make(map[string]int64, len(m))
|
||||
for k, n := range m {
|
||||
out[k] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// copyTopN returns the n highest-count entries of m (keeps the file small even
|
||||
// when the in-memory map holds thousands of vhosts/IPs).
|
||||
func copyTopN(m map[string]int64, n int) map[string]int64 {
|
||||
if len(m) <= n {
|
||||
return copyMap(m)
|
||||
}
|
||||
type kv struct {
|
||||
k string
|
||||
v int64
|
||||
}
|
||||
all := make([]kv, 0, len(m))
|
||||
for k, v := range m {
|
||||
all = append(all, kv{k, v})
|
||||
}
|
||||
// partial selection of the top n by count (simple insertion into a bounded slice)
|
||||
top := make([]kv, 0, n)
|
||||
for _, e := range all {
|
||||
if len(top) < n {
|
||||
top = append(top, e)
|
||||
continue
|
||||
}
|
||||
// find the current minimum in top and replace if e is larger
|
||||
minI := 0
|
||||
for i := 1; i < len(top); i++ {
|
||||
if top[i].v < top[minI].v {
|
||||
minI = i
|
||||
}
|
||||
}
|
||||
if e.v > top[minI].v {
|
||||
top[minI] = e
|
||||
}
|
||||
}
|
||||
out := make(map[string]int64, n)
|
||||
for _, e := range top {
|
||||
out[e.k] = e.v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeSnapshot atomically writes the snapshot JSON to statsPath (temp + rename)
|
||||
// so the API never reads a half-written file. Best-effort: errors are swallowed.
|
||||
func (v *VisitStats) writeSnapshot(nowUnix int64) {
|
||||
if v.statsPath == "" {
|
||||
return
|
||||
}
|
||||
snap := v.snapshot(nowUnix)
|
||||
buf, err := json.Marshal(snap)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tmp := v.statsPath + ".tmp"
|
||||
if err := os.WriteFile(tmp, buf, 0o640); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.Rename(tmp, v.statsPath)
|
||||
}
|
||||
|
||||
// runFlusher overwrites the stats file every visitFlushInterval for the process
|
||||
// lifetime. Started once from NewVisitStats.
|
||||
func (v *VisitStats) runFlusher() {
|
||||
t := time.NewTicker(visitFlushInterval)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
v.writeSnapshot(time.Now().Unix())
|
||||
}
|
||||
}
|
||||
|
|
@ -3775,10 +3775,14 @@ async def admin_metrics() -> dict:
|
|||
"events_24h_total": 0,
|
||||
"mitm": {"connections": 0, "tls_pinned": 0, "unique_hosts": 0},
|
||||
}
|
||||
# Per-source event counts (last 24h)
|
||||
# Per-source event counts. The legacy toolbox.db `events` table was fed by the
|
||||
# OLD Python mitmproxy addons; the current R3 path is Go sbxmitm → relay →
|
||||
# sidecars → cumulative stats, so that table is now empty. Read the cumulative
|
||||
# per-source totals (cookies/ja4/…) when the events table has nothing, so the
|
||||
# Live-metrics panel shows real activity instead of zeros.
|
||||
since = int(time.time()) - 86400
|
||||
try:
|
||||
with _sq3.connect("/var/lib/secubox/toolbox/toolbox.db", timeout=2) as c:
|
||||
since = int(time.time()) - 86400
|
||||
rows = c.execute(
|
||||
"SELECT source, COUNT(*) FROM events WHERE ts > ? GROUP BY source",
|
||||
(since,),
|
||||
|
|
@ -3791,6 +3795,17 @@ async def admin_metrics() -> dict:
|
|||
).fetchone()[0]
|
||||
except Exception as e:
|
||||
metrics["sqlite_error"] = str(e)
|
||||
if not metrics["events_by_source"]:
|
||||
try:
|
||||
ev = (cumulative.get_cached() or {}).get("events", {}) or {}
|
||||
src = {k: int(v) for k, v in ev.items()
|
||||
if k != "total_7d" and isinstance(v, (int, float))}
|
||||
if src:
|
||||
metrics["events_by_source"] = src
|
||||
metrics["events_24h_total"] = int(ev.get("total_7d") or sum(src.values()))
|
||||
metrics["events_window"] = "7d" # cumulative fallback, not strictly 24h
|
||||
except Exception:
|
||||
pass
|
||||
# Live MITM activity. NOTE: the old journal-scrape for "server connect"
|
||||
# NEVER worked — the workers run at --log-level warning, so those INFO lines
|
||||
# are never emitted → the trio was permanently 0. Derive from real data: the
|
||||
|
|
@ -3799,8 +3814,10 @@ async def admin_metrics() -> dict:
|
|||
try:
|
||||
cs = cumulative.get_cached() or {}
|
||||
ev = cs.get("events", {}) or {}
|
||||
# "connections analysées" — DPI classifies one flow per upstream connection.
|
||||
metrics["mitm"]["connections"] = int(ev.get("dpi", 0) or 0)
|
||||
# "connections analysées" — each JA4 observation is one inspected TLS
|
||||
# handshake/upstream connection. The cumulative stats expose ja4 (and
|
||||
# cookies), not a `dpi` key, so the old ev.get("dpi") was always 0.
|
||||
metrics["mitm"]["connections"] = int(ev.get("ja4") or ev.get("dpi") or 0)
|
||||
metrics["mitm"]["unique_hosts"] = len(cs.get("top_hosts_7d", []) or [])
|
||||
except Exception:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@
|
|||
</div>
|
||||
<div id="ads-section" class="grid">
|
||||
<div class="card" style="grid-column:1/-1">
|
||||
<h2>🛑 Pubs bloquées (24h)</h2>
|
||||
<h2>🛑 Trackers & pubs bloqués (24h)</h2>
|
||||
<div class="kv" id="ads-kpi"><span class="k">loading…</span><span class="v"></span></div>
|
||||
</div>
|
||||
<div class="card" style="grid-column:1/-1">
|
||||
|
|
@ -191,7 +191,7 @@
|
|||
<div id="ads-sites"><div class="empty">loading…</div></div>
|
||||
</div>
|
||||
<div class="card" style="grid-column:1/-1">
|
||||
<h2>👤 Top visiteurs (pubs bloquées)</h2>
|
||||
<h2>👤 Top visiteurs (trackers/pubs bloqués)</h2>
|
||||
<div id="ads-visitors"><div class="empty">loading…</div></div>
|
||||
<div id="ads-client-detail"></div>
|
||||
</div>
|
||||
|
|
@ -617,24 +617,24 @@ async function loadAds() {
|
|||
const kpi = document.getElementById('ads-kpi');
|
||||
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
if (!d || d.__error) { kpi.innerHTML = `<span class="k">err</span><span class="v">${(d&&d.__error)||'no data'}</span>`; return; }
|
||||
kpi.innerHTML = `<span class="k">Pubs bloquées</span> <span class="v">${d.total_blocked||0}</span>`
|
||||
+ ` <span class="k">Ko économisés</span> <span class="v">${Math.round((d.total_bytes||0)/1024)}</span>`
|
||||
kpi.innerHTML = `<span class="k">Trackers & pubs bloqués</span> <span class="v">${d.total_blocked||0}</span>`
|
||||
+ ` <span class="k" title="estimation : un contenu bloqué n'est jamais téléchargé, on ne peut pas mesurer les octets réels — ~45 Ko/blocage">Ko évités <span style="opacity:.6">(est.)</span></span> <span class="v">~${Math.round((d.total_bytes||0)/1024)}</span>`
|
||||
+ ` <span class="k">Silenced</span> <span class="v">${(d.by_action&&d.by_action.silent)||0}</span>`
|
||||
+ ` <span class="k">Fenêtre</span> <span class="v">${d.window_hours||24}h</span>`;
|
||||
const hostRows = (d.top_hosts||[]).slice(0, 5).map(r=>`<tr><td><code>${esc(r.host)}</code></td><td>${r.hits}</td><td>${Math.round((r.bytes||0)/1024)}</td></tr>`).join('');
|
||||
const hostRows = (d.top_hosts||[]).slice(0, 5).map(r=>`<tr><td><code>${esc(r.host)}</code></td><td>${r.hits}</td><td>~${Math.round((r.bytes||0)/1024)}</td></tr>`).join('');
|
||||
const siteRows = (d.top_sites||[]).slice(0, 5).map(r=>`<tr><td><code>${esc(r.site)}</code></td><td>${r.hits}</td></tr>`).join('');
|
||||
document.getElementById('ads-hosts').innerHTML = hostRows
|
||||
? '<table><thead><tr><th>Ad host</th><th>bloqués</th><th>Ko</th></tr></thead><tbody>'+hostRows+'</tbody></table>'
|
||||
: '<div class="empty">aucune pub bloquée dans la fenêtre</div>';
|
||||
? '<table><thead><tr><th>Tracker / pub host</th><th>bloqués</th><th>Ko (est.)</th></tr></thead><tbody>'+hostRows+'</tbody></table>'
|
||||
: '<div class="empty">rien de bloqué dans la fenêtre</div>';
|
||||
document.getElementById('ads-sites').innerHTML = siteRows
|
||||
? '<table><thead><tr><th>Site</th><th>pubs bloquées</th></tr></thead><tbody>'+siteRows+'</tbody></table>'
|
||||
? '<table><thead><tr><th>Site</th><th>trackers/pubs bloqués</th></tr></thead><tbody>'+siteRows+'</tbody></table>'
|
||||
: '';
|
||||
const visRows = (d.top_visitors||[]).slice(0, 5).map(r=>{
|
||||
const mh = esc(r.mac_hash);
|
||||
return `<tr><td><a href="#" onclick="loadAdsClient('${mh}');return false;"><code>${mh}</code></a></td><td>${r.hits}</td></tr>`;
|
||||
}).join('');
|
||||
document.getElementById('ads-visitors').innerHTML = visRows
|
||||
? '<table><thead><tr><th>Visiteur</th><th>pubs bloquées</th></tr></thead><tbody>'+visRows+'</tbody></table>'
|
||||
? '<table><thead><tr><th>Visiteur</th><th>trackers/pubs</th></tr></thead><tbody>'+visRows+'</tbody></table>'
|
||||
: '<div class="empty">aucun visiteur dans la fenêtre</div>';
|
||||
}
|
||||
|
||||
|
|
@ -645,7 +645,7 @@ async function loadAdsClient(mh) {
|
|||
const d = await J('/admin/ad-stats/client/'+encodeURIComponent(mh));
|
||||
if (!d || d.__error) { detail.innerHTML = `<div class="empty">${(d&&d.__error)||'no data'}</div>`; return; }
|
||||
const rows = (d.top_hosts||[]).slice(0, 5).map(r=>`<tr><td><code>${esc(r.host)}</code></td><td>${r.hits}</td><td>${Math.round((r.bytes||0)/1024)}</td></tr>`).join('');
|
||||
detail.innerHTML = `<h3>👤 <code>${esc(d.mac_hash)}</code> — ${d.total||0} pubs bloquées</h3>`
|
||||
detail.innerHTML = `<h3>👤 <code>${esc(d.mac_hash)}</code> — ${d.total||0} trackers/pubs bloqués</h3>`
|
||||
+ (rows
|
||||
? '<table><thead><tr><th>Ad host</th><th>bloqués</th><th>Ko</th></tr></thead><tbody>'+rows+'</tbody></table>'
|
||||
: '<div class="empty">aucune pub bloquée pour ce visiteur</div>');
|
||||
|
|
|
|||
|
|
@ -702,7 +702,13 @@ def _refresh_warm_caches() -> dict:
|
|||
cs_alerts = _get_crowdsec_alerts()
|
||||
except Exception:
|
||||
cs_alerts = []
|
||||
if cs_alerts:
|
||||
# The Go sbxwaf engine (#744) now writes a RICH threat log, so the WAF stats
|
||||
# are real and fresh. Only fall back to the CrowdSec overlay when the engine
|
||||
# produced NOTHING (engine down / log unreadable) — otherwise the overlay's
|
||||
# older, coarser CrowdSec scenarios would clobber the engine's fresh
|
||||
# categories AND push a stale "1h ago" entry to the live attack banner.
|
||||
engine_has_data = (new_stats.get("total_threats", 0) or 0) > 0 or bool(new_alerts)
|
||||
if cs_alerts and not engine_has_data:
|
||||
# Defensive: a CrowdSec-overlay failure must NEVER abort the refresh and
|
||||
# leave the warm cache frozen — the WAF top_ips/top_vhosts/tracked-attacker
|
||||
# data (the dashboard's per-IP/vhost panels) lives in new_stats and must
|
||||
|
|
@ -869,6 +875,43 @@ def _empty_stats_skeleton() -> dict:
|
|||
}
|
||||
|
||||
|
||||
VISITS_STATS = os.environ.get(
|
||||
"SECUBOX_WAF_VISITS_STATS", "/var/log/secubox/waf/visits-stats.json")
|
||||
|
||||
|
||||
@app.get("/visits")
|
||||
async def get_visits():
|
||||
"""Non-attacker visit statistics (#747) — the WAF sees ALL inbound traffic,
|
||||
so this is real 'who actually visited' data: total requests, client-type /
|
||||
OS / per-vhost / status breakdowns, plus the busiest client IPs geo-mapped to
|
||||
countries here (the engine ships only the IPs; the API holds the GeoIP DB).
|
||||
Public, served straight from the engine's periodic JSON snapshot."""
|
||||
try:
|
||||
snap = json.loads(Path(VISITS_STATS).read_text())
|
||||
except (OSError, ValueError):
|
||||
return {"available": False, "total": 0, "by_client_type": {}, "by_os": {},
|
||||
"by_status": {}, "by_vhost": {}, "top_countries": {}, "top_ips": {}}
|
||||
reader = _get_geoip_reader()
|
||||
top_ips = snap.get("top_ips") or {}
|
||||
by_country: Dict[str, int] = defaultdict(int)
|
||||
for ip, n in top_ips.items():
|
||||
by_country[_lookup_country(ip, reader)] += int(n or 0)
|
||||
return {
|
||||
"available": True,
|
||||
"total": int(snap.get("total", 0)),
|
||||
"by_client_type": snap.get("by_client_type") or {},
|
||||
"by_os": snap.get("by_os") or {},
|
||||
"by_status": snap.get("by_status") or {},
|
||||
"by_vhost": dict(sorted((snap.get("by_vhost") or {}).items(),
|
||||
key=lambda kv: -int(kv[1] or 0))[:10]),
|
||||
"top_countries": dict(sorted(by_country.items(),
|
||||
key=lambda kv: -kv[1])[:10]),
|
||||
"top_ips": dict(sorted(top_ips.items(),
|
||||
key=lambda kv: -int(kv[1] or 0))[:10]),
|
||||
"updated_unix": int(snap.get("updated_unix", 0)),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/stats")
|
||||
async def get_stats():
|
||||
"""Threat statistics for the dashboard (public).
|
||||
|
|
|
|||
|
|
@ -561,6 +561,25 @@
|
|||
<div class="stat-card"><div class="emoji">🛡️</div><div class="value" id="protectedSites">0</div><div class="label">Protected</div></div>
|
||||
</div>
|
||||
|
||||
<!-- #747 — dashboard tabs: Menaces / Attaquants suivis / Visites -->
|
||||
<div class="waf-tabs" id="wafTabs">
|
||||
<button class="waf-tab active" data-tab="overview" onclick="switchTab('overview')">🛡️ Menaces</button>
|
||||
<button class="waf-tab" data-tab="attackers" onclick="switchTab('attackers')">🎯 Attaquants suivis</button>
|
||||
<button class="waf-tab" data-tab="visits" onclick="switchTab('visits')">👁️ Visites</button>
|
||||
</div>
|
||||
<style>
|
||||
.waf-tabs{display:flex;gap:.4rem;margin:.4rem 0 1rem;flex-wrap:wrap}
|
||||
.waf-tab{background:rgba(110,64,201,.08);color:#c9b8ff;border:1px solid #2a2a3f;border-radius:.5rem;
|
||||
padding:.45rem 1rem;cursor:pointer;font-size:.88rem;font-weight:600}
|
||||
.waf-tab.active{background:linear-gradient(90deg,#00d4ff22,#6e40c944);color:#fff;border-color:#6e40c9}
|
||||
.vbars{display:flex;flex-direction:column;gap:.35rem}
|
||||
.vbar{display:grid;grid-template-columns:auto 1fr auto;gap:.6rem;align-items:center;font-size:.82rem}
|
||||
.vbar .vlabel{white-space:nowrap;max-width:16rem;overflow:hidden;text-overflow:ellipsis}
|
||||
.vbar .vtrack{height:.6rem;border-radius:.3rem;background:rgba(255,255,255,.07);overflow:hidden}
|
||||
.vbar .vfill{height:100%;border-radius:.3rem;background:linear-gradient(90deg,#00d4ff,#6e40c9)}
|
||||
.vbar .vcount{font-variant-numeric:tabular-nums;color:#c9a84c}
|
||||
</style>
|
||||
|
||||
<div class="grid-2">
|
||||
<!-- Security Events (combined alerts/bans) -->
|
||||
<div class="glass-card">
|
||||
|
|
@ -700,6 +719,19 @@
|
|||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- #747 — non-attacker visit statistics (shown in the Visites tab) -->
|
||||
<div class="glass-card" id="visitsCard" style="grid-column: 1 / -1;">
|
||||
<h2>👁️ Visites — trafic légitime <span class="muted" style="font-size:0.8rem;">— total <b id="visitsTotal">—</b> requêtes vues par le WAF</span></h2>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1rem;margin-top:0.6rem">
|
||||
<div><h3 style="font-size:0.85rem;margin:0 0 .4rem">🧭 Type de client</h3><div id="visitsClientType" class="vbars"><span class="muted">…</span></div></div>
|
||||
<div><h3 style="font-size:0.85rem;margin:0 0 .4rem">💻 Système</h3><div id="visitsOS" class="vbars"><span class="muted">…</span></div></div>
|
||||
<div><h3 style="font-size:0.85rem;margin:0 0 .4rem">🌍 Pays</h3><div id="visitsCountries" class="vbars"><span class="muted">…</span></div></div>
|
||||
<div><h3 style="font-size:0.85rem;margin:0 0 .4rem">📊 Statut</h3><div id="visitsStatus" class="vbars"><span class="muted">…</span></div></div>
|
||||
<div style="grid-column:1/-1"><h3 style="font-size:0.85rem;margin:0 0 .4rem">🎯 Sites les plus visités</h3><div id="visitsVhosts" class="vbars"><span class="muted">…</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
|
@ -1154,7 +1186,8 @@
|
|||
loadStats(),
|
||||
loadSecurityEvents(),
|
||||
loadCategories(),
|
||||
loadTrackedAttackers()
|
||||
loadTrackedAttackers(),
|
||||
loadVisits()
|
||||
]);
|
||||
btn.innerHTML = '🔄<span class="quick-btn-tooltip">Refresh</span>';
|
||||
}
|
||||
|
|
@ -1195,6 +1228,52 @@
|
|||
}).join('');
|
||||
}
|
||||
|
||||
// #747 — non-attacker visit statistics
|
||||
const _CT_EMOJI = { browser: '🌐', 'mobile-app': '📱', bot: '🤖', crawler: '🕷️', other: '❔' };
|
||||
const _OS_EMOJI = { Windows: '🪟', macOS: '🍎', Linux: '🐧', Android: '🤖', iOS: '📱', other: '❔' };
|
||||
function _vbars(elId, obj, emoji) {
|
||||
const el = document.getElementById(elId);
|
||||
if (!el) return;
|
||||
const entries = Object.entries(obj || {}).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
||||
if (!entries.length) { el.innerHTML = '<span class="muted">aucune donnée</span>'; return; }
|
||||
const max = entries[0][1] || 1;
|
||||
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<');
|
||||
el.innerHTML = entries.map(([k, v]) => {
|
||||
const pct = Math.max(3, Math.round((v / max) * 100));
|
||||
const ic = emoji ? (emoji[k] || '') + ' ' : '';
|
||||
return '<div class="vbar"><span class="vlabel" title="' + esc(k) + '">' + ic + esc(k) + '</span>'
|
||||
+ '<span class="vtrack"><span class="vfill" style="width:' + pct + '%"></span></span>'
|
||||
+ '<span class="vcount">' + v + '</span></div>';
|
||||
}).join('');
|
||||
}
|
||||
async function loadVisits() {
|
||||
const d = await api('/visits');
|
||||
const tot = document.getElementById('visitsTotal');
|
||||
if (tot) tot.textContent = (d && d.total != null) ? d.total.toLocaleString() : '—';
|
||||
_vbars('visitsClientType', d?.by_client_type, _CT_EMOJI);
|
||||
_vbars('visitsOS', d?.by_os, _OS_EMOJI);
|
||||
_vbars('visitsCountries', d?.top_countries, null);
|
||||
_vbars('visitsStatus', d?.by_status, null);
|
||||
_vbars('visitsVhosts', d?.by_vhost, null);
|
||||
}
|
||||
|
||||
// #747 — dashboard tabs (Menaces / Attaquants suivis / Visites)
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('#wafTabs .waf-tab').forEach(b =>
|
||||
b.classList.toggle('active', b.dataset.tab === name));
|
||||
const show = (sel, on) => document.querySelectorAll(sel).forEach(el =>
|
||||
el.style.display = on ? '' : 'none');
|
||||
const overview = name === 'overview';
|
||||
show('.grid-2', overview);
|
||||
show('.eyemote-card', overview);
|
||||
const tracked = document.getElementById('trackedAttackersCard');
|
||||
const visits = document.getElementById('visitsCard');
|
||||
if (tracked) tracked.style.display = (name === 'attackers') ? '' : 'none';
|
||||
if (visits) visits.style.display = (name === 'visits') ? '' : 'none';
|
||||
if (name === 'visits') loadVisits();
|
||||
if (name === 'attackers') loadTrackedAttackers();
|
||||
}
|
||||
|
||||
// WAF Toggle
|
||||
let wafActive = true;
|
||||
async function toggleWaf() {
|
||||
|
|
@ -1263,6 +1342,7 @@
|
|||
btn.innerHTML = '📤<span class="quick-btn-tooltip">Export</span>';
|
||||
}
|
||||
|
||||
switchTab('overview');
|
||||
refresh();
|
||||
setInterval(refresh, 30000);
|
||||
// More frequent live updates
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user