mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 09:14:00 +00:00
build(annuaire): package fleet panel + metrics-publish timer + sbx-fleetctl install + /fleet routing (fleet-metrics)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
parent
e5ffe91cd0
commit
9bfcbe5d9c
|
|
@ -1,3 +1,21 @@
|
|||
secubox-annuaire (0.10.0-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* feat(fleet): fleet metrics — every node signs a MetricSnapshot
|
||||
(CPU/mem/disk/load1/uptime/modules_down/counters) with its own sovereign
|
||||
node key and publishes it via secubox-metrics-publish.timer (60s,
|
||||
User=secubox, sbx-fleetctl publish) into a dedicated last-writer-wins
|
||||
store (/var/lib/secubox/annuaire/fleet/self.json). Peers pull it
|
||||
verbatim over the existing :8799 mesh listener (GET /fleet/self, public
|
||||
— mirrors /log/export); GET /fleet (JWT) aggregates self + verified
|
||||
peers with health/stale annotations. Ships the /fleet panel (matrix of
|
||||
nodes, health dot, cpu/mem/disk bars, "vu il y a Ns", XSS-strict:
|
||||
createElement/textContent only, no innerHTML, no inline handlers) and
|
||||
the menu.d/595-fleet.json entry. `sbx-fleetctl` now actually installs to
|
||||
/usr/sbin (fixes the prior task's install gap); [metrics] fleet_publish
|
||||
documented in secubox.conf.example as the per-box opt-out.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Mon, 27 Jul 2026 11:00:00 +0200
|
||||
|
||||
secubox-annuaire (0.9.0-1~bookworm1) bookworm; urgency=high
|
||||
|
||||
* merge: unify the annuaire trust substrate — the assist marketplace layer
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ case "$1" in
|
|||
install -d -m 0755 /var/lib/secubox/annuaire /var/log/secubox/annuaire 2>/dev/null || true
|
||||
chown -R secubox:secubox /var/lib/secubox/annuaire /var/log/secubox/annuaire 2>/dev/null || true
|
||||
|
||||
# Fleet metrics store (fleet-metrics, Task 5): secubox-metrics-publish.timer
|
||||
# runs sbx-fleetctl publish as User=secubox, so this specific leaf dir
|
||||
# (never the shared /var/lib/secubox parent) must be secubox-owned for
|
||||
# it to write self.json.
|
||||
install -d -o secubox -g secubox -m 0755 /var/lib/secubox/annuaire/fleet 2>/dev/null || true
|
||||
|
||||
# Node-key secret dir (annuairectl init writes node.key here, 0600 secubox).
|
||||
# CSPN: secrets under /etc/secubox/secrets/, 0700, owned by the service user.
|
||||
install -d -m 0700 /etc/secubox/secrets/annuaire 2>/dev/null || true
|
||||
|
|
@ -98,6 +104,11 @@ case "$1" in
|
|||
# The WAF hook: bridge local crowdsec bans into the mesh. Safe no-op
|
||||
# where crowdsec is absent, so enabling by default is fine.
|
||||
systemctl enable --now secubox-threatmesh-bridge.timer 2>/dev/null || true
|
||||
# Fleet metrics publish (fleet-metrics, Task 5): signs + persists this
|
||||
# box's own MetricSnapshot every 60s. [metrics] fleet_publish=false in
|
||||
# secubox.conf makes sbx-fleetctl publish a documented no-op, so
|
||||
# enabling the timer by default is safe.
|
||||
systemctl enable --now secubox-metrics-publish.timer 2>/dev/null || true
|
||||
if systemctl is-active --quiet nginx 2>/dev/null; then
|
||||
nginx -t >/dev/null 2>&1 && systemctl reload nginx 2>/dev/null || true
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ override_dh_auto_install:
|
|||
install -d $(CURDIR)/debian/secubox-annuaire/etc/nginx/secubox.d
|
||||
install -m 644 $(CURDIR)/nginx/annuaire.conf $(CURDIR)/debian/secubox-annuaire/etc/nginx/secubox.d/
|
||||
install -m 644 $(CURDIR)/nginx/centers.conf $(CURDIR)/debian/secubox-annuaire/etc/nginx/secubox.d/
|
||||
install -m 644 $(CURDIR)/nginx/fleet.conf $(CURDIR)/debian/secubox-annuaire/etc/nginx/secubox.d/
|
||||
|
||||
# Authoritative API route (secubox-routes.d)
|
||||
install -d $(CURDIR)/debian/secubox-annuaire/etc/nginx/secubox-routes.d
|
||||
|
|
@ -32,6 +33,9 @@ override_dh_auto_install:
|
|||
# Box operator CLI — delegated center grants (Centres & Grants, Task 7)
|
||||
install -m 755 $(CURDIR)/sbin/sbx-centersctl $(CURDIR)/debian/secubox-annuaire/usr/sbin/sbx-centersctl
|
||||
|
||||
# Box operator CLI — fleet metrics publish (fleet-metrics, Task 5)
|
||||
install -m 755 $(CURDIR)/sbin/sbx-fleetctl $(CURDIR)/debian/secubox-annuaire/usr/sbin/sbx-fleetctl
|
||||
|
||||
# Non-local bind sysctl (nginx binds the wg-mesh IP even if iface is late)
|
||||
install -d $(CURDIR)/debian/secubox-annuaire/etc/sysctl.d
|
||||
install -m 644 $(CURDIR)/sysctl/30-secubox-nonlocal-bind.conf $(CURDIR)/debian/secubox-annuaire/etc/sysctl.d/
|
||||
|
|
@ -44,10 +48,15 @@ override_dh_auto_install:
|
|||
install -d $(CURDIR)/debian/secubox-annuaire/usr/share/secubox/www/centers
|
||||
cp -r $(CURDIR)/www/centers/* $(CURDIR)/debian/secubox-annuaire/usr/share/secubox/www/centers/
|
||||
|
||||
# Web UI (static) — Flotte / fleet metrics panel (fleet-metrics, Task 5)
|
||||
install -d $(CURDIR)/debian/secubox-annuaire/usr/share/secubox/www/fleet
|
||||
install -m 644 $(CURDIR)/www/fleet/* $(CURDIR)/debian/secubox-annuaire/usr/share/secubox/www/fleet/
|
||||
|
||||
# Hub menu entry
|
||||
install -d $(CURDIR)/debian/secubox-annuaire/usr/share/secubox/menu.d
|
||||
install -m 644 $(CURDIR)/menu.d/575-annuaire.json $(CURDIR)/debian/secubox-annuaire/usr/share/secubox/menu.d/
|
||||
install -m 644 $(CURDIR)/menu.d/570-centers.json $(CURDIR)/debian/secubox-annuaire/usr/share/secubox/menu.d/
|
||||
install -m 644 $(CURDIR)/menu.d/595-fleet.json $(CURDIR)/debian/secubox-annuaire/usr/share/secubox/menu.d/
|
||||
|
||||
# systemd service
|
||||
install -d $(CURDIR)/debian/secubox-annuaire/usr/lib/systemd/system
|
||||
|
|
@ -63,6 +72,9 @@ override_dh_auto_install:
|
|||
install -m 755 $(CURDIR)/sbin/sbx-threatmesh-bridge $(CURDIR)/debian/secubox-annuaire/usr/sbin/sbx-threatmesh-bridge
|
||||
install -m 644 $(CURDIR)/systemd/secubox-threatmesh-bridge.service $(CURDIR)/debian/secubox-annuaire/usr/lib/systemd/system/
|
||||
install -m 644 $(CURDIR)/systemd/secubox-threatmesh-bridge.timer $(CURDIR)/debian/secubox-annuaire/usr/lib/systemd/system/
|
||||
# Fleet metrics publish timer + oneshot (fleet-metrics, Task 5)
|
||||
install -m 644 $(CURDIR)/systemd/secubox-metrics-publish.service $(CURDIR)/debian/secubox-annuaire/usr/lib/systemd/system/
|
||||
install -m 644 $(CURDIR)/systemd/secubox-metrics-publish.timer $(CURDIR)/debian/secubox-annuaire/usr/lib/systemd/system/
|
||||
install -d $(CURDIR)/debian/secubox-annuaire/etc/secubox
|
||||
install -m 644 $(CURDIR)/conf/annuaire-apply.conf.example $(CURDIR)/debian/secubox-annuaire/etc/secubox/
|
||||
|
||||
|
|
|
|||
10
packages/secubox-annuaire/menu.d/595-fleet.json
Normal file
10
packages/secubox-annuaire/menu.d/595-fleet.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "fleet",
|
||||
"name": "Flotte",
|
||||
"icon": "🛰",
|
||||
"path": "/fleet/",
|
||||
"category": "mesh",
|
||||
"order": 595,
|
||||
"description": "Vue mesh des instantanés de métriques signés par nœud — CPU/mém/disque/load, modules down, compteurs",
|
||||
"requires": ["secubox-annuaire"]
|
||||
}
|
||||
12
packages/secubox-annuaire/nginx/fleet.conf
Normal file
12
packages/secubox-annuaire/nginx/fleet.conf
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# /etc/nginx/secubox.d/fleet.conf — Installed by secubox-annuaire.
|
||||
# Static "Flotte" UI (Task 5, fleet-metrics). It calls the same
|
||||
# /api/v1/annuaire/ API prefix as www/annuaire/ and www/centers/ (routed by
|
||||
# secubox-routes.d/annuaire-routes.conf, already installed) — no separate
|
||||
# API route needed here; /fleet/self and /fleet ride the existing annuaire
|
||||
# API mount.
|
||||
|
||||
location /fleet/ {
|
||||
alias /usr/share/secubox/www/fleet/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /fleet/index.html;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
[Unit]
|
||||
Description=SecuBox Annuaire — publish this node's signed fleet metrics snapshot (fleet-metrics)
|
||||
Documentation=file:///usr/share/doc/secubox-annuaire/README
|
||||
After=secubox-annuaire.service
|
||||
Wants=secubox-annuaire.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=secubox
|
||||
Group=secubox
|
||||
# Signs with the box's own node.key (owned by secubox) — never root. A
|
||||
# missing/disabled config degrades to a no-op (see sbx-fleetctl --help).
|
||||
ExecStart=/usr/sbin/sbx-fleetctl publish
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
[Unit]
|
||||
Description=SecuBox Annuaire — periodic fleet metrics publish (fleet-metrics)
|
||||
|
||||
[Timer]
|
||||
OnBootSec=90s
|
||||
OnUnitActiveSec=60s
|
||||
RandomizedDelaySec=10s
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
122
packages/secubox-annuaire/tests/test_fleet_menu.py
Normal file
122
packages/secubox-annuaire/tests/test_fleet_menu.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
# 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 :: secubox-annuaire :: tests/test_fleet_menu.py
|
||||
Menu.d entry + /fleet panel coverage (Task 5, feat/fleet-metrics).
|
||||
|
||||
The panel is XSS-STRICT (stricter than sibling panels like www/centers):
|
||||
NO inline on*= handlers (event delegation only) AND NO innerHTML anywhere
|
||||
for API-sourced data — rows are built with createElement/textContent/dataset
|
||||
only. Mirrors the skin (hybrid-dark, sidebar.js, sbx_token) of
|
||||
www/centers/index.html.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MENU_PATH = ROOT / "menu.d" / "595-fleet.json"
|
||||
HTML_PATH = ROOT / "www" / "fleet" / "index.html"
|
||||
|
||||
VALID_CATEGORIES = {"auth", "wall", "boot", "mind", "root", "mesh"}
|
||||
|
||||
|
||||
def _html() -> str:
|
||||
return HTML_PATH.read_text()
|
||||
|
||||
|
||||
# ── menu.d/595-fleet.json ────────────────────────────────────────────────
|
||||
|
||||
def test_menu_valid():
|
||||
m = json.loads(MENU_PATH.read_text())
|
||||
assert m.get("category") in VALID_CATEGORIES
|
||||
assert "/fleet" in json.dumps(m)
|
||||
|
||||
|
||||
def test_menu_has_name_and_category_keys():
|
||||
m = json.loads(MENU_PATH.read_text())
|
||||
assert m.get("name")
|
||||
assert m.get("category") == "mesh"
|
||||
|
||||
|
||||
def test_menu_path_is_fleet():
|
||||
m = json.loads(MENU_PATH.read_text())
|
||||
assert m.get("path") == "/fleet/"
|
||||
|
||||
|
||||
# ── panel skin / navbar ──────────────────────────────────────────────────
|
||||
|
||||
def test_panel_uses_shared_skin_and_sidebar():
|
||||
html = _html()
|
||||
assert '<nav class="sidebar" id="sidebar">' in html
|
||||
assert '/shared/sidebar.js' in html
|
||||
assert 'hybrid-dark' in html
|
||||
|
||||
|
||||
def test_panel_uses_sbx_token():
|
||||
html = _html()
|
||||
assert "sbx_token" in html
|
||||
assert "localStorage.getItem('jwt_token')" not in html
|
||||
assert "localStorage.getItem('token')" not in html
|
||||
|
||||
|
||||
def test_panel_calls_fleet_api():
|
||||
html = _html()
|
||||
assert "/api/v1/annuaire" in html
|
||||
assert "'/fleet'" in html or '"/fleet"' in html
|
||||
|
||||
|
||||
# ── XSS-STRICT: no inline handlers, no innerHTML for API data ───────────
|
||||
|
||||
def test_no_inline_event_handlers():
|
||||
html = _html()
|
||||
stripped = re.sub(r"<!--.*?-->", "", html, flags=re.S)
|
||||
stripped = re.sub(r"(?<!:)//[^\n]*", "", stripped)
|
||||
offenders = re.findall(r'\son\w+\s*=\s*"', stripped)
|
||||
assert offenders == [], f"inline event handler(s) found: {offenders}"
|
||||
|
||||
|
||||
def test_no_innerhtml_anywhere():
|
||||
"""Stricter than sibling panels: fleet rows must be built with
|
||||
createElement/textContent/dataset, never innerHTML, even escaped."""
|
||||
html = _html()
|
||||
assert "innerHTML" not in html
|
||||
|
||||
|
||||
def test_uses_create_element_and_text_content():
|
||||
html = _html()
|
||||
assert "createElement" in html
|
||||
assert "textContent" in html
|
||||
|
||||
|
||||
def test_uses_event_delegation_with_dataset():
|
||||
html = _html()
|
||||
assert "dataset" in html
|
||||
assert "addEventListener('click'" in html or 'addEventListener("click"' in html
|
||||
|
||||
|
||||
# ── health / staleness rendering ──────────────────────────────────────────
|
||||
|
||||
def test_health_states_represented():
|
||||
html = _html()
|
||||
for state in ("down", "warn", "ok"):
|
||||
assert state in html
|
||||
|
||||
|
||||
def test_stale_handling_present():
|
||||
html = _html()
|
||||
assert "stale" in html
|
||||
|
||||
|
||||
def test_refresh_interval_around_10s():
|
||||
html = _html()
|
||||
assert "10000" in html or "10_000" in html
|
||||
|
||||
|
||||
# ── graceful degradation ─────────────────────────────────────────────────
|
||||
|
||||
def test_fetch_is_guarded_with_try_catch():
|
||||
html = _html()
|
||||
assert "try {" in html and "catch" in html
|
||||
103
packages/secubox-annuaire/tests/test_fleet_packaging.py
Normal file
103
packages/secubox-annuaire/tests/test_fleet_packaging.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# 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 :: secubox-annuaire :: tests/test_fleet_packaging.py
|
||||
Packaging coverage for fleet metrics (Task 5, feat/fleet-metrics):
|
||||
the publisher systemd unit/timer, debian/rules install lines, and
|
||||
debian/postinst (fleet data dir ownership + no shared-parent chown).
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
# ── publisher unit / timer ───────────────────────────────────────────────
|
||||
|
||||
def test_publisher_unit_non_root_oneshot():
|
||||
svc = (ROOT / "systemd" / "secubox-metrics-publish.service").read_text()
|
||||
assert "User=secubox" in svc
|
||||
assert "sbx-fleetctl publish" in svc
|
||||
assert "Type=oneshot" in svc
|
||||
|
||||
|
||||
def test_publisher_timer_schedule():
|
||||
tmr = (ROOT / "systemd" / "secubox-metrics-publish.timer").read_text()
|
||||
assert "OnUnitActiveSec" in tmr
|
||||
assert "OnBootSec=90s" in tmr
|
||||
assert "OnUnitActiveSec=60s" in tmr
|
||||
assert "WantedBy=timers.target" in tmr
|
||||
|
||||
|
||||
# ── debian/postinst ───────────────────────────────────────────────────────
|
||||
|
||||
def test_postinst_no_shared_parent_chown_and_fleetdir():
|
||||
p = (ROOT / "debian" / "postinst").read_text()
|
||||
for bad in (
|
||||
"chown -R secubox /run/secubox",
|
||||
"chown -R secubox /etc/secubox",
|
||||
"chown -R secubox /var/lib/secubox",
|
||||
):
|
||||
assert bad not in p, f"forbidden shared-parent chown found: {bad!r}"
|
||||
assert "/var/lib/secubox/annuaire/fleet" in p
|
||||
assert "#DEBHELPER#" in p
|
||||
|
||||
|
||||
def test_postinst_enables_metrics_publish_timer():
|
||||
p = (ROOT / "debian" / "postinst").read_text()
|
||||
assert "secubox-metrics-publish.timer" in p
|
||||
assert "daemon-reload" in p
|
||||
|
||||
|
||||
def test_debhelper_token_alone_on_its_line():
|
||||
p = (ROOT / "debian" / "postinst").read_text()
|
||||
lines = [l.strip() for l in p.splitlines()]
|
||||
assert "#DEBHELPER#" in lines
|
||||
|
||||
|
||||
# ── debian/rules install lines ───────────────────────────────────────────
|
||||
|
||||
def test_rules_installs_sbx_fleetctl_to_usr_sbin():
|
||||
rules = (ROOT / "debian" / "rules").read_text()
|
||||
assert "sbin/sbx-fleetctl" in rules
|
||||
assert "usr/sbin" in rules
|
||||
|
||||
|
||||
def test_rules_installs_metrics_publish_units():
|
||||
rules = (ROOT / "debian" / "rules").read_text()
|
||||
assert "secubox-metrics-publish.service" in rules
|
||||
assert "secubox-metrics-publish.timer" in rules
|
||||
assert "lib/systemd/system" in rules
|
||||
|
||||
|
||||
def test_rules_installs_www_fleet():
|
||||
rules = (ROOT / "debian" / "rules").read_text()
|
||||
assert "www/fleet" in rules
|
||||
|
||||
|
||||
def test_rules_installs_menu_and_nginx():
|
||||
rules = (ROOT / "debian" / "rules").read_text()
|
||||
assert "595-fleet.json" in rules
|
||||
assert "fleet.conf" in rules
|
||||
|
||||
|
||||
def test_annuaire_source_modules_exist_on_disk():
|
||||
"""fleet.py/fleet_store.py/metrics_collect.py already ship via the
|
||||
existing `cp -r annuaire/*` glob in override_dh_auto_install — verify
|
||||
the source files are actually present so that glob has something to copy."""
|
||||
for mod in ("fleet.py", "fleet_store.py", "metrics_collect.py"):
|
||||
assert (ROOT / "annuaire" / mod).exists(), f"missing annuaire/{mod}"
|
||||
|
||||
|
||||
# ── changelog ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_changelog_bumped_for_fleet_metrics():
|
||||
head = (ROOT / "debian" / "changelog").read_text().splitlines()[0]
|
||||
assert "secubox-annuaire (" in head
|
||||
# version must be strictly newer than the pre-task-5 0.9.0
|
||||
import re
|
||||
m = re.search(r"\((\d+)\.(\d+)\.(\d+)-", head)
|
||||
assert m, f"unparseable changelog head: {head!r}"
|
||||
major, minor, patch = (int(x) for x in m.groups())
|
||||
assert (major, minor, patch) > (0, 9, 0)
|
||||
309
packages/secubox-annuaire/www/fleet/index.html
Normal file
309
packages/secubox-annuaire/www/fleet/index.html
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
<!DOCTYPE html>
|
||||
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
|
||||
<html lang="fr" class="hybrid-dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SecuBox — Flotte</title>
|
||||
<link rel="stylesheet" href="/shared/hybrid-dark.css">
|
||||
<style>
|
||||
:root{
|
||||
--p31-dim:#6b7b8b; --bg-dark:#0d1117; --bg-card:rgba(30,40,55,.8);
|
||||
--bg-row:rgba(20,30,45,.6); --border:rgba(100,150,200,.2); --text:#e8e6d9;
|
||||
--cyan:#00d4ff; --red:#ff4466; --green:#00dd44; --yellow:#ffcc00;
|
||||
}
|
||||
body{font-family:'Courier Prime',monospace;color:var(--text);background:var(--bg-dark)}
|
||||
.header{display:flex;justify-content:space-between;align-items:center;gap:.6rem;flex-wrap:wrap;margin-bottom:1rem}
|
||||
.header h1{font-size:1.3rem;color:var(--cyan);text-shadow:0 0 10px var(--cyan);margin:0}
|
||||
.header .sub{font-size:.72rem;color:var(--p31-dim);margin-top:.2rem}
|
||||
.dot{display:inline-block;width:.6rem;height:.6rem;border-radius:50%;background:var(--p31-dim);margin-right:.35rem;vertical-align:middle}
|
||||
.dot.on{background:var(--green);box-shadow:0 0 6px var(--green)} .dot.off{background:var(--red)}
|
||||
.dot.ok{background:var(--green);box-shadow:0 0 6px var(--green)}
|
||||
.dot.warn{background:var(--yellow);box-shadow:0 0 6px var(--yellow)}
|
||||
.dot.down{background:var(--red);box-shadow:0 0 6px var(--red)}
|
||||
.dot.stale{background:var(--p31-dim);box-shadow:none}
|
||||
.btns{display:flex;flex-wrap:wrap;gap:.4rem}
|
||||
.btn{font-family:inherit;font-size:.7rem;text-transform:uppercase;letter-spacing:.05em;text-decoration:none;
|
||||
color:var(--text);background:var(--bg-row);border:1px solid var(--border);border-radius:6px;
|
||||
padding:.45rem .8rem;cursor:pointer;transition:border-color .15s,box-shadow .15s}
|
||||
.btn:hover{border-color:var(--cyan);box-shadow:0 0 8px rgba(0,212,255,.4)}
|
||||
.btn.small{padding:.2rem .45rem;font-size:.62rem}
|
||||
.card{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:1rem;margin-bottom:1rem;backdrop-filter:blur(10px)}
|
||||
.card h3{font-size:.8rem;color:var(--cyan);text-transform:uppercase;letter-spacing:.08em;margin-bottom:.6rem}
|
||||
.note{font-size:.76rem;color:var(--p31-dim);line-height:1.5;margin-top:.5rem}
|
||||
table.matrix{width:100%;border-collapse:collapse;font-size:.78rem}
|
||||
table.matrix th,table.matrix td{padding:.5rem .6rem;border-bottom:1px solid var(--border);text-align:left;vertical-align:middle}
|
||||
table.matrix th{color:var(--p31-dim);text-transform:uppercase;font-size:.66rem;letter-spacing:.06em}
|
||||
.empty-cell{color:var(--p31-dim);font-style:italic;font-size:.74rem}
|
||||
tr.node-row.is-stale{opacity:.45}
|
||||
.bar-wrap{display:inline-block;width:5rem;height:.5rem;background:var(--bg-row);border:1px solid var(--border);border-radius:3px;overflow:hidden;vertical-align:middle;margin-right:.4rem}
|
||||
.bar-fill{display:block;height:100%;background:var(--cyan)}
|
||||
.bar-fill.warn{background:var(--yellow)}
|
||||
.bar-fill.crit{background:var(--red)}
|
||||
.cell-inner{display:flex;align-items:center;gap:.3rem;flex-wrap:wrap}
|
||||
.counter-badge{display:inline-block;padding:.1rem .35rem;border-radius:4px;background:var(--bg-row);
|
||||
border:1px solid var(--border);font-size:.66rem;color:var(--text);margin-right:.25rem}
|
||||
.counter-badge.nonzero{color:var(--yellow);border-color:var(--yellow)}
|
||||
.module-badge{display:inline-block;padding:.1rem .35rem;border-radius:4px;background:var(--bg-row);
|
||||
border:1px solid var(--red);color:var(--red);font-size:.66rem;margin:.1rem .2rem 0 0}
|
||||
.empty{color:var(--p31-dim);font-size:.8rem;font-style:italic}
|
||||
.hostname-col{color:var(--cyan);white-space:nowrap}
|
||||
.toast{position:fixed;bottom:1rem;right:1rem;background:var(--cyan);color:#07121a;padding:.6rem 1rem;border-radius:6px;
|
||||
font-size:.8rem;opacity:0;pointer-events:none;transition:opacity .2s;z-index:50}
|
||||
.toast.show{opacity:1} .toast.error{background:var(--red);color:#fff}
|
||||
@media(max-width:768px){.main{margin-left:0}}
|
||||
</style>
|
||||
</head>
|
||||
<body class="hybrid-dark">
|
||||
<nav class="sidebar" id="sidebar"></nav>
|
||||
<script src="/shared/sidebar.js"></script>
|
||||
|
||||
<main class="main">
|
||||
<header class="header">
|
||||
<div>
|
||||
<h1>🛰 Flotte</h1>
|
||||
<div class="sub" id="sub"><span class="dot" id="dot"></span>chargement…</div>
|
||||
</div>
|
||||
<div class="btns">
|
||||
<button class="btn" id="btn-refresh">↻ Rafraîchir</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
<h3>Nœuds mesh</h3>
|
||||
<div class="note">Un instantané signé par nœud (dernier gagne) — publié toutes les 60s par
|
||||
<code>secubox-metrics-publish.timer</code>, agrégé et vérifié ici. Une ligne grisée /
|
||||
« hors-ligne » signifie un instantané périmé (aucune preuve fraîche de vie).</div>
|
||||
<div style="overflow-x:auto;margin-top:.6rem">
|
||||
<table class="matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nœud</th><th>CPU</th><th>Mém.</th><th>Disque</th><th>Load1</th>
|
||||
<th>Uptime</th><th>Compteurs</th><th>Modules down</th><th>Vu</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="fleet-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
const API = '/api/v1/annuaire';
|
||||
const REFRESH_MS = 10000;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function token(){ return localStorage.getItem('sbx_token') || ''; }
|
||||
function authHeaders(extra){
|
||||
return Object.assign({'Authorization': 'Bearer ' + token()}, extra || {});
|
||||
}
|
||||
function toast(msg, isErr){
|
||||
const t = $('toast'); t.textContent = msg;
|
||||
t.className = 'toast show' + (isErr ? ' error' : '');
|
||||
setTimeout(() => { t.className = 'toast'; }, 2600);
|
||||
}
|
||||
async function J(path, opts){
|
||||
const o = Object.assign({credentials: 'same-origin'}, opts || {});
|
||||
o.headers = authHeaders(o.headers);
|
||||
const r = await fetch(API + path, o);
|
||||
if (!r.ok) {
|
||||
let detail = 'HTTP ' + r.status;
|
||||
try { const b = await r.json(); if (b && b.detail) detail = b.detail; } catch (e) { /* ignore */ }
|
||||
throw new Error(detail);
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// Toutes les valeurs issues de l'API sont posées via textContent/dataset
|
||||
// uniquement — JAMAIS en assignant du HTML brut à un noeud (garde XSS plus
|
||||
// stricte que le panneau /centers). Aucune donnée d'API n'apparaît dans un
|
||||
// attribut d'événement inline ; les actions passent par un unique
|
||||
// addEventListener délégué qui lit `dataset`.
|
||||
|
||||
function clear(el){ while (el.firstChild) el.removeChild(el.firstChild); }
|
||||
|
||||
function agoStr(ts){
|
||||
if (!ts) return '—';
|
||||
const then = Date.parse(ts);
|
||||
if (Number.isNaN(then)) return '—';
|
||||
const s = Math.max(0, Math.floor((Date.now() - then) / 1000));
|
||||
if (s < 90) return 'il y a ' + s + 's';
|
||||
if (s < 5400) return 'il y a ' + Math.round(s / 60) + 'm';
|
||||
return 'il y a ' + Math.round(s / 3600) + 'h';
|
||||
}
|
||||
|
||||
function fmtUptime(sec){
|
||||
sec = Number(sec) || 0;
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
if (d > 0) return d + 'j ' + h + 'h';
|
||||
if (h > 0) return h + 'h ' + m + 'm';
|
||||
return m + 'm';
|
||||
}
|
||||
|
||||
function pctBarClass(pct){
|
||||
if (pct >= 90) return 'crit';
|
||||
if (pct >= 70) return 'warn';
|
||||
return '';
|
||||
}
|
||||
|
||||
function healthDot(healthState, isStale){
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'dot ' + (isStale ? 'stale' : (healthState || 'ok'));
|
||||
return dot;
|
||||
}
|
||||
|
||||
function pctCell(pct){
|
||||
const wrap = document.createElement('span');
|
||||
wrap.className = 'cell-inner';
|
||||
const barWrap = document.createElement('span');
|
||||
barWrap.className = 'bar-wrap';
|
||||
const fill = document.createElement('span');
|
||||
const v = Math.max(0, Math.min(100, Number(pct) || 0));
|
||||
fill.className = 'bar-fill ' + pctBarClass(v);
|
||||
fill.style.width = v + '%';
|
||||
barWrap.appendChild(fill);
|
||||
const label = document.createElement('span');
|
||||
label.textContent = v.toFixed(0) + '%';
|
||||
wrap.appendChild(barWrap);
|
||||
wrap.appendChild(label);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function countersCell(counters){
|
||||
const wrap = document.createElement('span');
|
||||
const c = counters || {};
|
||||
['bans', 'assist_sessions', 'soc_alerts'].forEach((key) => {
|
||||
const v = c[key] || 0;
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'counter-badge' + (v > 0 ? ' nonzero' : '');
|
||||
badge.textContent = key + ':' + v;
|
||||
wrap.appendChild(badge);
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function modulesDownCell(modulesDown){
|
||||
const wrap = document.createElement('span');
|
||||
const list = modulesDown || [];
|
||||
if (list.length === 0) {
|
||||
const ok = document.createElement('span');
|
||||
ok.className = 'empty';
|
||||
ok.textContent = 'aucun';
|
||||
wrap.appendChild(ok);
|
||||
return wrap;
|
||||
}
|
||||
list.forEach((name) => {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'module-badge';
|
||||
badge.textContent = name;
|
||||
wrap.appendChild(badge);
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function buildRow(node){
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'node-row' + (node.stale ? ' is-stale' : '');
|
||||
tr.dataset.nodeDid = node.node_did || '';
|
||||
|
||||
const hostTd = document.createElement('td');
|
||||
hostTd.className = 'hostname-col';
|
||||
const inner = document.createElement('span');
|
||||
inner.className = 'cell-inner';
|
||||
inner.appendChild(healthDot(node.health, node.stale));
|
||||
const hostSpan = document.createElement('span');
|
||||
hostSpan.textContent = node.hostname || '(inconnu)';
|
||||
inner.appendChild(hostSpan);
|
||||
hostTd.appendChild(inner);
|
||||
tr.appendChild(hostTd);
|
||||
|
||||
const cpuTd = document.createElement('td'); cpuTd.appendChild(pctCell(node.cpu_pct)); tr.appendChild(cpuTd);
|
||||
const memTd = document.createElement('td'); memTd.appendChild(pctCell(node.mem_pct)); tr.appendChild(memTd);
|
||||
const diskTd = document.createElement('td'); diskTd.appendChild(pctCell(node.disk_pct)); tr.appendChild(diskTd);
|
||||
|
||||
const loadTd = document.createElement('td');
|
||||
loadTd.textContent = (Number(node.load1) || 0).toFixed(2);
|
||||
tr.appendChild(loadTd);
|
||||
|
||||
const upTd = document.createElement('td');
|
||||
upTd.textContent = fmtUptime(node.uptime_s);
|
||||
tr.appendChild(upTd);
|
||||
|
||||
const countersTd = document.createElement('td');
|
||||
countersTd.appendChild(countersCell(node.counters));
|
||||
tr.appendChild(countersTd);
|
||||
|
||||
const modulesTd = document.createElement('td');
|
||||
modulesTd.appendChild(modulesDownCell(node.modules_down));
|
||||
tr.appendChild(modulesTd);
|
||||
|
||||
const seenTd = document.createElement('td');
|
||||
seenTd.textContent = node.stale ? 'hors-ligne' : agoStr(node.ts);
|
||||
tr.appendChild(seenTd);
|
||||
|
||||
return tr;
|
||||
}
|
||||
|
||||
function renderNodes(nodes){
|
||||
const body = $('fleet-body');
|
||||
clear(body);
|
||||
if (!nodes || nodes.length === 0) {
|
||||
const tr = document.createElement('tr');
|
||||
const td = document.createElement('td');
|
||||
td.colSpan = 9;
|
||||
td.className = 'empty-cell';
|
||||
td.textContent = 'aucun nœud publié';
|
||||
tr.appendChild(td);
|
||||
body.appendChild(tr);
|
||||
return;
|
||||
}
|
||||
nodes
|
||||
.slice()
|
||||
.sort((a, b) => (a.hostname || '').localeCompare(b.hostname || ''))
|
||||
.forEach((node) => body.appendChild(buildRow(node)));
|
||||
}
|
||||
|
||||
// Délégation d'événements : aucune interaction ne dépend d'un attribut
|
||||
// on*="..." inline ; data-node-did porte l'identifiant (jamais interprété
|
||||
// comme code, lu via dataset).
|
||||
$('fleet-body').addEventListener('click', (e) => {
|
||||
const row = e.target.closest('[data-node-did]');
|
||||
if (!row) return;
|
||||
// Réservé pour une future vue détaillée par nœud (data-node-did disponible).
|
||||
});
|
||||
|
||||
async function loadFleet(){
|
||||
try {
|
||||
const d = await J('/fleet');
|
||||
renderNodes(d.nodes || []);
|
||||
$('dot').className = 'dot on';
|
||||
const sub = $('sub');
|
||||
clear(sub);
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'dot on';
|
||||
sub.appendChild(dot);
|
||||
sub.appendChild(document.createTextNode((d.nodes || []).length + ' nœud(s)'));
|
||||
} catch (e) {
|
||||
$('dot').className = 'dot off';
|
||||
const sub = $('sub');
|
||||
clear(sub);
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'dot off';
|
||||
sub.appendChild(dot);
|
||||
sub.appendChild(document.createTextNode('flotte indisponible (' + e.message + ')'));
|
||||
renderNodes([]);
|
||||
toast('Flotte indisponible : ' + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
$('btn-refresh').addEventListener('click', loadFleet);
|
||||
|
||||
loadFleet();
|
||||
setInterval(loadFleet, REFRESH_MS);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -187,3 +187,19 @@ classifier_override = false
|
|||
# functional = ["^app_theme$"]
|
||||
# analytics = ["^my_analytics_"]
|
||||
# marketing = ["^my_ad_id$"]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Fleet metrics (fleet-metrics) — secubox-annuaire
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# secubox-metrics-publish.timer runs `sbx-fleetctl publish` every 60s, which
|
||||
# signs a MetricSnapshot (CPU/mem/disk/load/uptime/modules_down/counters)
|
||||
# with this box's own node key and persists it locally at
|
||||
# /var/lib/secubox/annuaire/fleet/self.json. The annuaire API serves it
|
||||
# verbatim on GET /fleet/self (public, mesh-only :8799 — mirrors /log/export)
|
||||
# and aggregates self + verified peers on the JWT-gated GET /fleet, backing
|
||||
# the /fleet panel.
|
||||
[metrics]
|
||||
# Set to false to opt this box OUT of publishing fleet metrics entirely —
|
||||
# sbx-fleetctl publish becomes a documented no-op (rc0, {"skipped": ...}).
|
||||
# The timer stays enabled either way; this is the operator's kill switch.
|
||||
fleet_publish = true
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user