secubox-deb/packages/secubox-profiles/api/observe.py
CyberMind-FR aa430739e6 fix(profiles): stop fabricating False in observe() systemd/routes branches
_run_cmd returned rc=1 both when systemctl genuinely answered "disabled"
and when the command failed to execute (OSError/timeout) — the latter
then read as a fabricated `enabled=False`/`active=False`, which a later
phase would treat as "off" and wrongly start. _run_cmd now returns
rc=None as the not-executed sentinel, and observe() maps it to None
instead of False for both enabled and active.

Likewise load_routes() folded an unreadable/corrupt routes file into the
same "no routes" set() as a genuinely absent file, producing a fabricated
portal_routed=False. It now returns None when the file exists but can't
be read/parsed, and observe() propagates that as portal_routed=None.

Also restores the `m: Manifest` type annotation on observe() that had
been dropped.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 12:04:55 +02:00

119 lines
4.4 KiB
Python

# 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 :: profiles — sondes d'état réel (LECTURE SEULE)
CyberMind — https://cybermind.fr
Seul fichier du module qui touche le système. Il n'exécute QUE des commandes de
lecture (is-enabled, is-active, show, lxc-info). Aucun enable/disable/start/stop
n'a sa place ici : Phase 1 est en lecture seule.
L'état réel est OBSERVÉ, jamais supposé depuis un état stocké — un paquet
réinstallé ré-`enable` son unit dans son postinst (motif imposé par le CLAUDE.md
du projet), donc un état mémorisé ment.
"""
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass
from pathlib import Path
from .manifest import Manifest
PROC = Path("/proc")
ROUTES_FILE = Path("/etc/secubox/waf/haproxy-routes.json")
_TIMEOUT = 5
@dataclass(frozen=True)
class Actual:
enabled: bool | None = None
active: bool | None = None
lxc_running: bool | None = None
lxc_autostart: bool | None = None
portal_routed: bool | None = None
rss_kb: int | None = None
def _run_cmd(argv: list[str]) -> tuple[int | None, str]:
"""rc=None signale que la commande n'a PAS pu s'exécuter (OSError, timeout) —
à distinguer d'un rc non-nul qui est une réponse authentique de la commande."""
try:
p = subprocess.run(argv, capture_output=True, text=True, timeout=_TIMEOUT)
return p.returncode, p.stdout
except (OSError, subprocess.SubprocessError):
return None, ""
def load_routes(path: Path = ROUTES_FILE) -> set[str] | None:
"""Domaines routés par le WAF. Fichier absent = aucune route (pas une erreur).
Fichier présent mais illisible/corrompu = indéterminable → None, pas set()."""
path = Path(path)
if not path.exists():
return set()
try:
return set(json.loads(path.read_text(encoding="utf-8")))
except (OSError, json.JSONDecodeError):
return None
def _rss_kb(pid: str) -> int | None:
if not pid or pid == "0":
return None
try:
for line in (PROC / pid / "status").read_text(encoding="utf-8").splitlines():
if line.startswith("VmRSS:"):
return int(line.split()[1])
except (OSError, ValueError, IndexError):
return None
return None
def observe(m: Manifest, *, run=_run_cmd, routes: set[str] | None = None) -> Actual:
"""État réel d'un module. Tout ce qui n'est pas déterminable reste None —
jamais False : un False inventé produirait une fausse décision."""
unit = m.units[0] if m.units else None
enabled = active = rss = None
if unit:
rc, _ = run(["systemctl", "is-enabled", unit])
# rc=None : la commande n'a pas pu s'exécuter (échec d'exécution/timeout),
# indistinguable d'un rc!=0 authentique si on ne le traite pas à part —
# rester None, jamais fabriquer un False.
enabled = None if rc is None else (rc == 0)
rc, _ = run(["systemctl", "is-active", unit])
active = None if rc is None else (rc == 0)
rc, out = run(["systemctl", "show", unit, "-p", "MainPID", "--value"])
if rc == 0:
rss = _rss_kb(out.strip())
lxc_running = lxc_autostart = None
if m.runtime == "lxc" and m.lxc:
rc, out = run(["lxc-info", "-n", m.lxc, "-s"])
# lxc-info échoue depuis le contexte non privilégié de l'API : on laisse
# None plutôt que d'affirmer "arrêté" à tort.
if rc == 0:
lxc_running = "RUNNING" in out.upper()
rc, out = run(["lxc-info", "-n", m.lxc, "-c", "lxc.start.auto"])
if rc == 0:
lxc_autostart = out.strip().endswith("1")
portal_routed = None
if m.portal_domain is not None:
resolved_routes = routes if routes is not None else load_routes()
if resolved_routes is not None:
portal_routed = m.portal_domain in resolved_routes
return Actual(enabled=enabled, active=active, lxc_running=lxc_running,
lxc_autostart=lxc_autostart, portal_routed=portal_routed, rss_kb=rss)
def is_on(a: Actual) -> bool:
"""Un module est ON s'il est enabled ET actif. `enabled` seul survit au
reboot mais ne sert à rien maintenant ; `active` seul ne survit pas."""
return bool(a.enabled) and bool(a.active)