edge ferme a l Internet ; le pare-feu Proxmox recoit les flux externe
L edge n expose qu a sa zone d administration (plus de pair externe) ; le public passera par le web frontal. Le devis Proxmox recoit les flux externe depuis +t<i>-internet (RFC 1918 exclus en nomatch) et depuis l admin quand le poste en est client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1bd211d62b
commit
1493059271
9 changed files with 184 additions and 24 deletions
30
CHANGELOG.md
30
CHANGELOG.md
|
|
@ -1,5 +1,35 @@
|
|||
# CHANGELOG — Set-OPS
|
||||
|
||||
## 2026-09-29 (33) — L'edge fermé à l'Internet ; le pare-feu Proxmox reçoit ce que la frontière laisse entrer
|
||||
|
||||
**La règle, mise au point par l'exploitant** : les URL `*.internal` des edges ne s'ouvrent
|
||||
qu'à la zone d'administration de leur écosystème ; ce qui sera publié sur l'Internet le sera
|
||||
par le **web frontal**, pas par l'edge.
|
||||
|
||||
**L'edge déclarait pourtant 80/443 `externe`.** Conséquences, dérivées du même registre :
|
||||
la frontière portait « WAN, n'importe qui → edge 80/443 » chez les deux locataires, et le
|
||||
pare-feu de la machine (`nftables`) de chaque edge — site compris — acceptait 80 et 443 de
|
||||
toute source. Seul le pare-feu Proxmox, qui sautait tout `externe`, fermait la porte.
|
||||
Désormais `serveur_nginx` ne déclare plus d'`externe` : 443 depuis `flotte` et `admin`, 80
|
||||
(redirection) depuis `admin`. Vérifié avant : le poste joint les edges par le réseau de
|
||||
gestion (10.37.0.17, règle `admin`), et les journaux d'accès des trois edges ne montrent
|
||||
aucun client hors flotte et administration.
|
||||
|
||||
**Le pare-feu Proxmox et les flux `externe`.** Il les sautait tous (« la frontière s'en
|
||||
charge ») ; depuis que les VM rejettent par défaut, un service publié aurait été refusé par
|
||||
sa propre VM — et le poste ne joignait pas Dovecot 993, que la frontière lui ouvre. Même
|
||||
partage que la frontière : un flux entrant `externe` est reçu depuis `+t<i>-internet`
|
||||
(`0.0.0.0/1`, `128.0.0.0/1`, les trois blocs RFC 1918 exclus en `nomatch` — jamais une source
|
||||
vide, et un IPSet refuse `/0`), et depuis `+t<i>-admin` quand le poste en est client
|
||||
(`poste`, vrai par défaut ; faux pour le 25 de Postfix). Le SSH `externe` reste réservé à
|
||||
l'administration. Rendu : Postfix 25 (Internet), Dovecot 993 (Internet, admin), ICMP
|
||||
« fragmentation nécessaire » (découverte de MTU). `appliquer_proxmox_fw` pose et relit les
|
||||
exclusions. `test_proxmox_fw_externe.py` garde les invariants : edge jamais ouvert à
|
||||
l'Internet, tout `externe` reçu, aucune source vide, exclusions en `nomatch`.
|
||||
|
||||
**Le web frontal** ne déclare encore que le 80 depuis l'edge : le rendre public (son propre
|
||||
TLS, sa redirection à la frontière) est l'étape suivante, pas celle-ci.
|
||||
|
||||
## 2026-09-29 (32) — L'edge expose aux personnes ; entre machines du site, on va au service
|
||||
|
||||
**La règle, énoncée par l'exploitant** : l'edge sert à exposer des interfaces web ; les
|
||||
|
|
|
|||
1
Makefile
1
Makefile
|
|
@ -319,6 +319,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
|
|||
python3 scripts/tests/test_sondes_syntaxe.py
|
||||
python3 scripts/tests/test_empreinte_plancher.py
|
||||
python3 scripts/tests/test_expositions_sans_port.py
|
||||
python3 scripts/tests/test_proxmox_fw_externe.py
|
||||
|
||||
# LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une
|
||||
# journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine
|
||||
|
|
|
|||
|
|
@ -74,8 +74,7 @@
|
|||
| `serveur_nextcloud` | egress | 443 | tcp | edge | tls-requis | Découverte OIDC auprès de Keycloak (via son FQDN publié à l'edge). |
|
||||
| `serveur_nextcloud` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Base de données Nextcloud (verify-full). |
|
||||
| `serveur_nextcloud` | egress | 9980 | tcp | serveur_collabora | tls-cible | Vérifications WOPI serveur->Collabora (édition en ligne). TLS interne = feuille de route edge->backends. |
|
||||
| `serveur_nginx` | ingress | 80 | tcp | externe | clair | HTTP entrant — redirection permanente vers HTTPS. |
|
||||
| `serveur_nginx` | ingress | 443 | tcp | externe | tls-requis | HTTPS entrant — services exposés (terminaison TLS à l'edge). |
|
||||
| `serveur_nginx` | ingress | 80 | tcp | admin | clair | HTTP depuis le reseau d'administration — redirection permanente vers HTTPS. |
|
||||
| `serveur_nginx` | ingress | 443 | tcp | flotte | tls-requis | HTTPS depuis le tenant : les FQDN publiés vivent à l'edge (découverte OIDC, appels inter-services par nom). |
|
||||
| `serveur_nginx` | ingress | 443 | tcp | admin | tls-requis | HTTPS depuis le reseau d'administration : l'exploitant administre les services par leur interface web, servie par l'edge. |
|
||||
| `serveur_nginx` | egress | derive | tcp | expositions | tls-cible | Proxy vers les backends exposés (host:port dérivés des expose ; TLS interne = roadmap edge→backends). |
|
||||
|
|
@ -135,4 +134,4 @@
|
|||
- **starttls** : 6 flux
|
||||
- **tls** : 11 flux
|
||||
- **tls-cible** : 2 flux
|
||||
- **tls-requis** : 35 flux
|
||||
- **tls-requis** : 34 flux
|
||||
|
|
|
|||
|
|
@ -3,18 +3,17 @@
|
|||
# Cas « edge » : ingress depuis l'extérieur (frontière publique) + egress vers les backends,
|
||||
# ces derniers dérivés des `expose:` des applications (pair: expositions).
|
||||
flux:
|
||||
# PAS D'`externe` (2026-09-29). L'edge expose les interfaces web de l'ecosysteme a SA
|
||||
# zone d'administration, et a personne d'autre ; ce qui sera publie sur l'Internet le sera
|
||||
# par le WEB FRONTAL. Les deux entrees `externe` qui figuraient ici posaient a la frontiere
|
||||
# « WAN, n'importe qui -> edge 80/443 » chez chaque locataire — une ouverture que rien ne
|
||||
# devait porter (le pare-feu Proxmox de l'edge la rejetait deja).
|
||||
- sens: ingress
|
||||
port: 80
|
||||
protocole: tcp
|
||||
pair: externe
|
||||
pair: admin
|
||||
chiffrement: clair
|
||||
raison: "HTTP entrant — redirection permanente vers HTTPS."
|
||||
- sens: ingress
|
||||
port: 443
|
||||
protocole: tcp
|
||||
pair: externe
|
||||
chiffrement: tls-requis
|
||||
raison: "HTTPS entrant — services exposés (terminaison TLS à l'edge)."
|
||||
raison: "HTTP depuis le reseau d'administration — redirection permanente vers HTTPS."
|
||||
# L'edge sert AUSSI les hôtes du tenant : les FQDN publiés (`expose`) n'existent qu'ici,
|
||||
# et un service interne qui doit joindre un autre service passe par son nom publié — pas
|
||||
# par une adresse. C'est le cas de toute découverte OIDC : oauth2-proxy interroge
|
||||
|
|
|
|||
|
|
@ -74,6 +74,11 @@ def _norm(cidr: str) -> str:
|
|||
return c
|
||||
|
||||
|
||||
def _membre(m: str) -> dict:
|
||||
"""Corps de l'API pour un membre d'IPSet : `!cidr` devient une exclusion (`nomatch`)."""
|
||||
return {"cidr": m[1:], "nomatch": 1} if m.startswith("!") else {"cidr": m}
|
||||
|
||||
|
||||
def _regle_cle(r: dict) -> tuple:
|
||||
"""Identite d'une regle de groupe, insensible a la position et au commentaire."""
|
||||
return (str(r.get("type") or r.get("sens") or "").lower(),
|
||||
|
|
@ -130,7 +135,10 @@ def plan(api: Cluster, devis: dict) -> dict:
|
|||
ipsets_poses = {}
|
||||
for s in (_lire(api, "/cluster/firewall/ipset") or []):
|
||||
if isinstance(s, dict) and str(s.get("name", "")).startswith(prefixes):
|
||||
membres = [str(m.get("cidr")) for m in (api(f"/cluster/firewall/ipset/{s['name']}") or [])
|
||||
# `nomatch` : une EXCLUSION (l'IPSet « internet » exclut les blocs prives). On la
|
||||
# lit comme on l'ecrit, prefixee de `!` — sinon elle se lirait comme une inclusion.
|
||||
membres = [("!" if m.get("nomatch") else "") + str(m.get("cidr"))
|
||||
for m in (api(f"/cluster/firewall/ipset/{s['name']}") or [])
|
||||
if isinstance(m, dict)]
|
||||
ipsets_poses[s["name"]] = sorted({_norm(m) for m in membres})
|
||||
|
||||
|
|
@ -243,12 +251,12 @@ def appliquer(api: Cluster, p: dict) -> int:
|
|||
if _fait(api("/cluster/firewall/ipset", "POST",
|
||||
{"name": n, "comment": v["role"][:100]}), f"ipset {n}"):
|
||||
for m in v["membres"]:
|
||||
_fait(api(f"/cluster/firewall/ipset/{n}", "POST", {"cidr": m}), f"{n} += {m}")
|
||||
_fait(api(f"/cluster/firewall/ipset/{n}", "POST", _membre(m)), f"{n} += {m}")
|
||||
for n, (avant, apres) in sorted(p["ipsets_majer"].items()):
|
||||
for m in sorted(set(apres) - set(avant)):
|
||||
_fait(api(f"/cluster/firewall/ipset/{n}", "POST", {"cidr": m}), f"{n} += {m}")
|
||||
_fait(api(f"/cluster/firewall/ipset/{n}", "POST", _membre(m)), f"{n} += {m}")
|
||||
for m in sorted(set(avant) - set(apres)):
|
||||
_fait(api(f"/cluster/firewall/ipset/{n}/{m}", "DELETE"), f"{n} -= {m}")
|
||||
_fait(api(f"/cluster/firewall/ipset/{n}/{m.lstrip('!')}", "DELETE"), f"{n} -= {m}")
|
||||
|
||||
# 2. Groupes. Une mise a jour REMPLACE les regles : elles sont a nous, et les
|
||||
# comparer une a une pour un edit partiel couterait plus que de les reposer.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ PARTAGE DES ROLES (cf. docs/sdn-evpn.md) :
|
|||
profondeur — une VM compromise franchit deux barrieres) ;
|
||||
- `make devis-opnsense` : tout ce qui ENTRE ou SORT d'un tenant.
|
||||
|
||||
Les flux `pair: externe` sont donc SAUTES ici : ils relevent de la frontiere.
|
||||
Les flux `pair: externe` : la frontiere decide QUI entre (redirection, filtre WAN) ; ce
|
||||
devis laisse ARRIVER ce qu'elle a laisse passer. Sans lui, un service publie serait rejete
|
||||
par sa propre VM (`policy_in: REJECT`). Voir `_EXTERNE` plus bas.
|
||||
|
||||
NON destructif : ce script n'ecrit rien sur le cluster. Il produit un devis a relire,
|
||||
puis (a venir) un JSON pour l'API Proxmox.
|
||||
|
|
@ -30,6 +32,9 @@ from pathlib import Path
|
|||
import yaml
|
||||
|
||||
RACINE = Path(__file__).resolve().parents[1]
|
||||
|
||||
# L'Internet vu par un IPSet Proxmox : `!` = entree `nomatch` (exclusion).
|
||||
INTERNET = ("0.0.0.0/1", "128.0.0.0/1", "!10.0.0.0/8", "!172.16.0.0/12", "!192.168.0.0/16")
|
||||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
|
||||
from resoudre_flux import ( # noqa: E402
|
||||
|
|
@ -158,6 +163,14 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
# Le reseau d'administration a son PROPRE IPSet. L'ajouter a `flotte` ouvrirait
|
||||
# aussi tous les autres flux qui s'y referent — LDAP, SQL, metriques — a un
|
||||
# reseau qui n'a rien a y faire. Un ensemble, un sens.
|
||||
# L'INTERNET, SANS LES ESPACES PRIVES (2026-09-29). Source des flux `externe` : la
|
||||
# meme definition que la frontiere (`!SETOPS_INTERNES`). Jamais une source vide —
|
||||
# une regle sans source ouvre le port a tout le monde, voisins compris. Un IPSet
|
||||
# Proxmox refuse `0.0.0.0/0` : deux moities, et les trois blocs RFC 1918 exclus.
|
||||
ipsets[f"t{n['index']}-internet"] = {
|
||||
"role": "Internet (tout sauf RFC 1918) — flux `externe`",
|
||||
"membres": list(INTERNET),
|
||||
}
|
||||
if admin_avec_tunnel(nom):
|
||||
ipsets[f"t{n['index']}-admin"] = {
|
||||
"role": "reseaux d'administration (intrant nftables_admin_ssh)",
|
||||
|
|
@ -190,9 +203,28 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
# la moitie `flotte` — plus aucun hote joignable en SSH depuis
|
||||
# l'interieur, Ansible compris. Meme piege pour le SMTP interne de
|
||||
# Postfix, declare `[externe, client_smtp]`.
|
||||
# CE QUE LA FRONTIERE LAISSE ENTRER DOIT POUVOIR ARRIVER (2026-09-29). Meme
|
||||
# partage que `devis_opnsense` : le SSH `externe` est celui de la gestion,
|
||||
# reserve a l'administration (garde anti-lockout, plus bas) ; tout autre
|
||||
# flux `externe` vient de l'Internet, et du poste s'il en est client
|
||||
# (`poste`, vrai par defaut — le 25 de Postfix ne l'est pas).
|
||||
if "externe" in pairs and not (
|
||||
str(fl.get("protocole", "tcp")).lower() != "icmp" and "22" in _ports(fl)):
|
||||
sources = [(f"t{n['index']}-internet", "internet")]
|
||||
if fl.get("poste", True) and admin_avec_tunnel(nom):
|
||||
sources.append((f"t{n['index']}-admin", "administration (poste)"))
|
||||
for cle_src, via in sources:
|
||||
utilises.add(cle_src)
|
||||
for cible in _cibles(fl):
|
||||
regles.append({
|
||||
"action": "ACCEPT", "sens": "IN",
|
||||
"source": f"+{cle_src}", "via": via,
|
||||
**cible,
|
||||
"raison": fl.get("raison", ""),
|
||||
})
|
||||
pairs = [p for p in pairs if p != "externe"]
|
||||
if not pairs:
|
||||
continue # purement externe : la frontiere s'en charge
|
||||
continue
|
||||
if pairs == ["localhost"]:
|
||||
continue # boucle locale : aucune regle inter-noeud
|
||||
if not _resoudre_sources(data, fl.get("pair"), tous, ips):
|
||||
|
|
@ -324,7 +356,7 @@ def rendre(devis: dict) -> str:
|
|||
"# barrieres disent la meme chose, et ne peuvent pas diverger.",
|
||||
"#",
|
||||
"# Ce qui ENTRE ou SORT d'un tenant n'est PAS ici : c'est la frontiere",
|
||||
"# (`make devis-opnsense`). Les flux `pair: externe` sont sautes.",
|
||||
"# (`make devis-opnsense`). Les flux `pair: externe` arrivent depuis `+t<index>-internet`.",
|
||||
"# ============================================================",
|
||||
"",
|
||||
"## 0. Prerequis — TROIS verrous, et le REJECT se pose PAR VM",
|
||||
|
|
|
|||
93
scripts/tests/test_proxmox_fw_externe.py
Normal file
93
scripts/tests/test_proxmox_fw_externe.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Le pare-feu Proxmox laisse ARRIVER ce que la frontiere laisse entrer — et l'edge reste
|
||||
ferme a l'Internet.
|
||||
|
||||
POURQUOI (2026-09-29). Le devis est-ouest sautait tout flux `externe`, « la frontiere s'en
|
||||
charge ». Mais depuis que les VM rejettent par defaut, un service publie (Postfix 25, Dovecot
|
||||
993) aurait ete refuse par sa propre VM, et le poste ne joignait pas Dovecot. A l'inverse,
|
||||
l'edge declarait 80/443 `externe` : la frontiere posait « WAN, n'importe qui -> edge ». Or
|
||||
l'edge n'expose les interfaces qu'a la zone d'administration ; le public passera par le web
|
||||
frontal.
|
||||
|
||||
Invariants gardes, sur le devis rendu depuis les plans montes :
|
||||
1. `serveur_nginx` ne declare aucun flux `externe`, et aucune regle d'edge ne vient de
|
||||
`+t<i>-internet` ;
|
||||
2. tout flux entrant `externe` (hors SSH, reserve a l'administration) d'un role porte
|
||||
donne une regle depuis `+t<i>-internet` ;
|
||||
3. l'IPSet « internet » exclut les trois blocs RFC 1918, et aucune regle n'a de source vide ;
|
||||
4. `appliquer_proxmox_fw` pose une exclusion en `nomatch`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
RACINE = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
|
||||
|
||||
def flux(role: str) -> list[dict]:
|
||||
f = RACINE / "roles" / role / "meta" / "flux.yml"
|
||||
return (yaml.safe_load(f.read_text(encoding="utf-8")) or {}).get("flux") or [] if f.exists() else []
|
||||
|
||||
|
||||
def pairs(fl: dict) -> list[str]:
|
||||
p = fl.get("pair")
|
||||
return [str(x) for x in (p if isinstance(p, list) else [p])]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
echecs: list[str] = []
|
||||
if any("externe" in pairs(fl) for fl in flux("serveur_nginx")):
|
||||
echecs.append("serveur_nginx declare un flux `externe` : l'edge serait ouvert a l'Internet")
|
||||
|
||||
r = subprocess.run([sys.executable, str(RACINE / "scripts/devis_proxmox_fw.py"), "--json"],
|
||||
capture_output=True, text=True, cwd=RACINE)
|
||||
try:
|
||||
devis = json.loads(r.stdout)
|
||||
except ValueError:
|
||||
print(f"SAUTE devis indisponible ici (pas de plans montes ?) : {r.stderr.strip()[:120]}")
|
||||
devis = []
|
||||
blocs = devis if isinstance(devis, list) else devis.get("blocs", [])
|
||||
for b in blocs:
|
||||
idx = next((k.split("-")[0] for k in b.get("ipsets", {}) if k.endswith("-internet")), None)
|
||||
roles = {g["role"]: g.get("regles", []) for g in b.get("groupes", [])}
|
||||
for role, regles in roles.items():
|
||||
for rg in regles:
|
||||
if not str(rg.get("source", "")).strip():
|
||||
echecs.append(f"{b['tenant']} {role} : regle sans source ({rg})")
|
||||
if role == "serveur_nginx" and "-internet" in str(rg.get("source")):
|
||||
echecs.append(f"{b['tenant']} : l'edge recoit l'Internet ({rg})")
|
||||
for fl in flux(role):
|
||||
if fl.get("sens") != "ingress" or "externe" not in pairs(fl):
|
||||
continue
|
||||
if str(fl.get("protocole", "tcp")).lower() != "icmp" and "22" in str(fl.get("port")):
|
||||
continue
|
||||
if not any("-internet" in str(rg.get("source")) for rg in regles):
|
||||
echecs.append(f"{b['tenant']} {role} : flux `externe` {fl.get('port')} sans regle "
|
||||
f"depuis l'Internet — la VM rejetterait ce que la frontiere laisse entrer")
|
||||
if idx:
|
||||
membres = b["ipsets"][f"{idx}-internet"]["membres"]
|
||||
for prive in ("!10.0.0.0/8", "!172.16.0.0/12", "!192.168.0.0/16"):
|
||||
if prive not in membres:
|
||||
echecs.append(f"{b['tenant']} : l'IPSet internet n'exclut pas {prive[1:]}")
|
||||
|
||||
from appliquer_proxmox_fw import _membre
|
||||
if _membre("!10.0.0.0/8") != {"cidr": "10.0.0.0/8", "nomatch": 1} or _membre("1.2.3.0/24") != {"cidr": "1.2.3.0/24"}:
|
||||
echecs.append("appliquer_proxmox_fw._membre ne rend pas `!cidr` en exclusion `nomatch`")
|
||||
|
||||
for e in echecs:
|
||||
print(f"ECHEC {e}")
|
||||
if echecs:
|
||||
return 1
|
||||
print(f"OK — edge ferme a l'Internet ; flux `externe` recus depuis +t<i>-internet "
|
||||
f"({len(blocs)} tenant(s)) ; exclusions RFC 1918 en `nomatch`.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -9,9 +9,8 @@
|
|||
graph LR
|
||||
R["<b>nginx</b>"]
|
||||
E0["admin"] -->|"443 · tls-requis"| R
|
||||
E1["externe"] -->|"443 · tls-requis"| R
|
||||
E2["externe"] -->|"80 · clair"| R
|
||||
E3["flotte"] -->|"443 · tls-requis"| R
|
||||
E1["admin"] -->|"80 · clair"| R
|
||||
E2["flotte"] -->|"443 · tls-requis"| R
|
||||
R -.->|"sonde « edge »"| ICINGA[["Icinga"]]
|
||||
```
|
||||
|
||||
|
|
@ -19,8 +18,7 @@ graph LR
|
|||
|
||||
| port | depuis | chiffrement | pourquoi |
|
||||
|---|---|---|---|
|
||||
| `80` | `externe` | clair | HTTP entrant — redirection permanente vers HTTPS. |
|
||||
| `443` | `externe` | tls-requis | HTTPS entrant — services exposés (terminaison TLS à l'edge). |
|
||||
| `80` | `admin` | clair | HTTP depuis le reseau d'administration — redirection permanente vers HTTPS. |
|
||||
| `443` | `flotte` | tls-requis | HTTPS depuis le tenant : les FQDN publiés vivent à l'edge (découverte OIDC, appels inter-services par nom). |
|
||||
| `443` | `admin` | tls-requis | HTTPS depuis le reseau d'administration : l'exploitant administre les services par leur interface web, servie par l'edge. |
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ Chaque fiche est construite depuis ce que le rôle **déclare lui-même** — `m
|
|||
| [`serveur_keycloak`](Rôle-serveur_keycloak) | 1 | 1 | — |
|
||||
| [`serveur_loki`](Rôle-serveur_loki) | 4 | 2 | — |
|
||||
| [`serveur_nextcloud`](Rôle-serveur_nextcloud) | 1 | 1 | — |
|
||||
| [`serveur_nginx`](Rôle-serveur_nginx) | 4 | 1 | — |
|
||||
| [`serveur_nginx`](Rôle-serveur_nginx) | 3 | 1 | — |
|
||||
| [`serveur_oauth2_proxy`](Rôle-serveur_oauth2_proxy) | 1 | 1 | — |
|
||||
| [`serveur_openldap`](Rôle-serveur_openldap) | 2 | 1 | — |
|
||||
| [`serveur_ops`](Rôle-serveur_ops) | 1 | 2 | — |
|
||||
|
|
|
|||
Loading…
Reference in a new issue