mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-30 10:00:52 +00:00
Compare commits
9 Commits
2f6ca5478b
...
2b05a31ab2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b05a31ab2 | ||
| 626acede56 | |||
| 2f3785d7fe | |||
| 8c932b1b2b | |||
| bf0df9b0a1 | |||
| c536d71657 | |||
| 8f8dfb137c | |||
| 765de07ac8 | |||
| c687225e1b |
|
|
@ -0,0 +1,387 @@
|
|||
# Toolbox #clients — reset-all (#634) + device/geo emojis (#635) Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a "reset all clients" admin action (#634) and per-client device/geo emojis (#635) to the toolbox `#clients` tab.
|
||||
|
||||
**Architecture:** Reuse the existing per-client `store.reset_client` + `social.wipe_mac` in a small `api._reset_all_clients()` helper behind a new `POST /admin/clients/reset-all` route (kbin-gated). Enrich `admin_clients_rich()` with a real device emoji (`avatar_analysis.classify_user_agent` of the latest `consents.user_agent`) and geo (`geo.lookup(ip)` → flag + asn_org). UI: render them in `loadClients()` + a reset-all button.
|
||||
|
||||
**Tech Stack:** FastAPI (`api.py`), `store.py`, `social.py`, `avatar_analysis.py`, `geo.py`, vanilla JS `www/toolbox/index.html`. pytest. Issues #634 + #635.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-17-toolbox-clients-reset-all-and-emojis-design.md`.
|
||||
|
||||
**Conventions:** worktree `secubox-deb-worktrees/634-…` branch `feature/634-…`; commits end `(ref #634)` / `(ref #635)`.
|
||||
|
||||
**Verified facts:**
|
||||
- `store.list_clients() -> list[dict]` (mac_hash, ip, state, score, level, first_seen, last_seen; LIMIT 200). `store.reset_client(mac_hash) -> int`. `social.wipe_mac(mac_hash) -> int`.
|
||||
- `avatar_analysis.classify_user_agent(ua) -> dict` (keys incl. `device`, `device_emoji`). `geo.lookup(key) -> dict` (keys incl. `flag`, `country_iso`, `asn_org`).
|
||||
- `admin_clients_rich()` builds `enriched` dicts and sets `"device_emoji": "📱"` (placeholder to replace).
|
||||
- Per-client route `POST /admin/clients/{mac_hash}/reset` (api.py) has NO in-code kbin gate. `_is_public_kbin(request)` helper exists (used by `/admin/filter-control/*`) and returns True when `Host` starts with `kbin.`.
|
||||
- `loadClients()` (index.html) renders a 7-col table (MAC/IP/state/niveau/score/last/Actions), top-5; `#panel-clients` has a toolbar with a refresh button.
|
||||
- `consents` table has `user_agent` + `ts` (store.py schema).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `store.latest_user_agent`
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/secubox-toolbox/secubox_toolbox/store.py`
|
||||
- Test: `packages/secubox-toolbox/tests/test_clients_reset_emoji.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# packages/secubox-toolbox/tests/test_clients_reset_emoji.py
|
||||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
import importlib
|
||||
from secubox_toolbox import store
|
||||
|
||||
|
||||
def _tmpdb(tmp_path, monkeypatch):
|
||||
import pathlib
|
||||
monkeypatch.setattr(store, "DB_PATH", pathlib.Path(tmp_path / "toolbox.db"))
|
||||
# force schema creation
|
||||
with store._conn() as c:
|
||||
pass
|
||||
return store
|
||||
|
||||
|
||||
def test_latest_user_agent(tmp_path, monkeypatch):
|
||||
s = _tmpdb(tmp_path, monkeypatch)
|
||||
with s._conn() as c:
|
||||
c.execute("INSERT INTO consents(mac_hash,ts,ttl_seconds,ip,user_agent) "
|
||||
"VALUES('m1',100,3600,'1.2.3.4','OldUA')")
|
||||
c.execute("INSERT INTO consents(mac_hash,ts,ttl_seconds,ip,user_agent) "
|
||||
"VALUES('m1',200,3600,'1.2.3.4','Mozilla/5.0 (iPhone) NewUA')")
|
||||
assert s.latest_user_agent("m1") == "Mozilla/5.0 (iPhone) NewUA"
|
||||
assert s.latest_user_agent("nope") is None
|
||||
```
|
||||
|
||||
Note: `consents` has PRIMARY KEY `mac_hash` — so two rows for `m1` would conflict. Use `INSERT OR REPLACE`, OR (better) make the test insert one row per mac and assert it; adjust: since consents is keyed by mac_hash, the "latest" is simply the single row. REWRITE the test body to a single consent row per mac:
|
||||
|
||||
```python
|
||||
def test_latest_user_agent(tmp_path, monkeypatch):
|
||||
s = _tmpdb(tmp_path, monkeypatch)
|
||||
with s._conn() as c:
|
||||
c.execute("INSERT INTO consents(mac_hash,ts,ttl_seconds,ip,user_agent) "
|
||||
"VALUES('m1',200,3600,'1.2.3.4','Mozilla/5.0 (iPhone) UA')")
|
||||
assert s.latest_user_agent("m1") == "Mozilla/5.0 (iPhone) UA"
|
||||
assert s.latest_user_agent("nope") is None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd packages/secubox-toolbox && python -m pytest tests/test_clients_reset_emoji.py::test_latest_user_agent -q`
|
||||
Expected: FAIL — `AttributeError: module 'secubox_toolbox.store' has no attribute 'latest_user_agent'`
|
||||
|
||||
- [ ] **Step 3: Implement in `store.py`**
|
||||
|
||||
```python
|
||||
def latest_user_agent(mac_hash: str):
|
||||
"""Most recent recorded User-Agent for a client (from consents), or None."""
|
||||
try:
|
||||
with _conn() as c:
|
||||
row = c.execute(
|
||||
"SELECT user_agent FROM consents "
|
||||
"WHERE mac_hash=? AND user_agent IS NOT NULL AND user_agent<>'' "
|
||||
"ORDER BY ts DESC LIMIT 1", (mac_hash,)).fetchone()
|
||||
return row["user_agent"] if row else None
|
||||
except sqlite3.Error:
|
||||
return None
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd packages/secubox-toolbox && python -m pytest tests/test_clients_reset_emoji.py -q`
|
||||
Expected: PASS (1 passed)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/secubox-toolbox/secubox_toolbox/store.py packages/secubox-toolbox/tests/test_clients_reset_emoji.py
|
||||
git commit -m "feat(toolbox): store.latest_user_agent for client device detection (ref #635)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `_reset_all_clients` + `POST /admin/clients/reset-all` (#634)
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/secubox-toolbox/secubox_toolbox/api.py`
|
||||
- Test: `packages/secubox-toolbox/tests/test_clients_reset_emoji.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test (append)**
|
||||
|
||||
```python
|
||||
def test_reset_all_clients_loops(monkeypatch):
|
||||
import secubox_toolbox.api as api
|
||||
from secubox_toolbox import store as st, social as so
|
||||
calls = {"reset": [], "wipe": []}
|
||||
monkeypatch.setattr(st, "list_clients", lambda: [{"mac_hash": "a"}, {"mac_hash": "b"}])
|
||||
monkeypatch.setattr(st, "reset_client", lambda mh: calls["reset"].append(mh) or 3)
|
||||
monkeypatch.setattr(so, "wipe_mac", lambda mh: calls["wipe"].append(mh) or 2)
|
||||
out = api._reset_all_clients()
|
||||
assert calls["reset"] == ["a", "b"] and calls["wipe"] == ["a", "b"]
|
||||
assert out == {"ok": True, "clients_reset": 2, "rows_deleted": 10}
|
||||
|
||||
|
||||
def test_reset_all_clients_one_failure_continues(monkeypatch):
|
||||
import secubox_toolbox.api as api
|
||||
from secubox_toolbox import store as st, social as so
|
||||
monkeypatch.setattr(st, "list_clients", lambda: [{"mac_hash": "a"}, {"mac_hash": "b"}])
|
||||
def _rc(mh):
|
||||
if mh == "a":
|
||||
raise RuntimeError("boom")
|
||||
return 3
|
||||
monkeypatch.setattr(st, "reset_client", _rc)
|
||||
monkeypatch.setattr(so, "wipe_mac", lambda mh: 2)
|
||||
out = api._reset_all_clients()
|
||||
assert out["ok"] is True and out["clients_reset"] == 1 # 'a' failed, 'b' ok
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd packages/secubox-toolbox && python -m pytest tests/test_clients_reset_emoji.py -q`
|
||||
Expected: FAIL — `AttributeError: module 'secubox_toolbox.api' has no attribute '_reset_all_clients'`
|
||||
|
||||
- [ ] **Step 3: Implement in `api.py`**
|
||||
|
||||
Add the helper (near `admin_client_reset`):
|
||||
```python
|
||||
def _reset_all_clients() -> dict:
|
||||
"""Apply the per-client reset to every client (events/consents/reports +
|
||||
social graph wiped, score zeroed, client row kept). One client's failure
|
||||
is logged and skipped. Returns counts."""
|
||||
from . import social as _s
|
||||
clients_reset = 0
|
||||
rows_deleted = 0
|
||||
for c in store.list_clients():
|
||||
mh = c.get("mac_hash")
|
||||
if not mh:
|
||||
continue
|
||||
try:
|
||||
rows_deleted += store.reset_client(mh)
|
||||
rows_deleted += _s.wipe_mac(mh)
|
||||
clients_reset += 1
|
||||
except Exception as e:
|
||||
log.warning("reset-all: client %s failed: %s", str(mh)[:8], e)
|
||||
log.info("admin reset-all: %d clients, %d rows", clients_reset, rows_deleted)
|
||||
return {"ok": True, "clients_reset": clients_reset, "rows_deleted": rows_deleted}
|
||||
```
|
||||
Add the route (near the per-client reset route). reset-all is bulk-destructive, so gate it on the public-kbin vhost (defense-in-depth; more conservative than the per-client route):
|
||||
```python
|
||||
@router.post("/admin/clients/reset-all")
|
||||
async def admin_clients_reset_all(request: Request) -> dict:
|
||||
"""RAZ ALL clients (bulk per-client reset). Blocked on the public kbin vhost."""
|
||||
if _is_public_kbin(request):
|
||||
raise HTTPException(403, "reset-all disabled on public vhost — use admin.gk2.secubox.in/toolbox/")
|
||||
return _reset_all_clients()
|
||||
```
|
||||
(Confirm `Request` and `HTTPException` are imported in api.py — they are, used by `/admin/filter-control/*`.)
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd packages/secubox-toolbox && python -m pytest tests/test_clients_reset_emoji.py -q`
|
||||
Expected: PASS (3 passed). Then full suite `python -m pytest tests/ -q`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/secubox-toolbox/secubox_toolbox/api.py packages/secubox-toolbox/tests/test_clients_reset_emoji.py
|
||||
git commit -m "feat(toolbox): POST /admin/clients/reset-all (bulk per-client reset, kbin-gated) (ref #634)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: enrich `admin_clients_rich` with device + geo (#635)
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/secubox-toolbox/secubox_toolbox/api.py` (`admin_clients_rich`)
|
||||
- Test: `packages/secubox-toolbox/tests/test_clients_reset_emoji.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test (append)**
|
||||
|
||||
```python
|
||||
def test_clients_rich_enriches_device_and_geo(monkeypatch):
|
||||
import asyncio
|
||||
import secubox_toolbox.api as api
|
||||
from secubox_toolbox import store as st, geo as g, avatar_analysis as av
|
||||
monkeypatch.setattr(st, "list_clients", lambda: [
|
||||
{"mac_hash": "m1", "ip": "1.2.3.4", "state": "validated",
|
||||
"score": 10, "level": "r2", "first_seen": 0, "last_seen": 0}])
|
||||
monkeypatch.setattr(st, "latest_user_agent",
|
||||
lambda mh: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2)")
|
||||
monkeypatch.setattr(g, "lookup",
|
||||
lambda ip: {"flag": "🇫🇷", "country_iso": "FR", "asn_org": "OVH"})
|
||||
out = asyncio.get_event_loop().run_until_complete(api.admin_clients_rich())
|
||||
c = out["clients"][0]
|
||||
assert c["flag"] == "🇫🇷" and c["country_iso"] == "FR" and c["asn_org"] == "OVH"
|
||||
assert c["device_emoji"] and c["device_emoji"] != "📱" or c["device"] # real device from UA
|
||||
assert "device" in c
|
||||
```
|
||||
|
||||
(If `admin_clients_rich` is async, `asyncio` run is needed as above; if it's sync, call it directly — adapt during impl.)
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd packages/secubox-toolbox && python -m pytest tests/test_clients_reset_emoji.py::test_clients_rich_enriches_device_and_geo -q`
|
||||
Expected: FAIL — the enriched client lacks `flag`/`country_iso`/`asn_org`/`device`.
|
||||
|
||||
- [ ] **Step 3: Implement in `admin_clients_rich`**
|
||||
|
||||
Add imports at the top of the function:
|
||||
```python
|
||||
from . import avatar_analysis as _av, geo as _geo
|
||||
```
|
||||
Replace the `"device_emoji": "📱", # placeholder …` line in the `enriched.append({...})` with real enrichment computed just before the append:
|
||||
```python
|
||||
# #635 — real device emoji from the latest UA + country/hosting via geo
|
||||
dev_emoji, dev_label = "📱", ""
|
||||
try:
|
||||
ua = store.latest_user_agent(r.get("mac_hash") or "")
|
||||
if ua:
|
||||
cl = _av.classify_user_agent(ua)
|
||||
dev_emoji = cl.get("device_emoji") or dev_emoji
|
||||
dev_label = cl.get("device") or ""
|
||||
except Exception:
|
||||
pass
|
||||
flag = country_iso = asn_org = ""
|
||||
try:
|
||||
gi = _geo.lookup(r.get("ip") or "")
|
||||
flag = gi.get("flag", "") or ""
|
||||
country_iso = gi.get("country_iso", "") or ""
|
||||
asn_org = gi.get("asn_org", "") or ""
|
||||
except Exception:
|
||||
pass
|
||||
```
|
||||
and in the `enriched.append({...})` dict, replace `"device_emoji": "📱", …` with:
|
||||
```python
|
||||
"device_emoji": dev_emoji,
|
||||
"device": dev_label,
|
||||
"flag": flag,
|
||||
"country_iso": country_iso,
|
||||
"asn_org": asn_org,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd packages/secubox-toolbox && python -m pytest tests/test_clients_reset_emoji.py -q`
|
||||
Expected: PASS. Then full suite `python -m pytest tests/ -q`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/secubox-toolbox/secubox_toolbox/api.py packages/secubox-toolbox/tests/test_clients_reset_emoji.py
|
||||
git commit -m "feat(toolbox): clients/rich device emoji (UA) + country flag + hosting (geo) (ref #635)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: UI — `loadClients` render + reset-all button
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/secubox-toolbox/www/toolbox/index.html`
|
||||
|
||||
- [ ] **Step 1: Inspect the panel toolbar + loadClients**
|
||||
|
||||
Run: `cd packages/secubox-toolbox && grep -n "panel-clients\|loadClients\|id=\"clients\"\|resetClient" www/toolbox/index.html`
|
||||
Read the `#panel-clients` toolbar markup and the `loadClients` row render (verified above).
|
||||
|
||||
- [ ] **Step 2: Add the device/flag/hosting to the row render**
|
||||
|
||||
In `loadClients()`, add an `esc` helper if not already in scope (reuse the file's pattern) and extend the IP cell to show device + flag + hosting. Replace the table header + the IP cell:
|
||||
- Header: change `<th>IP</th>` to `<th>IP / type</th>`.
|
||||
- IP cell: replace `<td>${c.ip || '—'}</td>` with:
|
||||
```javascript
|
||||
<td>${c.ip || '—'} <span style="white-space:nowrap">${c.device_emoji||''} ${c.flag||''}</span>${c.asn_org ? `<br><span style="color:var(--p31-dim,#888);font-size:0.72rem" title="${escA(c.device||'')}">${escA(c.asn_org)}</span>` : ''}</td>
|
||||
```
|
||||
Add near the top of `loadClients` (or as a file-level helper if one exists):
|
||||
```javascript
|
||||
const escA = s => String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
```
|
||||
(`escA` escapes `"` too since `device` goes into a `title="..."` attribute.)
|
||||
|
||||
- [ ] **Step 3: Add the "Reset all" button to the panel toolbar**
|
||||
|
||||
In the `#panel-clients` toolbar (next to the existing refresh button), add:
|
||||
```html
|
||||
<button type="button" onclick="resetAllClients()" style="color:var(--red,#e63946)">↺ Reset all</button>
|
||||
```
|
||||
Add the handler near `resetClient`:
|
||||
```javascript
|
||||
async function resetAllClients() {
|
||||
if (!confirm('Remettre à zéro TOUS les clients ? (events, consentements, graphes social — scores remis à zéro, clients conservés)')) return;
|
||||
try {
|
||||
const r = await fetch(`${API}/admin/clients/reset-all`, {method:'POST'});
|
||||
const d = await r.json().catch(()=>({}));
|
||||
if (!r.ok) { alert('Reset all refusé: ' + (d.detail || r.status)); return; }
|
||||
await loadClients();
|
||||
if (typeof loadSocial === 'function') loadSocial();
|
||||
} catch (e) { alert('Reset all erreur: ' + e); }
|
||||
}
|
||||
```
|
||||
(Match the file's existing `API` const + `resetClient` style. If `resetClient` uses a different fetch idiom, mirror it.)
|
||||
|
||||
- [ ] **Step 4: Static verification**
|
||||
|
||||
Run from `packages/secubox-toolbox`:
|
||||
- `grep -n "resetAllClients\|IP / type\|escA\|device_emoji" www/toolbox/index.html` → all present.
|
||||
- `python -c "import pathlib;t=pathlib.Path('www/toolbox/index.html').read_text();assert t.count('<script')==t.count('</script>');assert 'resetAllClients' in t and 'IP / type' in t;print('ok')"`
|
||||
- `python -m pytest tests/ -q` (unchanged, frontend-only).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/secubox-toolbox/www/toolbox/index.html
|
||||
git commit -m "feat(toolbox): #clients UI — device/flag/hosting + Reset-all button (ref #634, #635)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: changelog + gate
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/secubox-toolbox/debian/changelog`
|
||||
|
||||
- [ ] **Step 1: Bump changelog**
|
||||
|
||||
`head -3 debian/changelog` (top is `2.6.49-1~bookworm1` on master). Add a NEW top entry `2.6.50-1~bookworm1`, dch format (2 spaces before date), author `Gerald KERMA <devel@cybermind.fr>`, dated 2026-06-17:
|
||||
```
|
||||
* #clients tab: bulk "reset all clients" admin action (#634, per-client reset
|
||||
applied to every client, kbin-gated); per-client device emoji (from UA),
|
||||
country flag + hosting/ASN via geo (#635).
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Gate**
|
||||
|
||||
Run: `cd packages/secubox-toolbox && python -m pytest tests/ -q` (expect all green).
|
||||
Run: `python -c "import ast; ast.parse(open('secubox_toolbox/api.py').read()); ast.parse(open('secubox_toolbox/store.py').read()); print('ok')"`.
|
||||
Run: `python -c "import pathlib;t=pathlib.Path('www/toolbox/index.html').read_text();assert t.count('<script')==t.count('</script>');print('html ok')"`.
|
||||
Run: `dpkg-parsechangelog -l debian/changelog | grep Version` → `2.6.50-1~bookworm1`.
|
||||
Run: `git status --short` → empty after commit.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/secubox-toolbox/debian/changelog
|
||||
git commit -m "chore(toolbox): changelog 2.6.50 for #clients reset-all + emojis (ref #634, #635)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- §2 reset-all (reuse reset_client+wipe_mac, keep rows, fail-continue, kbin gate) → Tasks 2, 4. ✓
|
||||
- §3 device (latest UA → classify) + geo (flag/asn) enrichment → Tasks 1, 3. ✓
|
||||
- §3/§4 UI render + reset-all button → Task 4. ✓
|
||||
- §4 error handling (per-client try/except in reset-all + enrichment; latest_ua OSError→None; escape free-text) → Tasks 1/2/3/4. ✓
|
||||
- §5 tests → Tasks 1/2/3. ✓
|
||||
- Packaging → Task 5. ✓
|
||||
- **Gate correction:** the per-client reset has NO in-code kbin gate (vhost-level); reset-all ADDS `_is_public_kbin` (defense-in-depth for the bulk destructive op) — recorded so it isn't read as inconsistent with §2's "identical" wording.
|
||||
|
||||
**Placeholder scan:** none — every code step has complete code. Task 1's test has a self-correcting note (consents PK is mac_hash → single-row form is the one to use); the corrected form is given explicitly.
|
||||
|
||||
**Type consistency:** `store.latest_user_agent(mac_hash)->str|None` (Task 1) used in Task 3. `_reset_all_clients()->dict{ok,clients_reset,rows_deleted}` (Task 2) called by the route + asserted in tests. `classify_user_agent`→`device_emoji`/`device`; `geo.lookup`→`flag`/`country_iso`/`asn_org` consistent between Task 3 impl and the UI render (Task 4) + tests. `escA` used in the UI for `asn_org`/`device`.
|
||||
|
||||
**Rollout:** cosmetic + an operator action; no gating/engine/nft/DNS impact; ships in toolbox 2.6.50.
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
# Toolbox #clients — reset-all (#634) + device/geo emojis (#635)
|
||||
|
||||
- **Date:** 2026-06-17
|
||||
- **Package:** `secubox-toolbox`
|
||||
- **Issues:** #634 (reset-all), #635 (emojis) — one branch closes both
|
||||
- **Status:** Design approved, pending implementation plan
|
||||
- **Origin:** operator requests on the live `#clients` toolbox tab.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Two small, independent additions to the toolbox webui `#clients` tab (panel
|
||||
`#panel-clients`, JS `loadClients()` → `GET /admin/clients/rich`):
|
||||
|
||||
- **#634 — Reset all:** a one-click "reset all clients" that applies the existing
|
||||
per-client reset to every client.
|
||||
- **#635 — Emojis:** show a real device-type emoji, a country flag, and the
|
||||
hosting/ASN per client (the row currently has level/risk/status emojis and a
|
||||
hardcoded `device_emoji:"📱"` placeholder).
|
||||
|
||||
### Decisions (brainstorming)
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Reset-all scope | **Per-client reset applied to all** — wipe events/consents/reports + all social-graph rows, zero scores, state=validated; **keep** client rows |
|
||||
| Device source | latest UA from `consents.user_agent` → `avatar_analysis.classify_user_agent` |
|
||||
| Geo source | `geo.lookup(ip)` (24h-cached) → `flag` + `asn_org` |
|
||||
|
||||
---
|
||||
|
||||
## 2. #634 — Reset all
|
||||
|
||||
**API.** New `POST /admin/clients/reset-all` in `api.py`:
|
||||
- Gated by `_is_public_kbin(request)` → `HTTPException(403)` on the public kbin
|
||||
vhost (identical to the per-client `/admin/clients/{mac_hash}/reset`).
|
||||
- Body: `for c in store.list_clients(): store.reset_client(c["mac_hash"]); social.wipe_mac(c["mac_hash"])` — reuse the **existing, tested** functions (per-client
|
||||
semantics: events/consents/reports wiped, score zeroed, state=validated, all
|
||||
`social_*` rows wiped; client row kept). Accumulate counts.
|
||||
- Returns `{"ok": True, "clients_reset": N, "rows_deleted": M}`.
|
||||
|
||||
**UI.** A "↺ Reset all" button in the `#panel-clients` toolbar (next to the refresh
|
||||
button), `onclick` → `confirm("Remettre à zéro TOUS les clients ? …")` → `POST`
|
||||
`/admin/clients/reset-all` → on success `loadClients()` (+ optionally `loadSocial()`
|
||||
so the graphs refresh). On the public kbin vhost the button may render but the POST
|
||||
returns 403 (consistent with the per-client reset; the existing UI already lives
|
||||
behind the auth-gated admin vhost for writes).
|
||||
|
||||
**Scale.** ≤200 clients (`list_clients` LIMIT 200), each reset is a handful of
|
||||
DELETEs — acceptable as a synchronous loop. (A single bulk-DELETE SQL would be
|
||||
faster but re-implements logic; reuse is safer and consistent.)
|
||||
|
||||
---
|
||||
|
||||
## 3. #635 — Device / geo emojis
|
||||
|
||||
**API.** Enrich each client in `admin_clients_rich()`:
|
||||
- **Device:** fetch the most recent `user_agent` for the `mac_hash` from the
|
||||
`consents` table (it stores `user_agent` + `ts`); pass to
|
||||
`avatar_analysis.classify_user_agent(ua)` → use its `device_emoji` + `device`
|
||||
label. No UA → keep a generic fallback (`"📱"` / `"?"`). This replaces the
|
||||
hardcoded placeholder.
|
||||
- **Geo:** `geo.lookup(client_ip)` → add `flag`, `country_iso`, `asn_org`.
|
||||
Cached 24h; for a private/LAN IP or miss, `lookup` returns blanks → render
|
||||
nothing for those fields.
|
||||
|
||||
A small helper `_latest_ua(conn, mac_hash) -> str` reads
|
||||
`SELECT user_agent FROM consents WHERE mac_hash=? AND user_agent IS NOT NULL
|
||||
ORDER BY ts DESC LIMIT 1`.
|
||||
|
||||
**UI.** In `loadClients()`'s row render, add the device emoji + country flag +
|
||||
hosting. Keep it compact — e.g. a "Type / Geo" cell rendering
|
||||
`${device_emoji} ${flag} <span title="${device} · ${asn_org}">${asn_org||''}</span>`
|
||||
(escape any free-text like `asn_org`/`device`). The existing columns
|
||||
(MAC/IP/state/niveau/score/last/Actions) stay; the new info slots in (either a new
|
||||
cell or appended to the IP cell). Vanilla JS, consistent with the file.
|
||||
|
||||
---
|
||||
|
||||
## 4. Error handling
|
||||
|
||||
- Reset-all: wrap the loop so one client's failure (DB error) is logged and the
|
||||
loop continues; return the partial counts. Never 500 the whole request on a
|
||||
single-row error. The `_is_public_kbin` gate is checked first.
|
||||
- Enrichment: `geo.lookup` and `classify_user_agent` are wrapped per-client; a
|
||||
failure yields the generic/blank fallback for that client, never breaks the list.
|
||||
`_latest_ua` returns "" on any query error.
|
||||
- All free-text fields (`asn_org`, `device`) are HTML-escaped in the UI render
|
||||
(same `esc`/`escT` discipline as the #social/#filtres fixes).
|
||||
|
||||
## 5. Tests
|
||||
|
||||
`tests/test_clients_reset_emoji.py` (in-memory sqlite + stubbed geo):
|
||||
- **reset-all:** seed N clients + events + social rows; call the reset-all handler
|
||||
logic (or `store`/`social` reuse) → all clients' events/social wiped, scores 0,
|
||||
client rows still present; count returned. kbin gate → 403 (assert the public-host
|
||||
branch raises/returns 403).
|
||||
- **device:** `_latest_ua` returns the most recent UA; `classify_user_agent` of a
|
||||
known iPhone UA → `device_emoji`/`device` as expected (reuse avatar_analysis).
|
||||
- **geo enrichment:** with `geo.lookup` monkeypatched to return a known
|
||||
`{flag, country_iso, asn_org}`, the enriched client carries those fields; a
|
||||
lookup returning blanks → blank fields (no crash).
|
||||
|
||||
## 6. Rollout
|
||||
|
||||
Cosmetic + an admin action; no gating, no engine/nft/DNS impact, no shared-dir
|
||||
changes. Ships in the next toolbox version. Reset-all is destructive but
|
||||
operator-initiated, confirm-dialogged, and kbin-gated — same trust model as the
|
||||
existing per-client reset.
|
||||
|
||||
## 7. Out of scope
|
||||
|
||||
- New device-classification infrastructure (reuse `avatar_analysis`).
|
||||
- Bulk-DELETE SQL (reuse per-client functions).
|
||||
- Per-client geo persistence (lookup-on-render, cached).
|
||||
|
|
@ -1,3 +1,13 @@
|
|||
secubox-toolbox (2.6.50-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* #clients tab: bulk "reset all clients" admin action (#634) — applies the
|
||||
per-client reset (events/consents/reports + social graph wiped, scores
|
||||
zeroed, client rows kept) to every client, kbin-gated, confirm-dialogged.
|
||||
Per-client device emoji (from latest UA via avatar_analysis), country flag +
|
||||
hosting/ASN via geo (#635), shown in the clients list.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Wed, 17 Jun 2026 22:00:00 +0200
|
||||
|
||||
secubox-toolbox (2.6.49-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Banner fix (#639): inject the transparency banner only into top-level
|
||||
|
|
|
|||
|
|
@ -2907,9 +2907,13 @@ async def admin_clients_rich() -> dict:
|
|||
"""Phase 6.D : enriched client list with pseudo icons + statuses + levels.
|
||||
|
||||
Returns each client decorated with device emoji, status emoji, level chip,
|
||||
and last activity. Designed for the admin webui table.
|
||||
last activity, real device classification (from UA), and geo data (country,
|
||||
flag, ASN org). Designed for the admin webui table.
|
||||
"""
|
||||
import time as _t
|
||||
# Use module-level imports so monkeypatching in tests works correctly.
|
||||
_av = avatar_analysis
|
||||
_geo = geo
|
||||
rows = store.list_clients()
|
||||
now = _t.time()
|
||||
enriched = []
|
||||
|
|
@ -2931,6 +2935,28 @@ async def admin_clients_rich() -> dict:
|
|||
level_emoji = {"r0": "🌐", "r1": "🛡", "r2": "🔍", "r3": "🌐"}.get(level, "❔")
|
||||
score = r.get("score", 0)
|
||||
risk_emoji = "🟢" if score < 30 else "🟡" if score < 70 else "🔴"
|
||||
|
||||
# --- Device classification (UA-based) ---
|
||||
dev_emoji, dev_label = "📱", ""
|
||||
try:
|
||||
ua = store.latest_user_agent(r.get("mac_hash") or "")
|
||||
if ua:
|
||||
cl = _av.classify_user_agent(ua)
|
||||
dev_emoji = cl.get("device_emoji") or dev_emoji
|
||||
dev_label = cl.get("device") or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- Geo enrichment (country flag, ISO, ASN org) ---
|
||||
flag = country_iso = asn_org = ""
|
||||
try:
|
||||
gi = _geo.lookup(r.get("ip") or "")
|
||||
flag = gi.get("flag", "") or ""
|
||||
country_iso = gi.get("country_iso", "") or ""
|
||||
asn_org = gi.get("asn_org", "") or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
enriched.append({
|
||||
"mac_hash": r.get("mac_hash"),
|
||||
"ip": r.get("ip"),
|
||||
|
|
@ -2943,7 +2969,11 @@ async def admin_clients_rich() -> dict:
|
|||
"status_label": status_label,
|
||||
"first_seen": r.get("first_seen"),
|
||||
"last_seen": r.get("last_seen"),
|
||||
"device_emoji": "📱", # placeholder ; could derive from avatar_analysis
|
||||
"device_emoji": dev_emoji,
|
||||
"device": dev_label,
|
||||
"flag": flag,
|
||||
"country_iso": country_iso,
|
||||
"asn_org": asn_org,
|
||||
})
|
||||
return {"clients": enriched, "count": len(enriched)}
|
||||
|
||||
|
|
@ -3088,6 +3118,35 @@ async def admin_client_reset(mac_hash: str) -> dict:
|
|||
return {"ok": True, "rows_deleted": rows, "mac_hash_prefix": mac_hash[:8]}
|
||||
|
||||
|
||||
def _reset_all_clients() -> dict:
|
||||
"""Apply the per-client reset to every client (events/consents/reports +
|
||||
social graph wiped, score zeroed, client row kept). One client's failure is
|
||||
logged and skipped. Returns counts."""
|
||||
from . import social as _s
|
||||
clients_reset = 0
|
||||
rows_deleted = 0
|
||||
for c in store.list_clients():
|
||||
mh = c.get("mac_hash")
|
||||
if not mh:
|
||||
continue
|
||||
try:
|
||||
rows_deleted += store.reset_client(mh)
|
||||
rows_deleted += _s.wipe_mac(mh)
|
||||
clients_reset += 1
|
||||
except Exception as e:
|
||||
log.warning("reset-all: client %s failed: %s", str(mh)[:8], e)
|
||||
log.info("admin reset-all: %d clients, %d rows", clients_reset, rows_deleted)
|
||||
return {"ok": True, "clients_reset": clients_reset, "rows_deleted": rows_deleted}
|
||||
|
||||
|
||||
@router.post("/admin/clients/reset-all")
|
||||
async def admin_clients_reset_all(request: Request) -> dict:
|
||||
"""RAZ ALL clients (bulk per-client reset). Blocked on the public kbin vhost."""
|
||||
if _is_public_kbin(request):
|
||||
raise HTTPException(403, "reset-all disabled on public vhost — use admin.gk2.secubox.in/toolbox/")
|
||||
return _reset_all_clients()
|
||||
|
||||
|
||||
@router.get("/admin/clients/{mac_hash}/events")
|
||||
async def admin_client_events(mac_hash: str) -> dict:
|
||||
"""Admin endpoint : per-source event summary for a specific client."""
|
||||
|
|
|
|||
|
|
@ -134,6 +134,21 @@ def purge_expired() -> int:
|
|||
return n
|
||||
|
||||
|
||||
def latest_user_agent(mac_hash: str) -> str | None:
|
||||
"""Most recent recorded User-Agent for a client (from consents), or None."""
|
||||
try:
|
||||
with _conn() as c:
|
||||
row = c.execute(
|
||||
"SELECT user_agent FROM consents "
|
||||
"WHERE mac_hash=? AND user_agent IS NOT NULL AND user_agent<>'' "
|
||||
"ORDER BY ts DESC LIMIT 1",
|
||||
(mac_hash,),
|
||||
).fetchone()
|
||||
return row["user_agent"] if row else None
|
||||
except sqlite3.Error:
|
||||
return None
|
||||
|
||||
|
||||
def reset_client(mac_hash: str) -> int:
|
||||
"""Phase 12.B (#516) — RAZ a specific client's accumulated toolbox
|
||||
state : events + consents + reports. Returns rows deleted. The
|
||||
|
|
|
|||
64
packages/secubox-toolbox/tests/test_clients_reset_emoji.py
Normal file
64
packages/secubox-toolbox/tests/test_clients_reset_emoji.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
import pathlib
|
||||
from secubox_toolbox import store
|
||||
|
||||
|
||||
def _tmpdb(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(store, "DB_PATH", pathlib.Path(tmp_path / "toolbox.db"))
|
||||
with store._conn():
|
||||
pass
|
||||
return store
|
||||
|
||||
|
||||
def test_latest_user_agent(tmp_path, monkeypatch):
|
||||
s = _tmpdb(tmp_path, monkeypatch)
|
||||
with s._conn() as c:
|
||||
c.execute("INSERT INTO consents(mac_hash,ts,ttl_seconds,ip,user_agent) "
|
||||
"VALUES('m1',200,3600,'1.2.3.4','Mozilla/5.0 (iPhone) UA')")
|
||||
assert s.latest_user_agent("m1") == "Mozilla/5.0 (iPhone) UA"
|
||||
assert s.latest_user_agent("nope") is None
|
||||
|
||||
|
||||
def test_reset_all_clients_loops(monkeypatch):
|
||||
import secubox_toolbox.api as api
|
||||
from secubox_toolbox import store as st, social as so
|
||||
calls = {"reset": [], "wipe": []}
|
||||
monkeypatch.setattr(st, "list_clients", lambda: [{"mac_hash": "a"}, {"mac_hash": "b"}])
|
||||
monkeypatch.setattr(st, "reset_client", lambda mh: calls["reset"].append(mh) or 3)
|
||||
monkeypatch.setattr(so, "wipe_mac", lambda mh: calls["wipe"].append(mh) or 2)
|
||||
out = api._reset_all_clients()
|
||||
assert calls["reset"] == ["a", "b"] and calls["wipe"] == ["a", "b"]
|
||||
assert out == {"ok": True, "clients_reset": 2, "rows_deleted": 10}
|
||||
|
||||
|
||||
def test_reset_all_clients_one_failure_continues(monkeypatch):
|
||||
import secubox_toolbox.api as api
|
||||
from secubox_toolbox import store as st, social as so
|
||||
monkeypatch.setattr(st, "list_clients", lambda: [{"mac_hash": "a"}, {"mac_hash": "b"}])
|
||||
def _rc(mh):
|
||||
if mh == "a":
|
||||
raise RuntimeError("boom")
|
||||
return 3
|
||||
monkeypatch.setattr(st, "reset_client", _rc)
|
||||
monkeypatch.setattr(so, "wipe_mac", lambda mh: 2)
|
||||
out = api._reset_all_clients()
|
||||
assert out["ok"] is True and out["clients_reset"] == 1
|
||||
|
||||
|
||||
def test_clients_rich_enriches_device_and_geo(monkeypatch):
|
||||
import asyncio
|
||||
import secubox_toolbox.api as api
|
||||
from secubox_toolbox import store as st, geo as g
|
||||
monkeypatch.setattr(st, "list_clients", lambda: [
|
||||
{"mac_hash": "m1", "ip": "1.2.3.4", "state": "validated",
|
||||
"score": 10, "level": "r2", "first_seen": 0, "last_seen": 0}])
|
||||
monkeypatch.setattr(st, "latest_user_agent",
|
||||
lambda mh: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X)")
|
||||
monkeypatch.setattr(g, "lookup",
|
||||
lambda ip: {"flag": "🇫🇷", "country_iso": "FR", "asn_org": "OVH"})
|
||||
out = asyncio.get_event_loop().run_until_complete(api.admin_clients_rich())
|
||||
c = out["clients"][0]
|
||||
assert c["flag"] == "🇫🇷" and c["country_iso"] == "FR" and c["asn_org"] == "OVH"
|
||||
assert "device" in c and "device_emoji" in c
|
||||
# iPhone UA should classify to a phone device (not the bare placeholder semantics)
|
||||
assert c["device"] # non-empty device label derived from UA
|
||||
|
|
@ -101,6 +101,7 @@
|
|||
<section class="panel" id="panel-clients">
|
||||
<div class="toolbar">
|
||||
<button onclick="loadClients()">🔁 Refresh</button>
|
||||
<button type="button" onclick="resetAllClients()" style="color:var(--red,#e63946)">↺ Reset all</button>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:1rem">
|
||||
<h2>👥 Clients actifs</h2>
|
||||
|
|
@ -245,12 +246,13 @@ async function loadClients() {
|
|||
if (!rows) { el.innerHTML = `<div class="empty">${(d && d.__error) || 'no data'}</div>`; return; }
|
||||
if (!rows.length) { el.innerHTML = '<div class="empty">no clients</div>'; return; }
|
||||
const shown = rows.slice(0, 5); // #575 — cap the list to top 5
|
||||
let html = '<table><thead><tr><th>MAC (hash)</th><th>IP</th><th>state</th><th>niveau</th><th>score</th><th>last</th><th>Actions</th></tr></thead><tbody>';
|
||||
const escA = s => String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
let html = '<table><thead><tr><th>MAC (hash)</th><th>IP / type</th><th>state</th><th>niveau</th><th>score</th><th>last</th><th>Actions</th></tr></thead><tbody>';
|
||||
for (const c of shown) {
|
||||
const ago = c.last_seen ? Math.round((Date.now()/1000 - c.last_seen) / 60) + 'm' : '—';
|
||||
html += `<tr>
|
||||
<td><code>${c.mac_hash}</code></td>
|
||||
<td>${c.ip || '—'}</td>
|
||||
<td>${c.ip || '—'} <span style="white-space:nowrap">${c.device_emoji||''} ${c.flag||''}</span>${c.asn_org ? `<br><span style="color:var(--p31-dim,#888);font-size:0.72rem" title="${escA(c.device||'')}">${escA(c.asn_org)}</span>` : ''}</td>
|
||||
<td><span class="state-${c.state}">${c.state || '—'}</span></td>
|
||||
<td>${levelChip(c.level)} ${levelSwitcher(c.mac_hash, c.level)}</td>
|
||||
<td>${c.score ?? '—'}</td>
|
||||
|
|
@ -288,6 +290,19 @@ async function resetClient(macHash) {
|
|||
} catch (e) { alert('Échec RAZ : ' + e.message); }
|
||||
}
|
||||
|
||||
async function resetAllClients() {
|
||||
if (!confirm('Remettre à zéro TOUS les clients ? (events, consentements, graphes social — scores remis à zéro, clients conservés)')) return;
|
||||
try {
|
||||
const r = await fetch(`${API}/admin/clients/reset-all`, {method:'POST', credentials:'same-origin'});
|
||||
const d = await r.json().catch(()=>({}));
|
||||
if (!r.ok) { alert('Reset all refusé: ' + (d.detail || ('HTTP ' + r.status))); return; }
|
||||
await loadClients();
|
||||
if (typeof loadSocial === 'function') loadSocial();
|
||||
} catch (e) {
|
||||
alert('Reset all erreur: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
async function quarantine(ip) {
|
||||
if (!ip) return;
|
||||
if (!confirm(`Mettre en quarantaine l'appareil ${ip} ? Tout son trafic sera coupé 6h.`)) return;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user