Welle 2 · Sicherheits-Markierung: Debian-Sicherheitsupdates, Alpine über secdb, Lücken-Scans aus Arcane
Der Ausführer zählt je Gast, wie viele offene Updates aus einer Sicherheits-Suite kommen („-security“ in apt list), beim Proxmox-Host ebenso; für Alpine (NPMplus) schickt er die Paketliste, und der Homelab-Teil gleicht sie mit Alpines Sicherheitsdatenbank ab (im Hintergrund geladen, zwölf Stunden gemerkt, Alpine-Versionsvergleich). Die Update-Tabelle, die Kacheln und die Übersicht zeigen „· 3 Sicherheit“, die Rückfrage nennt es. Je Docker-Container steht, was Arcanes Lücken-Scan (Trivy, sonntags 02:00) im Image fand, kritische und hohe. Der Ausführer braucht ausfuehrer-einrichten.sh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
995a0f34ee
commit
1c66daad0b
@@ -36,6 +36,7 @@ class BausteinStand:
|
|||||||
kurz: str = "" # „10 Pakete", „b11160 → b11172", „743 Änderungen"
|
kurz: str = "" # „10 Pakete", „b11160 → b11172", „743 Änderungen"
|
||||||
grund: str | None = None # bei „unbekannt" und „festgehalten": warum
|
grund: str | None = None # bei „unbekannt" und „festgehalten": warum
|
||||||
aktion: Aktion | None = None # None = kein Knopf (aktuell, festgehalten, unbekannt)
|
aktion: Aktion | None = None # None = kein Knopf (aktuell, festgehalten, unbekannt)
|
||||||
|
sicherheit: int | None = None # davon Sicherheitsupdates (seit 25.09.2026; None = unbekannt)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -219,6 +219,32 @@ def _container_liste(url: str) -> list[dict] | None:
|
|||||||
return ergebnis
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def luecken(url: str, image_ids: tuple[str, ...]) -> dict[str, dict]:
|
||||||
|
"""Arcanes Lücken-Scan (Trivy) je Image: {imageId: {"kritisch", "hoch", "mittel", "gesamt", "zeit"}} — nur für
|
||||||
|
gescannte Images. Leer ohne Schlüssel, ohne Scan oder wenn Arcane nicht antwortet (seit 25.09.2026)."""
|
||||||
|
if not schluessel() or not image_ids:
|
||||||
|
return {}
|
||||||
|
return _gemerkt(f"luecken:{url}:{','.join(image_ids)}", lambda: _luecken(url, list(image_ids))) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def _luecken(url: str, image_ids: list[str]) -> dict[str, dict]:
|
||||||
|
ergebnis: dict[str, dict] = {}
|
||||||
|
try:
|
||||||
|
for umgebung in umgebungen(url):
|
||||||
|
r = httpx.post(f"{url}/api/environments/{umgebung['id']}/images/vulnerabilities/summaries",
|
||||||
|
headers={"X-API-Key": schluessel()}, json={"imageIds": image_ids}, timeout=ZEITLIMIT)
|
||||||
|
r.raise_for_status()
|
||||||
|
for image_id, s in (((r.json() or {}).get("data") or {}).get("summaries") or {}).items():
|
||||||
|
zahlen = (s or {}).get("summary") or {}
|
||||||
|
if s and s.get("scanTime") and zahlen:
|
||||||
|
ergebnis[image_id] = {"kritisch": int(zahlen.get("critical") or 0), "hoch": int(zahlen.get("high") or 0),
|
||||||
|
"mittel": int(zahlen.get("medium") or 0), "gesamt": int(zahlen.get("total") or 0),
|
||||||
|
"zeit": s.get("scanTime")}
|
||||||
|
except (httpx.HTTPError, ValueError, TypeError, AttributeError):
|
||||||
|
return {}
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
def container_zahlen(url: str) -> dict | None:
|
def container_zahlen(url: str) -> dict | None:
|
||||||
"""Laufende und alle Container über alle Umgebungen, für die Kachel (seit 25.09.2026): {"laufend", "gesamt"}.
|
"""Laufende und alle Container über alle Umgebungen, für die Kachel (seit 25.09.2026): {"laufend", "gesamt"}.
|
||||||
None ohne Schlüssel oder wenn Arcane nicht antwortet."""
|
None ohne Schlüssel oder wenn Arcane nicht antwortet."""
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ Stack (Compose-Projekt) Zustand, Gesundheit, Image und Update-Stand:
|
|||||||
• „lokal gebaut“, wenn das Image aus keiner Registry kommt (Arcane: updateType „local“, oder die Registry kennt es
|
• „lokal gebaut“, wenn das Image aus keiner Registry kommt (Arcane: updateType „local“, oder die Registry kennt es
|
||||||
nicht — NerdQuiz und Rippy baut der User selbst) — dort gibt es keinen Abgleich,
|
nicht — NerdQuiz und Rippy baut der User selbst) — dort gibt es keinen Abgleich,
|
||||||
• sonst „aktuell“.
|
• sonst „aktuell“.
|
||||||
|
Seit 25.09.2026 (Punkt 11) steht je Container auch, was Arcanes Lücken-Scan (Trivy, sonntags 02:00) im Image gefunden
|
||||||
|
hat — kritische und hohe zuerst; ungescannte Images haben keine Angabe.
|
||||||
Der Wächter meldet gelb, wenn ein Container ungesund ist (Healthcheck) oder immer wieder neu startet; beendete
|
Der Wächter meldet gelb, wenn ein Container ungesund ist (Healthcheck) oder immer wieder neu startet; beendete
|
||||||
Container (etwa einmalige Aufgaben) sind kein Befund. Neue Stacks erscheinen unter „Neu entdeckt“ (entdecken.py).
|
Container (etwa einmalige Aufgaben) sind kein Befund. Neue Stacks erscheinen unter „Neu entdeckt“ (entdecken.py).
|
||||||
"""
|
"""
|
||||||
@@ -37,7 +39,8 @@ def container_zeile(c: dict) -> dict:
|
|||||||
else:
|
else:
|
||||||
update = "aktuell"
|
update = "aktuell"
|
||||||
name = str((c.get("names") or ["?"])[0]).lstrip("/")
|
name = str((c.get("names") or ["?"])[0]).lstrip("/")
|
||||||
return {"id": str(c.get("id") or name)[:12], "name": name, "stack": labels.get("com.docker.compose.project"),
|
return {"id": str(c.get("id") or name)[:12], "image_id": c.get("imageId"), "luecken": None,
|
||||||
|
"name": name, "stack": labels.get("com.docker.compose.project"),
|
||||||
"dienst": labels.get("com.docker.compose.service"), "image": c.get("image"),
|
"dienst": labels.get("com.docker.compose.service"), "image": c.get("image"),
|
||||||
"zustand": str(c.get("state") or "unbekannt"), "status": c.get("status"),
|
"zustand": str(c.get("state") or "unbekannt"), "status": c.get("status"),
|
||||||
"gesund": {"healthy": True, "unhealthy": False}.get(gesund.group(1)) if gesund else None,
|
"gesund": {"healthy": True, "unhealthy": False}.get(gesund.group(1)) if gesund else None,
|
||||||
@@ -55,8 +58,10 @@ def lage() -> dict:
|
|||||||
if roh is None:
|
if roh is None:
|
||||||
return {"verfuegbar": False, "grund": "Arcane antwortet gerade nicht.", "stacks": []}
|
return {"verfuegbar": False, "grund": "Arcane antwortet gerade nicht.", "stacks": []}
|
||||||
stacks: dict[str, list[dict]] = {}
|
stacks: dict[str, list[dict]] = {}
|
||||||
for c in roh:
|
zeilen = [container_zeile(c) for c in roh]
|
||||||
zeile = container_zeile(c)
|
befunde = arcane.luecken(url, tuple(sorted({z["image_id"] for z in zeilen if z["image_id"]})))
|
||||||
|
for zeile in zeilen:
|
||||||
|
zeile["luecken"] = befunde.get(zeile["image_id"] or "")
|
||||||
stacks.setdefault(zeile["stack"] or zeile["name"], []).append(zeile)
|
stacks.setdefault(zeile["stack"] or zeile["name"], []).append(zeile)
|
||||||
return {"verfuegbar": True, "grund": None,
|
return {"verfuegbar": True, "grund": None,
|
||||||
"stacks": [{"name": n, "container": sorted(cs, key=lambda z: z["name"])} for n, cs in sorted(stacks.items())]}
|
"stacks": [{"name": n, "container": sorted(cs, key=lambda z: z["name"])} for n, cs in sorted(stacks.items())]}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from kern.github import neueste_version
|
|||||||
from kern.zeit import LOCAL_TZ
|
from kern.zeit import LOCAL_TZ
|
||||||
from kern.ziele import Aktion, BausteinStand, ZielStand
|
from kern.ziele import Aktion, BausteinStand, ZielStand
|
||||||
|
|
||||||
from services.homelab import apps, arcane, kanal, karenz
|
from services.homelab import apps, arcane, kanal, karenz, sicherheit
|
||||||
|
|
||||||
BERICHT_ALT_S = 30 * 60
|
BERICHT_ALT_S = 30 * 60
|
||||||
LISTEN_ALT_S = 14 * 24 * 3600
|
LISTEN_ALT_S = 14 * 24 * 3600
|
||||||
@@ -168,9 +168,13 @@ def _os_baustein(gast: dict, zid: str, jetzt: float) -> BausteinStand:
|
|||||||
stand.zustand, stand.aktion = "unbekannt", suchen
|
stand.zustand, stand.aktion = "unbekannt", suchen
|
||||||
stand.grund = f"Die Paketlisten sind vom {_datum(listen)}; was fehlt, weiß erst eine neue Suche."
|
stand.grund = f"Die Paketlisten sind vom {_datum(listen)}; was fehlt, weiß erst eine neue Suche."
|
||||||
elif anzahl > 0:
|
elif anzahl > 0:
|
||||||
|
# Seit 25.09.2026: wie viele davon eine Sicherheitslücke schließen (Debian: Suite „-security“, Alpine: secdb).
|
||||||
|
stand.sicherheit = os_stand.get("sicherheit") if "sicherheit" in os_stand else sicherheit.alpine_sicherheit(os_stand)
|
||||||
|
davon = f" (davon {stand.sicherheit} Sicherheitsupdate{'s' if stand.sicherheit != 1 else ''})" \
|
||||||
|
if stand.sicherheit else ""
|
||||||
stand.zustand, stand.kurz = "neu", f"{anzahl} Pakete"
|
stand.zustand, stand.kurz = "neu", f"{anzahl} Pakete"
|
||||||
stand.aktion = Aktion("POST", f"/api/homelab/ziele/{zid}/os-update",
|
stand.aktion = Aktion("POST", f"/api/homelab/ziele/{zid}/os-update",
|
||||||
f"{anzahl} Pakete im Gast jetzt einspielen? {_rueckweg_frage(gast)}")
|
f"{anzahl} Pakete{davon} im Gast jetzt einspielen? {_rueckweg_frage(gast)}")
|
||||||
return stand
|
return stand
|
||||||
|
|
||||||
|
|
||||||
@@ -269,6 +273,8 @@ def _host_ziel(host: dict) -> ZielStand:
|
|||||||
pakete.zustand, pakete.grund = "unbekannt", host.get("fehler") or "Die Update-Liste fehlt im Bericht."
|
pakete.zustand, pakete.grund = "unbekannt", host.get("fehler") or "Die Update-Liste fehlt im Bericht."
|
||||||
elif updates:
|
elif updates:
|
||||||
pakete.zustand, pakete.kurz = "neu", f"{len(updates)} Pakete"
|
pakete.zustand, pakete.kurz = "neu", f"{len(updates)} Pakete"
|
||||||
|
if isinstance(host.get("updates_sicherheit"), list):
|
||||||
|
pakete.sicherheit = len(host["updates_sicherheit"])
|
||||||
pakete.aktion = Aktion("POST", "/api/homelab/ziele/pve/update",
|
pakete.aktion = Aktion("POST", "/api/homelab/ziele/pve/update",
|
||||||
f"{len(updates)} Pakete auf dem Proxmox-Host einspielen? Alle Gäste laufen weiter. "
|
f"{len(updates)} Pakete auf dem Proxmox-Host einspielen? Alle Gäste laufen weiter. "
|
||||||
"Einen Neustart löst das nicht aus — der ist ein eigener Knopf.")
|
"Einen Neustart löst das nicht aus — der ist ein eigener Knopf.")
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Sicherheits-Markierung (Ausbauplan Welle 2, Punkt 11, seit 25.09.2026).
|
||||||
|
|
||||||
|
Welche offenen Paket-Updates schließen eine Sicherheitslücke?
|
||||||
|
• Debian und Ubuntu (Gäste und Proxmox-Host) liefern sie aus eigenen Suiten („trixie-security“); das zählt der
|
||||||
|
Ausführer selbst (os_updates.sicherheit, host.updates_sicherheit).
|
||||||
|
• Alpine (NPMplus) kennt keine solche Suite. Dafür führt Alpine eine Sicherheitsdatenbank (secdb.alpinelinux.org,
|
||||||
|
je Zweig main und community): je Paket die Versionen, die Lücken schließen. Ein Update zählt, wenn eine davon
|
||||||
|
nach der installierten und höchstens bei der neuen Version liegt. Die Datenbank lädt dieser Teil im Hintergrund
|
||||||
|
und merkt sie zwölf Stunden (<Datenordner>/alpine-secdb-v<zweig>.json); bis sie da ist, heißt es „unbekannt“.
|
||||||
|
Docker-Images prüft Arcane selbst (Trivy, sonntags 02:00): docker.py holt dessen Befunde je Image.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SECDB = "https://secdb.alpinelinux.org/v{zweig}/{repo}.json"
|
||||||
|
NEU_LADEN_S = 12 * 3600
|
||||||
|
_SUFFIX = {"alpha": -4, "beta": -3, "pre": -2, "rc": -1, "cvs": 1, "svn": 1, "git": 1, "hg": 1, "p": 2}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_laedt: set[str] = set()
|
||||||
|
_gemerkt: dict[str, tuple[float, dict]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def apk_teile(version: str) -> tuple:
|
||||||
|
"""Eine Alpine-Version („1.37.0-r18“, „3.3.2_rc1-r0“, „1.2a-r1“) als vergleichbares Tupel: Zahlen (Buchstaben
|
||||||
|
als kleine Zahlen), Zusätze (_alpha < _beta < _pre < _rc < ohne < _p), zuletzt die Paketrevision -rN."""
|
||||||
|
haupt, _, revision = version.partition("-r")
|
||||||
|
kern, *zusaetze = haupt.split("_")
|
||||||
|
teile = tuple(int(x) if x.isdigit() else ord(x[0]) - 96 for x in re.findall(r"\d+|[a-z]+", kern.lower()))
|
||||||
|
suffixe = []
|
||||||
|
for z in zusaetze:
|
||||||
|
m = re.fullmatch(r"([a-z]+)(\d*)", z)
|
||||||
|
suffixe.append((_SUFFIX.get(m.group(1), 0), int(m.group(2) or 0)) if m else (0, 0))
|
||||||
|
return (teile, tuple(suffixe) or ((0, 0),), int(revision) if revision.isdigit() else 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _pfad(zweig: str) -> Path:
|
||||||
|
return einstellungen().daten_dir / f"alpine-secdb-v{zweig}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _laden(zweig: str) -> None:
|
||||||
|
"""Beide Repos des Zweigs holen und als {paket: [Versionen mit Fixes]} ablegen (eigener Faden)."""
|
||||||
|
try:
|
||||||
|
fixes: dict[str, list[str]] = {}
|
||||||
|
for repo in ("main", "community"):
|
||||||
|
r = httpx.get(SECDB.format(zweig=zweig, repo=repo), timeout=30, follow_redirects=True)
|
||||||
|
r.raise_for_status()
|
||||||
|
for eintrag in r.json().get("packages") or []:
|
||||||
|
pkg = (eintrag or {}).get("pkg") or {}
|
||||||
|
if pkg.get("name") and isinstance(pkg.get("secfixes"), dict):
|
||||||
|
fixes.setdefault(pkg["name"], []).extend(v for v in pkg["secfixes"] if v != "0")
|
||||||
|
pfad = _pfad(zweig)
|
||||||
|
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = pfad.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps({"geholt": time.time(), "fixes": fixes}), encoding="utf-8")
|
||||||
|
tmp.replace(pfad)
|
||||||
|
log.info("sicherheit: Alpine-Sicherheitsdatenbank v%s geladen (%d Pakete)", zweig, len(fixes))
|
||||||
|
except Exception:
|
||||||
|
log.warning("sicherheit: Alpine-Sicherheitsdatenbank v%s nicht geladen", zweig, exc_info=True)
|
||||||
|
finally:
|
||||||
|
with _lock:
|
||||||
|
_laedt.discard(zweig)
|
||||||
|
|
||||||
|
|
||||||
|
def secdb(zweig: str, jetzt: float | None = None) -> dict | None:
|
||||||
|
"""{paket: [Versionen mit Fixes]} — aus der Datei, im Speicher gemerkt; ist sie alt oder fehlt, lädt ein Faden
|
||||||
|
sie nach (bis dahin die alte oder None). Blockiert nie."""
|
||||||
|
jetzt = time.time() if jetzt is None else jetzt
|
||||||
|
pfad = _pfad(zweig)
|
||||||
|
daten = None
|
||||||
|
try:
|
||||||
|
mtime = pfad.stat().st_mtime
|
||||||
|
with _lock:
|
||||||
|
alt = _gemerkt.get(zweig)
|
||||||
|
if alt and alt[0] == mtime:
|
||||||
|
daten = alt[1]
|
||||||
|
else:
|
||||||
|
daten = json.loads(pfad.read_text(encoding="utf-8"))
|
||||||
|
with _lock:
|
||||||
|
_gemerkt[zweig] = (mtime, daten)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
daten = None
|
||||||
|
if daten is None or jetzt - float(daten.get("geholt") or 0) > NEU_LADEN_S:
|
||||||
|
with _lock:
|
||||||
|
starten = zweig not in _laedt
|
||||||
|
_laedt.add(zweig)
|
||||||
|
if starten:
|
||||||
|
threading.Thread(target=_laden, args=(zweig,), name=f"secdb-{zweig}", daemon=True).start()
|
||||||
|
return daten.get("fixes") if daten else None
|
||||||
|
|
||||||
|
|
||||||
|
def alpine_sicherheit(os_stand: dict, fixes: dict | None = None) -> int | None:
|
||||||
|
"""Wie viele der offenen Alpine-Updates eine Lücke schließen (None, solange die Datenbank fehlt)."""
|
||||||
|
pakete = os_stand.get("pakete")
|
||||||
|
if not isinstance(pakete, list):
|
||||||
|
return None
|
||||||
|
if not pakete:
|
||||||
|
return 0
|
||||||
|
if fixes is None:
|
||||||
|
zweig = ".".join(str(os_stand.get("alpine") or "").split(".")[:2])
|
||||||
|
if not re.fullmatch(r"\d+\.\d+", zweig):
|
||||||
|
return None
|
||||||
|
fixes = secdb(zweig)
|
||||||
|
if fixes is None:
|
||||||
|
return None
|
||||||
|
anzahl = 0
|
||||||
|
for p in pakete:
|
||||||
|
alt, neu = apk_teile(str(p.get("alt") or "")), apk_teile(str(p.get("neu") or ""))
|
||||||
|
if any(alt < apk_teile(v) <= neu for v in fixes.get(str(p.get("name")), [])):
|
||||||
|
anzahl += 1
|
||||||
|
return anzahl
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Sicherheits-Markierung (Ausbauplan Welle 2, Punkt 11, 25.09.2026): Debian-Suiten „-security“ (Ausführer), Alpine
|
||||||
|
über die Sicherheitsdatenbank (NPMplus, Alpine 3.24), Lücken-Scans aus Arcane je Container."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from kern import einstellungen as einstellungen_mod
|
||||||
|
from services.homelab import arcane, docker, einstellungen, sicherheit
|
||||||
|
|
||||||
|
AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def a():
|
||||||
|
spec = importlib.util.spec_from_file_location("ausfuehrer_sicherheit", AUSFUEHRER)
|
||||||
|
modul = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(modul)
|
||||||
|
return modul
|
||||||
|
|
||||||
|
|
||||||
|
def test_alpine_versionen_vergleichen():
|
||||||
|
t = sicherheit.apk_teile
|
||||||
|
assert t("1.37.0-r18") < t("1.37.0-r19") < t("1.37.1-r0")
|
||||||
|
assert t("3.3.2_rc1-r0") < t("3.3.2-r0") < t("3.3.2_p1-r0")
|
||||||
|
assert t("1.2-r0") < t("1.2a-r0") and t("2.042-r0") > t("2.41-r9")
|
||||||
|
|
||||||
|
|
||||||
|
def test_alpine_sicherheitsupdates_zaehlen():
|
||||||
|
fixes = {"busybox": ["1.37.0-r19", "1.36.1-r2"], "py3-requests": ["2.32.0-r0"]}
|
||||||
|
stand = {"alpine": "3.24.2", "pakete": [
|
||||||
|
{"name": "busybox", "alt": "1.37.0-r18", "neu": "1.37.0-r19"}, # schließt eine Lücke
|
||||||
|
{"name": "py3-requests", "alt": "2.32.3-r0", "neu": "2.32.4-r0"}, # Fix war schon älter
|
||||||
|
{"name": "curl", "alt": "8.14.1-r0", "neu": "8.14.1-r1"}]} # ohne Eintrag
|
||||||
|
assert sicherheit.alpine_sicherheit(stand, fixes) == 1
|
||||||
|
assert sicherheit.alpine_sicherheit({"pakete": []}) == 0
|
||||||
|
assert sicherheit.alpine_sicherheit({"anzahl": 3}) is None # Debian-Stand: nicht hier
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def daten(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
|
||||||
|
einstellungen_mod.einstellungen.cache_clear()
|
||||||
|
sicherheit._gemerkt.clear()
|
||||||
|
yield tmp_path
|
||||||
|
einstellungen_mod.einstellungen.cache_clear()
|
||||||
|
sicherheit._gemerkt.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_datenbank_laedt_im_hintergrund_und_blockiert_nie(daten, monkeypatch):
|
||||||
|
geladen: list[str] = []
|
||||||
|
monkeypatch.setattr(sicherheit, "_laden", lambda zweig: geladen.append(zweig) or sicherheit._laedt.discard(zweig))
|
||||||
|
monkeypatch.setattr(sicherheit.threading, "Thread", lambda target, args, name, daemon: type(
|
||||||
|
"T", (), {"start": lambda self: target(*args)})())
|
||||||
|
assert sicherheit.secdb("3.24") is None and geladen == ["3.24"] # fehlt: nachladen, bis dahin None
|
||||||
|
(daten / "alpine-secdb-v3.24.json").write_text(json.dumps({"geholt": time.time(), "fixes": {"busybox": ["1-r1"]}}),
|
||||||
|
encoding="utf-8")
|
||||||
|
assert sicherheit.secdb("3.24") == {"busybox": ["1-r1"]} and geladen == ["3.24"]
|
||||||
|
alt = time.time() - sicherheit.NEU_LADEN_S - 60
|
||||||
|
(daten / "alpine-secdb-v3.24.json").write_text(json.dumps({"geholt": alt, "fixes": {"x": []}}), encoding="utf-8")
|
||||||
|
assert sicherheit.secdb("3.24") == {"x": []} and geladen == ["3.24", "3.24"] # alt: liefern und nachladen
|
||||||
|
|
||||||
|
|
||||||
|
# --- Ausführer ---------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_ausfuehrer_zaehlt_sicherheitsupdates(a, monkeypatch):
|
||||||
|
monkeypatch.setattr(a, "_im_gast", lambda vmid, befehl, zeitlimit=30: (0, "12\n3\n1790329178\n"))
|
||||||
|
assert a._os_updates(104, "debian") == {"anzahl": 12, "sicherheit": 3, "listen_stand": 1790329178}
|
||||||
|
monkeypatch.setattr(a, "_im_gast", lambda vmid, befehl, zeitlimit=30: (
|
||||||
|
0, "3.24.2\n1790328932\nbusybox-1.37.0-r18 < 1.37.0-r19\n"))
|
||||||
|
alpine = a._os_updates(101, "alpine")
|
||||||
|
assert (alpine["anzahl"], alpine["alpine"], alpine["pakete"]) == (
|
||||||
|
1, "3.24.2", [{"name": "busybox", "alt": "1.37.0-r18", "neu": "1.37.0-r19"}])
|
||||||
|
liste = ("Listing...\ncurl/stable-security,stable 8.14.1-2+deb13u1 amd64 [upgradable from: 8.14.1-2]\n"
|
||||||
|
"pve-manager/stable 9.2.20 all [upgradable from: 9.2.19]\n")
|
||||||
|
assert a.sicherheit_auf_dem_host(liste) == ["curl"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Homelab-Teil ------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_bausteine_tragen_die_markierung(monkeypatch):
|
||||||
|
from services.homelab import inventar
|
||||||
|
gast = {"vmid": 104, "art": "lxc", "os_updates": {"anzahl": 12, "sicherheit": 3, "listen_stand": time.time()},
|
||||||
|
"snapshot_moeglich": True}
|
||||||
|
stand = inventar._os_baustein(gast, "ct-104", time.time())
|
||||||
|
assert (stand.sicherheit, stand.kurz) == (3, "12 Pakete") and "(davon 3 Sicherheitsupdates)" in stand.aktion.frage
|
||||||
|
host = inventar._host_ziel({"version": "9.2.19", "updates": [{"paket": "curl"}, {"paket": "vim"}],
|
||||||
|
"updates_sicherheit": ["curl"]})
|
||||||
|
assert host.bausteine[0].sicherheit == 1
|
||||||
|
ohne = inventar._host_ziel({"version": "9.2.19", "updates": [{"paket": "vim"}]})
|
||||||
|
assert ohne.bausteine[0].sicherheit is None # älterer Ausführer: unbekannt
|
||||||
|
|
||||||
|
|
||||||
|
def test_docker_mit_luecken_aus_arcane(monkeypatch):
|
||||||
|
monkeypatch.setattr(einstellungen, "arcane_url", lambda: "http://arcane")
|
||||||
|
monkeypatch.setattr(arcane, "schluessel", lambda: "schluessel")
|
||||||
|
monkeypatch.setattr(arcane, "container_liste", lambda url: [
|
||||||
|
{"id": "a1", "names": ["/nerdquiz"], "imageId": "sha256:q", "state": "running", "labels": {}},
|
||||||
|
{"id": "a2", "names": ["/arcane"], "imageId": "sha256:a", "state": "running", "labels": {}}])
|
||||||
|
gefragt: list[tuple] = []
|
||||||
|
monkeypatch.setattr(arcane, "luecken", lambda url, ids: gefragt.append(ids) or {
|
||||||
|
"sha256:q": {"kritisch": 2, "hoch": 5, "mittel": 9, "gesamt": 30, "zeit": "2026-09-27T02:04:00Z"}})
|
||||||
|
zeilen = {c["name"]: c for s in docker.lage()["stacks"] for c in s["container"]}
|
||||||
|
assert gefragt == [("sha256:a", "sha256:q")]
|
||||||
|
assert zeilen["nerdquiz"]["luecken"]["kritisch"] == 2 and zeilen["arcane"]["luecken"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_arcane_luecken_auslesen(monkeypatch):
|
||||||
|
monkeypatch.setattr(arcane, "schluessel", lambda: "schluessel")
|
||||||
|
monkeypatch.setattr(arcane, "umgebungen", lambda url, s=None: [{"id": 0}])
|
||||||
|
arcane.cache_leeren()
|
||||||
|
|
||||||
|
class Antwort:
|
||||||
|
def raise_for_status(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {"data": {"summaries": {
|
||||||
|
"sha256:q": {"scanTime": "2026-09-27T02:04:00Z", "summary": {"critical": 2, "high": 5, "total": 30}},
|
||||||
|
"sha256:n": {"status": "not_scanned", "summary": {}}}}}
|
||||||
|
|
||||||
|
monkeypatch.setattr(arcane.httpx, "post", lambda url, headers, json, timeout: Antwort())
|
||||||
|
assert arcane.luecken("http://arcane", ("sha256:n", "sha256:q")) == {
|
||||||
|
"sha256:q": {"kritisch": 2, "hoch": 5, "mittel": 0, "gesamt": 30, "zeit": "2026-09-27T02:04:00Z"}}
|
||||||
|
assert arcane.luecken("http://arcane", ()) == {}
|
||||||
@@ -320,21 +320,54 @@ def _app_version(vmid: int, kennung: str) -> str | None:
|
|||||||
return m.group(1) if m else zeile[:40]
|
return m.group(1) if m else zeile[:40]
|
||||||
|
|
||||||
|
|
||||||
|
# Sicherheitsupdates (seit 25.09.2026, Ausbauplan Welle 2, Punkt 11): Debian und Ubuntu liefern sie aus eigenen Suiten
|
||||||
|
# („trixie-security“, „jammy-security“) — das steht in `apt list --upgradable` hinter dem Paketnamen. Alpine hat das
|
||||||
|
# nicht; dort geht die Liste mit den Versionen mit, und der Homelab-Teil gleicht sie mit Alpines Sicherheitsdatenbank ab.
|
||||||
|
APT_ZAEHLEN = ("LC_ALL=C apt list --upgradable 2>/dev/null"
|
||||||
|
" | awk '/upgradable/{n++; if ($1 ~ /-security/) s++} END{print n+0; print s+0}'; "
|
||||||
|
"stat -c %Y /var/lib/apt/lists 2>/dev/null || echo 0")
|
||||||
|
APK_LISTE = ("cat /etc/alpine-release 2>/dev/null || echo ?; stat -c %Y /var/cache/apk 2>/dev/null || echo 0; "
|
||||||
|
"apk version -l '<' 2>/dev/null | tail -n +2")
|
||||||
|
_APK_ZEILE = re.compile(r"^(\S+?)-(\d\S*)\s+<\s+(\S+)$")
|
||||||
|
|
||||||
|
|
||||||
|
def apk_auswerten(text: str) -> dict:
|
||||||
|
"""Ausgabe von APK_LISTE → {"anzahl", "listen_stand", "alpine", "pakete": [{"name", "alt", "neu"}]}."""
|
||||||
|
zeilen = text.splitlines()
|
||||||
|
pakete = [{"name": m.group(1), "alt": m.group(2), "neu": m.group(3)}
|
||||||
|
for z in zeilen[2:] if (m := _APK_ZEILE.match(z.strip()))]
|
||||||
|
try:
|
||||||
|
stand = int(zeilen[1].strip()) or None
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
stand = None
|
||||||
|
return {"anzahl": len(pakete), "listen_stand": stand, "alpine": zeilen[0].strip() if zeilen else None,
|
||||||
|
"pakete": pakete[:300]}
|
||||||
|
|
||||||
|
|
||||||
def _os_updates(vmid: int, ostype: str) -> dict:
|
def _os_updates(vmid: int, ostype: str) -> dict:
|
||||||
"""Aktualisierbare Pakete nach den vorhandenen Paketlisten (liest nur) und wie alt die Listen sind."""
|
"""Aktualisierbare Pakete nach den vorhandenen Paketlisten (liest nur), wie alt die Listen sind und — seit
|
||||||
|
25.09.2026 — wie viele davon Sicherheitsupdates sind (Alpine: die Liste für den Abgleich im Homelab-Teil)."""
|
||||||
if ostype == "alpine":
|
if ostype == "alpine":
|
||||||
code, text = _im_gast(vmid, "apk version -l '<' 2>/dev/null | tail -n +2 | wc -l; "
|
code, text = _im_gast(vmid, APK_LISTE, 30)
|
||||||
"stat -c %Y /var/cache/apk 2>/dev/null || echo 0", 30)
|
return apk_auswerten(text) if code in (0, 1) else {"anzahl": None, "listen_stand": None, "fehler": text[:160]}
|
||||||
else:
|
code, text = _im_gast(vmid, APT_ZAEHLEN, 30)
|
||||||
code, text = _im_gast(vmid, "LC_ALL=C apt list --upgradable 2>/dev/null | grep -c upgradable; "
|
|
||||||
"stat -c %Y /var/lib/apt/lists 2>/dev/null || echo 0", 30)
|
|
||||||
zeilen = text.split() if code in (0, 1) else []
|
zeilen = text.split() if code in (0, 1) else []
|
||||||
try:
|
try:
|
||||||
return {"anzahl": int(zeilen[0]), "listen_stand": int(zeilen[1]) or None}
|
return {"anzahl": int(zeilen[0]), "sicherheit": int(zeilen[1]), "listen_stand": int(zeilen[2]) or None}
|
||||||
except (IndexError, ValueError):
|
except (IndexError, ValueError):
|
||||||
return {"anzahl": None, "listen_stand": None, "fehler": text[:160]}
|
return {"anzahl": None, "listen_stand": None, "fehler": text[:160]}
|
||||||
|
|
||||||
|
|
||||||
|
def sicherheit_auf_dem_host(apt_liste: str) -> list[str]:
|
||||||
|
"""Die Pakete aus `apt list --upgradable`, die aus einer Sicherheits-Suite kommen."""
|
||||||
|
namen = []
|
||||||
|
for zeile in apt_liste.splitlines():
|
||||||
|
kopf = zeile.split(" ", 1)[0]
|
||||||
|
if "upgradable" in zeile and "/" in kopf and "-security" in kopf.split("/", 1)[1]:
|
||||||
|
namen.append(kopf.split("/", 1)[0])
|
||||||
|
return namen
|
||||||
|
|
||||||
|
|
||||||
def _ip_lxc(vmid: int) -> str | None:
|
def _ip_lxc(vmid: int) -> str | None:
|
||||||
try:
|
try:
|
||||||
for schnitt in _pvesh(f"/nodes/{NODE}/lxc/{vmid}/interfaces"):
|
for schnitt in _pvesh(f"/nodes/{NODE}/lxc/{vmid}/interfaces"):
|
||||||
@@ -834,6 +867,9 @@ def _host() -> dict:
|
|||||||
host["version"] = _pvesh("/version").get("version")
|
host["version"] = _pvesh("/version").get("version")
|
||||||
host["updates"] = [{"paket": p.get("Package"), "alt": p.get("OldVersion"), "neu": p.get("Version"),
|
host["updates"] = [{"paket": p.get("Package"), "alt": p.get("OldVersion"), "neu": p.get("Version"),
|
||||||
"herkunft": p.get("Origin")} for p in _pvesh(f"/nodes/{NODE}/apt/update")]
|
"herkunft": p.get("Origin")} for p in _pvesh(f"/nodes/{NODE}/apt/update")]
|
||||||
|
code, liste = _laufen(["env", "LC_ALL=C", "apt", "list", "--upgradable"], 60)
|
||||||
|
if code == 0:
|
||||||
|
host["updates_sicherheit"] = sicherheit_auf_dem_host(liste)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
host["fehler"] = str(exc)[:200]
|
host["fehler"] = str(exc)[:200]
|
||||||
code, kernel = _laufen(["uname", "-r"], 5)
|
code, kernel = _laufen(["uname", "-r"], 5)
|
||||||
|
|||||||
@@ -31,14 +31,26 @@ function zustandText(c: DockerContainer): string {
|
|||||||
/** Aus „ghcr.io/getarcaneapp/manager:latest“ wird „manager“ — für das Logo. */
|
/** Aus „ghcr.io/getarcaneapp/manager:latest“ wird „manager“ — für das Logo. */
|
||||||
const imageName = (image: string | null) => (image ?? "").split("/").at(-1)?.split(":")[0] ?? ""
|
const imageName = (image: string | null) => (image ?? "").split("/").at(-1)?.split(":")[0] ?? ""
|
||||||
|
|
||||||
|
/** „2 kritische, 5 hohe Lücken“ aus Arcanes Scan (seit 25.09.2026) — nur, wenn es kritische oder hohe gibt. */
|
||||||
|
function lueckenText(c: DockerContainer): string | null {
|
||||||
|
const l = c.luecken
|
||||||
|
if (!l || (!l.kritisch && !l.hoch)) return null
|
||||||
|
const teile = [l.kritisch ? `${l.kritisch} kritische` : null, l.hoch ? `${l.hoch} hohe` : null].filter(Boolean)
|
||||||
|
return `${teile.join(", ")} Lücken`
|
||||||
|
}
|
||||||
|
|
||||||
function Zeile({ c }: { c: DockerContainer }) {
|
function Zeile({ c }: { c: DockerContainer }) {
|
||||||
const u = UPDATE[c.update] ?? UPDATE.unbekannt
|
const u = UPDATE[c.update] ?? UPDATE.unbekannt
|
||||||
|
const luecken = lueckenText(c)
|
||||||
return (
|
return (
|
||||||
<li className="flex min-w-0 items-center gap-3 border-t border-white/[0.06] py-2.5 first:border-t-0">
|
<li className="flex min-w-0 items-center gap-3 border-t border-white/[0.06] py-2.5 first:border-t-0">
|
||||||
<Geraetesymbol bild={geraetebild({ art: "container", name: c.dienst ?? imageName(c.image) })} groesse="sm" punkt={punkt(c)} />
|
<Geraetesymbol bild={geraetebild({ art: "container", name: c.dienst ?? imageName(c.image) })} groesse="sm" punkt={punkt(c)} />
|
||||||
<span className="flex min-w-0 flex-1 flex-col">
|
<span className="flex min-w-0 flex-1 flex-col">
|
||||||
<span className="truncate text-[14.5px] font-medium">{c.name}</span>
|
<span className="truncate text-[14.5px] font-medium">{c.name}</span>
|
||||||
<span className="truncate text-[12.5px] text-text-3">{c.image}</span>
|
<span className="truncate text-[12.5px] text-text-3">{c.image}</span>
|
||||||
|
{luecken && (
|
||||||
|
<span className={cn("truncate text-[12.5px]", c.luecken?.kritisch ? "text-rot-text" : "text-bernstein")}>{luecken}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex shrink-0 flex-col items-end text-right">
|
<span className="flex shrink-0 flex-col items-end text-right">
|
||||||
<span className={cn("text-[13px]", punkt(c) === "rot" ? "text-rot-text" : "text-text-2")}>{zustandText(c)}</span>
|
<span className={cn("text-[13px]", punkt(c) === "rot" ? "text-rot-text" : "text-text-2")}>{zustandText(c)}</span>
|
||||||
|
|||||||
@@ -42,7 +42,14 @@ function PaketZelle({ b, onSuchen, gesperrt }: { b: BausteinStand | null; onSuch
|
|||||||
if (!b) return <span className="text-sm text-text-3">–</span>
|
if (!b) return <span className="text-sm text-text-3">–</span>
|
||||||
const docker = b.id === "docker"
|
const docker = b.id === "docker"
|
||||||
let text
|
let text
|
||||||
if (b.zustand === "neu") text = <span className="text-cyan-text">{b.kurz || "Update bereit"}</span>
|
if (b.zustand === "neu") {
|
||||||
|
text = (
|
||||||
|
<span className="text-cyan-text">
|
||||||
|
{b.kurz || "Update bereit"}
|
||||||
|
{!!b.sicherheit && <span className="text-rot-text"> · {b.sicherheit} Sicherheit</span>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
else if (b.zustand === "aktuell") text = <span className="text-gruen-text">{docker ? "Images aktuell" : "aktuell"}</span>
|
else if (b.zustand === "aktuell") text = <span className="text-gruen-text">{docker ? "Images aktuell" : "aktuell"}</span>
|
||||||
else {
|
else {
|
||||||
const datum = listenAlter(b.grund)
|
const datum = listenAlter(b.grund)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { kennzahlText, lage, lampeFuer, listenAlter, offeneUpdates, rueckwegText } from "@/lib/homelab"
|
import { kennzahlText, lage, lampeFuer, listenAlter, offeneUpdates, rueckwegText, sicherheitText } from "@/lib/homelab"
|
||||||
import type { BausteinStand, Hinweis, HomelabStand, ZielStand } from "@/lib/typen"
|
import type { BausteinStand, Hinweis, HomelabStand, ZielStand } from "@/lib/typen"
|
||||||
|
|
||||||
const baustein = (b: Partial<BausteinStand> & Pick<BausteinStand, "id" | "zustand">): BausteinStand => ({
|
const baustein = (b: Partial<BausteinStand> & Pick<BausteinStand, "id" | "zustand">): BausteinStand => ({
|
||||||
@@ -110,3 +110,12 @@ describe("kennzahlText (seit 25.09.2026)", () => {
|
|||||||
expect(kennzahlText(undefined)).toBeNull()
|
expect(kennzahlText(undefined)).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("Sicherheits-Markierung (seit 25.09.2026)", () => {
|
||||||
|
it("hängt die Zahl der Sicherheitsupdates an", () => {
|
||||||
|
const pakete = baustein({ id: "os", zustand: "neu", kurz: "12 Pakete", sicherheit: 3 })
|
||||||
|
expect(sicherheitText(pakete)).toBe(" · 3 Sicherheit")
|
||||||
|
expect(lampeFuer(ziel({ id: "ct-104", name: "Gitea", bausteine: [pakete] }), false).wert).toBe("12 Pakete · 3 Sicherheit")
|
||||||
|
expect(sicherheitText(baustein({ id: "os", zustand: "neu", kurz: "2 Pakete", sicherheit: 0 }))).toBe("")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -95,7 +95,12 @@ export const versionText = (v: string) => v.replace(/^Image vom /, "")
|
|||||||
/** Was die Versionsspalte „Neu“ zeigt: die neue Version einer App, sonst „58 Pakete“. */
|
/** Was die Versionsspalte „Neu“ zeigt: die neue Version einer App, sonst „58 Pakete“. */
|
||||||
export function neuText(b: BausteinStand): string {
|
export function neuText(b: BausteinStand): string {
|
||||||
if (istApp(b) && b.verfuegbar) return b.verfuegbar
|
if (istApp(b) && b.verfuegbar) return b.verfuegbar
|
||||||
return b.kurz || b.verfuegbar || "neu"
|
return `${b.kurz || b.verfuegbar || "neu"}${sicherheitText(b)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „ · 3 Sicherheit“ — wenn Updates eine Sicherheitslücke schließen (seit 25.09.2026), sonst leer. */
|
||||||
|
export function sicherheitText(b: BausteinStand): string {
|
||||||
|
return b.sicherheit ? ` · ${b.sicherheit} Sicherheit` : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Anteil der belegten Platte (0…1) oder null, wo unbekannt. Ab 80 % gelb, ab 90 % rot — wie der Wächter (ext4 hält 5 % zurück). */
|
/** Anteil der belegten Platte (0…1) oder null, wo unbekannt. Ab 80 % gelb, ab 90 % rot — wie der Wächter (ext4 hält 5 % zurück). */
|
||||||
|
|||||||
@@ -306,6 +306,8 @@ export interface BausteinStand {
|
|||||||
kurz: string
|
kurz: string
|
||||||
grund: string | null
|
grund: string | null
|
||||||
aktion: ZielAktion | null
|
aktion: ZielAktion | null
|
||||||
|
/** Davon Sicherheitsupdates (seit 25.09.2026; null/fehlt = unbekannt). */
|
||||||
|
sicherheit?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ein Gerät (KI-Box, Proxmox-Host, Container, VM) mit seinen Bausteinen. */
|
/** Ein Gerät (KI-Box, Proxmox-Host, Container, VM) mit seinen Bausteinen. */
|
||||||
@@ -697,6 +699,8 @@ export interface DockerContainer {
|
|||||||
update: "neu" | "lokal" | "aktuell" | "unbekannt"
|
update: "neu" | "lokal" | "aktuell" | "unbekannt"
|
||||||
aktuell: string | null
|
aktuell: string | null
|
||||||
neu: string | null
|
neu: string | null
|
||||||
|
/** Arcanes Lücken-Scan des Images (seit 25.09.2026); null = noch nicht gescannt. */
|
||||||
|
luecken?: { kritisch: number; hoch: number; mittel: number; gesamt: number; zeit: string } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DockerLage {
|
export interface DockerLage {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { systemBaustein, appBaustein } from "@/lib/homelab"
|
import { appBaustein, sicherheitText, systemBaustein } from "@/lib/homelab"
|
||||||
import type { BausteinStand, ZielStand } from "@/lib/typen"
|
import type { BausteinStand, ZielStand } from "@/lib/typen"
|
||||||
|
|
||||||
// Die Kurzform für die Übersicht: wer hat wo welche Updates — in einer Zeile je Gerät.
|
// Die Kurzform für die Übersicht: wer hat wo welche Updates — in einer Zeile je Gerät.
|
||||||
@@ -32,7 +32,7 @@ export function kurzform(ziel: ZielStand): KurzZeile | null {
|
|||||||
teile.push(`App → ${app.verfuegbar ?? "neu"}${w ? ` (${w})` : ""}`)
|
teile.push(`App → ${app.verfuegbar ?? "neu"}${w ? ` (${w})` : ""}`)
|
||||||
}
|
}
|
||||||
const sys = systemBaustein(ziel)
|
const sys = systemBaustein(ziel)
|
||||||
if (sys?.zustand === "neu") teile.push(sys.kurz || (sys.id === "docker" ? "Images" : "Pakete"))
|
if (sys?.zustand === "neu") teile.push(`${sys.kurz || (sys.id === "docker" ? "Images" : "Pakete")}${sicherheitText(sys)}`)
|
||||||
// KI-Box: Bausteine heißen selbst (Betriebssystem, Hermes, Motor …).
|
// KI-Box: Bausteine heißen selbst (Betriebssystem, Hermes, Motor …).
|
||||||
for (const b of neu) {
|
for (const b of neu) {
|
||||||
if (b === app || b === sys) continue
|
if (b === app || b === sys) continue
|
||||||
|
|||||||
Reference in New Issue
Block a user