feat(secubox-toolbox): 3 QR code endpoints + splash QR display + 9th dashboard widget (device fingerprint) + README poster integration (ref #495 #497)

## QR endpoints (python-qrcode)

  GET /qr/splash.png    → QR encodes the splash URL (Cabine join)
  GET /qr/cert.png      → QR encodes /ca/mobileconfig (iOS profile)
  GET /qr/webclip.png   → QR encodes /ca/webclip-cabine.mobileconfig
  GET /qr/{target}.png  → generic : maps splash/cert/webclip/report/
                          fingerprint to fixed URLs, or encodes literal

  PNG output, Cache-Control: public, max-age=3600.
  Verified : 232x232 1-bit grayscale, 384-553 bytes each.

  debian/control adds python3-qrcode dependency.

## Splash QR card

The cert install card on the splash now shows 3 QR codes (① SPLASH,
② CERT iPHONE, ③ WEBCLIP HOME) on a white background grid below the
test button. Scannable from another device for cross-installation
(useful at the cabine : user shows QR to others without typing URLs).

## Dashboard hero : 9th widget (device fingerprint)

Mirroring the poster layout, added a full-width 9th widget below the
2 rows of 4 KPI widgets :

  ┌─────────────────────────────────────────────────────────┐
  │ 📱 📱 Empreinte device                                   │
  │      iPhone iOS 26.3 · 🟢 Chrome 148                     │
  │      21 UAs distincts observés                           │
  └─────────────────────────────────────────────────────────┘

Now matches the 9-widget metrics layout from the public poster :
🌐 connexions · 📡 hôtes ·  OK 2xx · 🔒 cert-pin
📺 apps · 🍪 trackers · 🌍 pays · 🎯 score
📱 device (full-width)

## README + assets/poster integration (#497)

  - README.md : new section at top with poster image embed + 3 doc links
  - docs/assets/poster/README.md : print specs + variants list + license
  - Placeholder docs/assets/poster/village3b-A2.png expected (user provides)

Wiki integration pending : will be added via gh wiki API once issue #497
phase 2 launches.

## Rebase

Branch was rebased onto master (which now has all PR #493 Phase 3
commits). control merged : Phase 3 fonts + Phase 5 lxc deps + new
python3-qrcode in one cohesive Depends block.
This commit is contained in:
CyberMind-FR 2026-06-05 11:24:03 +02:00
parent 448aee77e9
commit 3d2ecd574f
5 changed files with 181 additions and 0 deletions

View File

@ -14,6 +14,25 @@
---
## 📡 VILLAGE3B — Cabine Numérique Gondwana ToolBoX
<p align="center">
<img src="docs/assets/poster/village3b-A2.png" alt="Poster grand public VILLAGE3B" width="600">
</p>
> **Diagnostic compromission iPhone gratuit · Anonyme · Open Source**
>
> 3 niveaux d'opt-in (R0 bypass complet, R1 analyse passive ✓ recommandée, R2 TLS-break + bandeau).
> Rapport téléchargeable avec 9 widgets metrics : 🌐 connexions · 📡 hôtes · ✅ OK 2xx · 🔒 cert-pinning · 📺 apps · 🍪 trackers · 🌍 pays · 🎯 score · 📱 device.
> Conformité CSPN ANSSI + LCEN. Aucune donnée externe.
>
> - Détails techniques : [docs/AI-HANDOVER-cabine-evolution.md](docs/AI-HANDOVER-cabine-evolution.md)
> - Brief poster + variantes : [docs/marketing/POSTER-grand-public-village3b.md](docs/marketing/POSTER-grand-public-village3b.md)
> - Press kit + candidatures France.gouv : [docs/marketing/PROMPT-claude-presse-gouv.md](docs/marketing/PROMPT-claude-presse-gouv.md)
> - Issue tracking poster : [#497](https://github.com/CyberMind-FR/secubox-deb/issues/497)
---
## License — CyberMind Source-Disclosed (CMSD-1.0)
> **Source disclosed, rights reserved.**

View File

@ -0,0 +1,55 @@
# Posters VILLAGE3B — assets
Référence pour le poster public grand format basé sur le brief
`docs/marketing/POSTER-grand-public-village3b.md`.
## Fichiers attendus dans ce dossier
- `village3b-A2.png` — poster A2 portrait haute résolution (300dpi, ~5MB)
- `village3b-A2.svg` — source vectoriel Inkscape éditable
- `village3b-A4.pdf` — variante A4 print-ready
- `village3b-social-1080x1080.png` — variante Instagram/LinkedIn
- `village3b-story-1080x1920.png` — variante Story Instagram/Snapchat
## Brief layout (6 zones)
Voir `docs/marketing/POSTER-grand-public-village3b.md` pour le détail des
6 zones (titre, niveaux R0/R1/R2, 9 widgets metrics, transparence,
conformité, QR codes, footer).
## QR codes embedded
Le poster contient 3 QR codes pointant vers les endpoints FastAPI :
- ① SPLASH → `http://village3b/`
- ② CERT iPHONE → `http://village3b/ca/mobileconfig`
- ③ WEBCLIP HOME → `http://village3b/ca/webclip-cabine.mobileconfig`
Les URLs sont auto-générées par les routes `/qr/{splash,cert,webclip}.png`
de l'API (Phase 3.x #497 + #495).
## Charte couleurs
```
--cosmos-black: #0a0a0f /* fond */
--gold-hermetic: #c9a84c /* accents titre */
--matrix-green: #00ff41 /* texte principal */
--phos: #00dd44 /* bordures P31 */
--phos-hot: #00ff55 /* highlights */
--amber: #ffb347 /* warning R2 */
--cinnabar: #e63946 /* alertes risque HIGH */
```
## Print specs
- A2 portrait, 420×594 mm, marges 15mm, 3mm bleed
- 300dpi minimum
- CMYK pour print, RGB pour digital
- Papier mat 200g/m² recommandé
## Licence
LicenseRef-CMSD-1.0 — image dérivée du logiciel cabine ToolBox.
## Issue tracking
[#497 — Poster grand public VILLAGE3B](https://github.com/CyberMind-FR/secubox-deb/issues/497)

View File

@ -98,6 +98,24 @@ li::before{content:"▸ ";color:var(--phos);text-shadow:0 0 4px var(--phos)}
<div style="font-size:0.65rem;color:var(--dim)">risque /100</div>
</div>
</div>
{# 9th widget (poster sync #497) : 📱 empreinte device — full-width below #}
{% set _avatar = avatar_analysis or {} %}
{% set _dev_emj = _avatar.most_common_emoji or '❔' %}
{% set _dev_label = _avatar.most_common or 'unknown' %}
{% set _devices = _avatar.devices or {} %}
{% set _dev_info = _devices.get(_dev_label, {}) %}
{% set _os_label = _dev_info.os_label or _dev_label %}
{% set _browsers = _avatar.browsers or {} %}
<div style="margin-top:0.5rem;padding:0.7rem;background:rgba(45,25,60,0.5);border-radius:4px;text-align:center">
<div style="font-size:1.4rem">📱 <b style="color:#cbb6ff">{{ _dev_emj }} Empreinte device</b></div>
<div style="font-size:0.92rem;color:var(--phos-hot);margin-top:0.3rem;text-shadow:0 0 3px var(--phos)">
{{ _os_label }}
{% for br, info in _browsers.items() if br != 'unknown' %}
· {{ info.emoji or '' }} {{ info.label or br }}
{% endfor %}
</div>
<div style="font-size:0.65rem;color:var(--dim);margin-top:0.2rem">{{ _avatar.raw_count or 0 }} UAs distincts observés</div>
</div>
{# Top device + app + ASN line #}
{% set avatar = avatar_analysis or {} %}
{% set top_dev = avatar.most_common or '?' %}

View File

@ -86,6 +86,25 @@ button.lvl-r2{border-color:var(--amber);color:#ffd6a0}
<span id="cert-text">Auto-test en cours...</span>
</div>
{# Phase 3.x (#497) : 3 QR codes définitifs synchronisés avec le poster #}
<p style="font-size:0.78rem;color:#cbb6ff;margin-top:0.8rem;text-align:center;border-top:1px solid rgba(158,118,255,0.3);padding-top:0.5rem">
📲 <b>QR codes</b> — scanne depuis un autre appareil
</p>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.5rem;margin-top:0.4rem">
<div style="text-align:center;background:rgba(255,255,255,0.95);padding:0.4rem;border-radius:4px">
<img src="/qr/splash.png" alt="QR splash" style="width:100%;max-width:120px;display:block;margin:auto"/>
<p style="font-size:0.62rem;color:#0a0a0f;margin-top:0.2rem;font-weight:bold">① SPLASH</p>
</div>
<div style="text-align:center;background:rgba(255,255,255,0.95);padding:0.4rem;border-radius:4px">
<img src="/qr/cert.png" alt="QR cert" style="width:100%;max-width:120px;display:block;margin:auto"/>
<p style="font-size:0.62rem;color:#0a0a0f;margin-top:0.2rem;font-weight:bold">② CERT iPHONE</p>
</div>
<div style="text-align:center;background:rgba(255,255,255,0.95);padding:0.4rem;border-radius:4px">
<img src="/qr/webclip.png" alt="QR webclip" style="width:100%;max-width:120px;display:block;margin:auto"/>
<p style="font-size:0.62rem;color:#0a0a0f;margin-top:0.2rem;font-weight:bold">③ WEBCLIP HOME</p>
</div>
</div>
<p style="font-size:0.7rem;opacity:0.85;margin-top:0.6rem;line-height:1.5">
⚠ L'auto-test est <b>best-effort</b> (probe HTTPS). Pour <b>certifier</b>
que le CA est bien installé + activé sur iPhone :

View File

@ -299,6 +299,76 @@ async def ca_fingerprint() -> dict:
return {"sha1": sha1, "sha256": sha256, "subject": subject}
# Phase 3.x (#497) : 3 QR code endpoints — splash, cert install, webclip
# Used by the splash page + reports + the public poster (POSTER-grand-public).
def _qr_png(payload: str, size: int = 8, border: int = 2) -> bytes:
"""Generate a PNG QR code for the given payload. Returns bytes."""
import qrcode
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=size,
border=border,
)
qr.add_data(payload)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
import io
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def _portal_url(request: "Request", path: str = "/") -> str:
"""Build absolute URL based on request Host header — works regardless of
whether the portal is reached via 10.99.0.1, village3b, or external."""
host = request.headers.get("host", "10.99.0.1:8088")
scheme = "http" # captive portal is HTTP-only by design
return f"{scheme}://{host.rstrip('/')}{path}"
@router.get("/qr/splash.png")
async def qr_splash(request: Request) -> Response:
"""QR encodes the splash URL — scannable from another device to join VILLAGE3B."""
return Response(content=_qr_png(_portal_url(request, "/")),
media_type="image/png",
headers={"Cache-Control": "public, max-age=3600"})
@router.get("/qr/cert.png")
async def qr_cert(request: Request) -> Response:
"""QR encodes the CA install URL (.mobileconfig)."""
return Response(content=_qr_png(_portal_url(request, "/ca/mobileconfig")),
media_type="image/png",
headers={"Cache-Control": "public, max-age=3600"})
@router.get("/qr/webclip.png")
async def qr_webclip(request: Request) -> Response:
"""QR encodes the webclip URL (Add-to-Home-Screen profile)."""
return Response(content=_qr_png(_portal_url(request, "/ca/webclip-cabine.mobileconfig")),
media_type="image/png",
headers={"Cache-Control": "public, max-age=3600"})
@router.get("/qr/{target}")
async def qr_generic(target: str, request: Request) -> Response:
"""Generic fallback : ?target=splash|cert|webclip|fingerprint maps to fixed
URLs ; anything else encodes the literal target string."""
targets = {
"splash": _portal_url(request, "/"),
"cert": _portal_url(request, "/ca/mobileconfig"),
"webclip": _portal_url(request, "/ca/webclip-cabine.mobileconfig"),
"report": _portal_url(request, "/report/me/html"),
"fingerprint": _portal_url(request, "/ca/fingerprint"),
}
payload = targets.get(target.removesuffix(".png"), target)
return Response(content=_qr_png(payload),
media_type="image/png",
headers={"Cache-Control": "public, max-age=3600"})
@router.get("/client-status")
async def client_status(request: Request) -> dict:
"""Health-banner-friendly endpoint : detect captive subnet + R2 consent +