#!/usr/bin/python3
# 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-nac-apply — applique l'état voulu du NAC à nftables (#1766)
CyberMind — https://cybermind.fr

Service ROOT, étroit : il lit /var/lib/secubox/nac/nft-desired.json (écrit par l'API sans
privilège), VALIDE chaque MAC et chaque nom d'ensemble, puis charge en UNE transaction
`nft -f` les règles (/etc/nftables.d/secubox-nac.nft) et les éléments. Rien d'autre :
ni commande reçue du fichier, ni chemin choisi par lui.

Déclenché par secubox-nac-apply.path (changement du fichier) et au démarrage de nftables
(un `flush ruleset` efface la table : elle est reconstruite ici).
"""
from __future__ import annotations

import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path

REGLES = Path(os.environ.get("NAC_NFT_REGLES", "/etc/nftables.d/secubox-nac.nft"))
DESIRED = Path(os.environ.get("NAC_NFT_DESIRED", "/var/lib/secubox/nac/nft-desired.json"))
STATUT = Path(os.environ.get("NAC_NFT_STATUT", "/var/lib/secubox/nac/nft-applied.json"))
SETS = ("blocked", "lan_allowed", "lxc_zone", "iot_zone", "guest_zone", "quarantine_zone")
MAC_RE = re.compile(r"^([0-9a-f]{2}:){5}[0-9a-f]{2}$")


def lire_voulu(chemin: Path = DESIRED) -> dict:
    """{ensemble: [mac,…]} ; ce qui n'est pas une MAC valide ou un ensemble connu est écarté."""
    try:
        brut = json.loads(chemin.read_text())
    except (OSError, ValueError):
        return {s: [] for s in SETS}
    if not isinstance(brut, dict):
        return {s: [] for s in SETS}
    out = {}
    for s in SETS:
        liste = brut.get(s, [])
        out[s] = sorted({m.strip().lower() for m in liste
                         if isinstance(m, str) and MAC_RE.match(m.strip().lower())}) if isinstance(liste, list) else []
    return out


def construire(regles: str, voulu: dict) -> str:
    """Script nft : les règles (table recréée) puis les éléments, d'un seul tenant."""
    lignes = [regles.rstrip()]
    for s in SETS:
        if voulu.get(s):
            lignes.append("add element inet secubox_nac %s { %s }" % (s, ", ".join(voulu[s])))
    return "\n".join(lignes) + "\n"


def appliquer(script: str) -> tuple[bool, str]:
    r = subprocess.run(["nft", "-f", "-"], input=script, capture_output=True, text=True, timeout=30)
    return r.returncode == 0, (r.stderr or "").strip()[:300]


def statut(ok: bool, voulu: dict, erreur: str = "") -> None:
    try:
        STATUT.parent.mkdir(parents=True, exist_ok=True)
        tmp = STATUT.with_name(STATUT.name + ".tmp")
        tmp.write_text(json.dumps({"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "ok": ok,
                                   "erreur": erreur, "elements": {s: len(voulu.get(s, [])) for s in SETS}}))
        os.chmod(tmp, 0o644)
        os.replace(tmp, STATUT)
    except OSError:
        pass


def main() -> int:
    try:
        regles = REGLES.read_text()
    except OSError as e:
        statut(False, {}, f"règles illisibles : {e}")
        print(f"secubox-nac-apply : règles illisibles : {e}", file=sys.stderr)
        return 1
    voulu = lire_voulu()
    ok, err = appliquer(construire(regles, voulu))
    statut(ok, voulu, err)
    if not ok:
        print(f"secubox-nac-apply : nft a refusé : {err}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
