mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 18:06:21 +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
|
capture_output=True, text=True, timeout=3
|
||||||
)
|
)
|
||||||
nft_output = result.stdout if result.returncode == 0 else ""
|
nft_output = result.stdout if result.returncode == 0 else ""
|
||||||
checks["nftables_crowdsec"] = "ip crowdsec" in nft_output
|
# The SecuBox firewall-bouncer uses a CUSTOM table name (inet
|
||||||
checks["nftables_crowdsec6"] = "ip6 crowdsec6" in nft_output
|
# secubox_blacklist), not the upstream default `ip crowdsec` / `ip6
|
||||||
checks["nftables_ok"] = checks["nftables_crowdsec"] and checks["nftables_crowdsec6"]
|
# 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:
|
except Exception as e:
|
||||||
log.warning("nftables check failed: %s", e)
|
log.warning("nftables check failed: %s", e)
|
||||||
checks["nftables_ok"] = False
|
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.
|
// the upstream); cacheable responses on a miss are stored after proxying.
|
||||||
// Nil means caching is disabled (--media-cache-dir="").
|
// Nil means caching is disabled (--media-cache-dir="").
|
||||||
mediaCache *MediaCache
|
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:
|
// 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) {
|
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.
|
// 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
|
// Cheap when nothing changed (throttle=0 means one stat per call, but stat
|
||||||
// is O(1) and not on the inner response path).
|
// is O(1) and not on the inner response path).
|
||||||
|
|
@ -208,6 +238,7 @@ func (s *Server) handler() http.Handler {
|
||||||
host = r.Host
|
host = r.Host
|
||||||
}
|
}
|
||||||
host = strings.ToLower(strings.TrimSpace(host))
|
host = strings.ToLower(strings.TrimSpace(host))
|
||||||
|
visitHost = host
|
||||||
|
|
||||||
ip, port, ok := s.routeLookup(host)
|
ip, port, ok := s.routeLookup(host)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -238,6 +269,8 @@ func (s *Server) handler() http.Handler {
|
||||||
if ca := s.cookieAudit; ca != nil {
|
if ca := s.cookieAudit; ca != nil {
|
||||||
ca.Record(host, resp.Request, resp)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
|
@ -534,6 +567,15 @@ func main() {
|
||||||
// Task 6.1: response media cache.
|
// Task 6.1: response media cache.
|
||||||
mediaCacheDir := flag.String("media-cache-dir", "/var/cache/secubox/waf/media",
|
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")
|
"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.
|
// 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).
|
// 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.
|
// 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)
|
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{
|
srv := &Server{
|
||||||
upstreamTimeout: *upstreamTimeout,
|
upstreamTimeout: *upstreamTimeout,
|
||||||
transport: sharedTransport,
|
transport: sharedTransport,
|
||||||
|
|
@ -591,6 +640,11 @@ func main() {
|
||||||
cookieAudit: cookieAudit,
|
cookieAudit: cookieAudit,
|
||||||
// Task 6.1: response media cache.
|
// Task 6.1: response media cache.
|
||||||
mediaCache: mediaCache,
|
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).
|
// Body inspection cap (--max-body-inspect).
|
||||||
maxBodyInspect: *maxBodyInspectFlag,
|
maxBodyInspect: *maxBodyInspectFlag,
|
||||||
// Trusted-host skip (--waf-skip-hosts): mirrors Python whitelist.
|
// Trusted-host skip (--waf-skip-hosts): mirrors Python whitelist.
|
||||||
|
|
@ -626,6 +680,8 @@ func main() {
|
||||||
r := LoadRoutes(*routesFile, sharedTransport)
|
r := LoadRoutes(*routesFile, sharedTransport)
|
||||||
// Task 5.1: inject cookie audit so Routes-built proxies also record cookies.
|
// Task 5.1: inject cookie audit so Routes-built proxies also record cookies.
|
||||||
r.cookieAudit = cookieAudit
|
r.cookieAudit = cookieAudit
|
||||||
|
r.widgetHosts = srv.widgetHosts
|
||||||
|
r.bannerOrigin = srv.bannerOrigin
|
||||||
srv.routes = r
|
srv.routes = r
|
||||||
srv.routeLookup = r.Lookup
|
srv.routeLookup = r.Lookup
|
||||||
log.Printf("sbxwaf: routes loaded from %s (%d entries)", *routesFile, func() int {
|
log.Printf("sbxwaf: routes loaded from %s (%d entries)", *routesFile, func() int {
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,11 @@ type Routes struct {
|
||||||
// LoadRoutes time; never mutated afterwards.
|
// LoadRoutes time; never mutated afterwards.
|
||||||
cookieAudit *CookieAudit
|
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 handles mtime tracking + Apply callbacks (throttle=0 → eager).
|
||||||
watcher *reload.Watcher
|
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.
|
// visible to both startup-built and hot-reload-built proxies.
|
||||||
p.ModifyResponse = func(resp *http.Response) error {
|
p.ModifyResponse = func(resp *http.Response) error {
|
||||||
resp.Header.Set("X-SecuBox-WAF", "inspected")
|
resp.Header.Set("X-SecuBox-WAF", "inspected")
|
||||||
|
reqHost := ""
|
||||||
|
if resp.Request != nil {
|
||||||
|
reqHost = resp.Request.Host
|
||||||
|
}
|
||||||
if ca := r.cookieAudit; ca != nil {
|
if ca := r.cookieAudit; ca != nil {
|
||||||
reqHost := ""
|
|
||||||
if resp.Request != nil {
|
|
||||||
reqHost = resp.Request.Host
|
|
||||||
}
|
|
||||||
ca.Record(reqHost, resp.Request, resp)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
p.ErrorHandler = func(w http.ResponseWriter, req *http.Request, err error) {
|
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,
|
"events_24h_total": 0,
|
||||||
"mitm": {"connections": 0, "tls_pinned": 0, "unique_hosts": 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:
|
try:
|
||||||
with _sq3.connect("/var/lib/secubox/toolbox/toolbox.db", timeout=2) as c:
|
with _sq3.connect("/var/lib/secubox/toolbox/toolbox.db", timeout=2) as c:
|
||||||
since = int(time.time()) - 86400
|
|
||||||
rows = c.execute(
|
rows = c.execute(
|
||||||
"SELECT source, COUNT(*) FROM events WHERE ts > ? GROUP BY source",
|
"SELECT source, COUNT(*) FROM events WHERE ts > ? GROUP BY source",
|
||||||
(since,),
|
(since,),
|
||||||
|
|
@ -3791,6 +3795,17 @@ async def admin_metrics() -> dict:
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
metrics["sqlite_error"] = str(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"
|
# Live MITM activity. NOTE: the old journal-scrape for "server connect"
|
||||||
# NEVER worked — the workers run at --log-level warning, so those INFO lines
|
# 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
|
# are never emitted → the trio was permanently 0. Derive from real data: the
|
||||||
|
|
@ -3799,8 +3814,10 @@ async def admin_metrics() -> dict:
|
||||||
try:
|
try:
|
||||||
cs = cumulative.get_cached() or {}
|
cs = cumulative.get_cached() or {}
|
||||||
ev = cs.get("events", {}) or {}
|
ev = cs.get("events", {}) or {}
|
||||||
# "connections analysées" — DPI classifies one flow per upstream connection.
|
# "connections analysées" — each JA4 observation is one inspected TLS
|
||||||
metrics["mitm"]["connections"] = int(ev.get("dpi", 0) or 0)
|
# 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 [])
|
metrics["mitm"]["unique_hosts"] = len(cs.get("top_hosts_7d", []) or [])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="ads-section" class="grid">
|
<div id="ads-section" class="grid">
|
||||||
<div class="card" style="grid-column:1/-1">
|
<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 class="kv" id="ads-kpi"><span class="k">loading…</span><span class="v"></span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" style="grid-column:1/-1">
|
<div class="card" style="grid-column:1/-1">
|
||||||
|
|
@ -191,7 +191,7 @@
|
||||||
<div id="ads-sites"><div class="empty">loading…</div></div>
|
<div id="ads-sites"><div class="empty">loading…</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" style="grid-column:1/-1">
|
<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-visitors"><div class="empty">loading…</div></div>
|
||||||
<div id="ads-client-detail"></div>
|
<div id="ads-client-detail"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -617,24 +617,24 @@ async function loadAds() {
|
||||||
const kpi = document.getElementById('ads-kpi');
|
const kpi = document.getElementById('ads-kpi');
|
||||||
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
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; }
|
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>`
|
kpi.innerHTML = `<span class="k">Trackers & pubs bloqués</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>`
|
+ ` <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">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>`;
|
+ ` <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('');
|
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
|
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>'
|
? '<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">aucune pub bloquée dans la fenêtre</div>';
|
: '<div class="empty">rien de bloqué dans la fenêtre</div>';
|
||||||
document.getElementById('ads-sites').innerHTML = siteRows
|
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 visRows = (d.top_visitors||[]).slice(0, 5).map(r=>{
|
||||||
const mh = esc(r.mac_hash);
|
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>`;
|
return `<tr><td><a href="#" onclick="loadAdsClient('${mh}');return false;"><code>${mh}</code></a></td><td>${r.hits}</td></tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
document.getElementById('ads-visitors').innerHTML = visRows
|
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>';
|
: '<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));
|
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; }
|
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('');
|
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
|
+ (rows
|
||||||
? '<table><thead><tr><th>Ad host</th><th>bloqués</th><th>Ko</th></tr></thead><tbody>'+rows+'</tbody></table>'
|
? '<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>');
|
: '<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()
|
cs_alerts = _get_crowdsec_alerts()
|
||||||
except Exception:
|
except Exception:
|
||||||
cs_alerts = []
|
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
|
# Defensive: a CrowdSec-overlay failure must NEVER abort the refresh and
|
||||||
# leave the warm cache frozen — the WAF top_ips/top_vhosts/tracked-attacker
|
# 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
|
# 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")
|
@app.get("/stats")
|
||||||
async def get_stats():
|
async def get_stats():
|
||||||
"""Threat statistics for the dashboard (public).
|
"""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 class="stat-card"><div class="emoji">🛡️</div><div class="value" id="protectedSites">0</div><div class="label">Protected</div></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">
|
<div class="grid-2">
|
||||||
<!-- Security Events (combined alerts/bans) -->
|
<!-- Security Events (combined alerts/bans) -->
|
||||||
<div class="glass-card">
|
<div class="glass-card">
|
||||||
|
|
@ -700,6 +719,19 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
@ -1154,7 +1186,8 @@
|
||||||
loadStats(),
|
loadStats(),
|
||||||
loadSecurityEvents(),
|
loadSecurityEvents(),
|
||||||
loadCategories(),
|
loadCategories(),
|
||||||
loadTrackedAttackers()
|
loadTrackedAttackers(),
|
||||||
|
loadVisits()
|
||||||
]);
|
]);
|
||||||
btn.innerHTML = '🔄<span class="quick-btn-tooltip">Refresh</span>';
|
btn.innerHTML = '🔄<span class="quick-btn-tooltip">Refresh</span>';
|
||||||
}
|
}
|
||||||
|
|
@ -1195,6 +1228,52 @@
|
||||||
}).join('');
|
}).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
|
// WAF Toggle
|
||||||
let wafActive = true;
|
let wafActive = true;
|
||||||
async function toggleWaf() {
|
async function toggleWaf() {
|
||||||
|
|
@ -1263,6 +1342,7 @@
|
||||||
btn.innerHTML = '📤<span class="quick-btn-tooltip">Export</span>';
|
btn.innerHTML = '📤<span class="quick-btn-tooltip">Export</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switchTab('overview');
|
||||||
refresh();
|
refresh();
|
||||||
setInterval(refresh, 30000);
|
setInterval(refresh, 30000);
|
||||||
// More frequent live updates
|
// More frequent live updates
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user