frontiere : le devis sait desormais refuser SANS consigner
L'outil ne savait qu'AUTORISER — `"action": "pass"` etait en dur dans l'emetteur. Une regle de silence ne pouvait donc pas naitre du depot, et j'en avais pose deux a la main sur le boitier : exactement ce que ce projet refuse. CE QUI L'A MOTIVE. Le journal de la frontiere ecrivait 982 000 entrees par jour, dont 82 % un balayage Internet contre le port VNC et le reste du bavardage de decouverte du reseau local. Sa fenetre utile etait tombee a QUARANTE-QUATRE SECONDES. J'y ai cherche la trace d'un flux du site vers les hyperviseurs, je n'ai rien trouve, et j'en ai conclu a tort qu'aucune regle ne bloquait. Un journal noye ment aussi surement qu'un journal mort. Mesure apres declaration : ~20 700/jour. Une regle de silence ne change AUCUN comportement : ce qu'elle vise etait deja refuse par le defaut. Elle ne supprime qu'une trace que personne ne lira. TROIS PIECES. `cle_regle` accepte une action sans changer d'un octet la cle des regles `pass` deja posees. L'ajout naif d'un champ les aurait toutes detruites pour les recreer a l'identique, sur la frontiere, en production. Le plan l'a confirme : 7 a creer, 0 a retirer, 121 inchangees. `_corps_regle` lit l'action, la consignation et la SEQUENCE depuis le devis. La sequence est ce qui rend un `block` sur : OPNsense evalue en `quick`, donc un blocage large emis avant les `pass` fermerait courrier, web et acces distant. `devis_opnsense` lit `opnsense_silences` et en fabrique regles et alias, motif compris — une regle `block` muette sans raison ecrite est indiscernable d'un oubli. P50 garde ces deux dangers. Controles negatifs verifies : un silence en sequence 1 echoue, un silence sans motif echoue. Carte : 28 pieces d'audit (P48 l'avait vu juste). make prouver : CONFORME, 50 OK, 0 echec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
14fa129731
commit
3b65384b0f
5 changed files with 143 additions and 9 deletions
|
|
@ -7,7 +7,7 @@
|
||||||
> [`docs/audit/affirmations.md`](affirmations.md).
|
> [`docs/audit/affirmations.md`](affirmations.md).
|
||||||
|
|
||||||
- **Instance** : `instance` — inventaire `instance/inventories/production/hosts.yml`
|
- **Instance** : `instance` — inventaire `instance/inventories/production/hosts.yml`
|
||||||
- **Verdict** : ✅ CONFORME (49 OK · 0 echec · 0 saute)
|
- **Verdict** : ✅ CONFORME (50 OK · 0 echec · 0 saute)
|
||||||
|
|
||||||
## Preuves
|
## Preuves
|
||||||
|
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 50 scripts expliques et atteignables, 104 cibles make documentees, 61 roles avec README. |
|
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 50 scripts expliques et atteignables, 104 cibles make documentees, 61 roles avec README. |
|
||||||
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 14 exigence(s) de role, toutes satisfaites (39 cle(s) declaree(s) par l'instance). |
|
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 14 exigence(s) de role, toutes satisfaites (39 cle(s) declaree(s) par l'instance). |
|
||||||
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (16 groupes). |
|
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (16 groupes). |
|
||||||
| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (24 genere(s) exempte(s)). |
|
| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (25 genere(s) exempte(s)). |
|
||||||
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 0 application(s) exigeant une base l'ont toutes (0 entree(s) au registre). |
|
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 0 application(s) exigeant une base l'ont toutes (0 entree(s) au registre). |
|
||||||
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 2 hote(s) detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
|
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 2 hote(s) detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
|
||||||
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
|
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
|
||||||
|
|
@ -62,6 +62,7 @@
|
||||||
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
|
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
|
||||||
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 84 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
|
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 84 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
|
||||||
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (110 lignes). |
|
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (110 lignes). |
|
||||||
|
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 121 regles `pass`), tous non consignes et tous motives. |
|
||||||
|
|
||||||
## Couverture des affirmations ✅ du registre
|
## Couverture des affirmations ✅ du registre
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ README de rôles). Cette page comble ces deux trous.
|
||||||
| rôles | 61 | `roles/*/` |
|
| rôles | 61 | `roles/*/` |
|
||||||
| README de rôles | 61 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
|
| README de rôles | 61 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
|
||||||
| documents | 38 | `docs/*.md` |
|
| documents | 38 | `docs/*.md` |
|
||||||
| pièces d'audit | 27 | `docs/audit/*` |
|
| pièces d'audit | 28 | `docs/audit/*` |
|
||||||
| unités de wiki | 27 | `wiki/*.md` |
|
| unités de wiki | 27 | `wiki/*.md` |
|
||||||
| décisions en vigueur | 78 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
|
| décisions en vigueur | 78 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
|
||||||
| décisions renversées | 3 | lignes `\| **D-nn** —` du même document |
|
| décisions renversées | 3 | lignes `\| **D-nn** —` du même document |
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,14 @@ def cle_regle(r: dict) -> str:
|
||||||
une regle a retirer, ce qui est exactement la verite.
|
une regle a retirer, ce qui est exactement la verite.
|
||||||
"""
|
"""
|
||||||
ports = ",".join(map(str, r["ports"] or ["*"]))
|
ports = ",".join(map(str, r["ports"] or ["*"]))
|
||||||
return (f"setops:{r['tenant']}:{r['interface']}:{r['sens']}:{r['protocole']}:"
|
# L'ACTION N'ENTRE DANS LA CLE QUE SI ELLE N'EST PAS `pass` (2026-08-27). Les regles
|
||||||
|
# de SILENCE — des `block` non consignes — ont besoin d'une identite distincte, mais
|
||||||
|
# l'ajout naif d'un champ aurait change la cle des 117 regles deja posees : le
|
||||||
|
# rapprochement les aurait toutes detruites pour les recreer a l'identique, sur la
|
||||||
|
# frontiere, en production. Une marque vide pour le cas majoritaire garde ces cles
|
||||||
|
# octet pour octet, et n'en donne une nouvelle qu'a ce qui est nouveau.
|
||||||
|
marque = "" if str(r.get("action") or "pass") == "pass" else f"{r['action']}:"
|
||||||
|
return (f"setops:{marque}{r['tenant']}:{r['interface']}:{r['sens']}:{r['protocole']}:"
|
||||||
f"{r['source']}->{r['destination']}:{ports}")
|
f"{r['source']}->{r['destination']}:{ports}")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -174,14 +181,29 @@ def _corps_regle(r: dict, k: str) -> dict:
|
||||||
# le champ `destination_not` d'OPNsense.
|
# le champ `destination_not` d'OPNsense.
|
||||||
dst = str(r["destination"])
|
dst = str(r["destination"])
|
||||||
nie = dst.startswith("!")
|
nie = dst.startswith("!")
|
||||||
|
# L'ACTION ET LA CONSIGNATION VIENNENT DU DEVIS (2026-08-27). Elles etaient en dur :
|
||||||
|
# l'outil ne savait qu'AUTORISER, et une regle qui fait taire du bruit deja refuse
|
||||||
|
# n'avait donc aucun moyen de naitre du depot. Les defauts reproduisent exactement
|
||||||
|
# l'ancien comportement — `pass`, non consigne, sequence 1 — pour que les regles
|
||||||
|
# existantes soient emises a l'identique.
|
||||||
|
#
|
||||||
|
# LA SEQUENCE EST CE QUI REND UN `block` SUR : OPNsense evalue en `quick`, donc la
|
||||||
|
# PREMIERE regle qui correspond gagne. Un blocage large pose avant les `pass` du
|
||||||
|
# devis fermerait le courrier, le web et l'acces distant. Les silences se declarent
|
||||||
|
# a 900, apres tout le reste.
|
||||||
corps = {
|
corps = {
|
||||||
"enabled": "1",
|
"enabled": "1",
|
||||||
"sequence": "1",
|
"sequence": str(r.get("sequence") or "1"),
|
||||||
"action": "pass",
|
"action": str(r.get("action") or "pass"),
|
||||||
|
"log": "1" if r.get("journaliser") else "0",
|
||||||
"interface": r["interface"],
|
"interface": r["interface"],
|
||||||
"direction": "in", # le devis raisonne en ARRIVEE sur l'interface (D-61)
|
"direction": "in", # le devis raisonne en ARRIVEE sur l'interface (D-61)
|
||||||
"ipprotocol": "inet",
|
"ipprotocol": "inet",
|
||||||
"protocol": "ICMP" if r["protocole"] == "icmp" else r["protocole"].upper(),
|
# `any` reste EN MINUSCULES : OPNsense attend ses protocoles en majuscules mais
|
||||||
|
# nomme « tout protocole » `any`. Un `ANY` majuscule est rejete a la validation.
|
||||||
|
"protocol": ("any" if r["protocole"] == "any"
|
||||||
|
else "ICMP" if r["protocole"] == "icmp"
|
||||||
|
else r["protocole"].upper()),
|
||||||
"source_net": r["source"],
|
"source_net": r["source"],
|
||||||
"destination_net": dst[1:] if nie else dst,
|
"destination_net": dst[1:] if nie else dst,
|
||||||
"destination_not": "1" if nie else "0",
|
"destination_not": "1" if nie else "0",
|
||||||
|
|
|
||||||
|
|
@ -832,6 +832,50 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
||||||
"tenant": "SITE",
|
"tenant": "SITE",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# LES SILENCES — refuser sans CONSIGNER (2026-08-27).
|
||||||
|
#
|
||||||
|
# Un pare-feu refuse deja tout ce qui n'est pas declare ; le probleme n'est pas ce
|
||||||
|
# qu'il bloque, c'est ce qu'il ECRIT. Mesure du jour : 982 000 entrees par jour, dont
|
||||||
|
# 82 % un balayage Internet contre le port VNC et le reste du bavardage de decouverte
|
||||||
|
# (mDNS, SSDP, NetBIOS) du reseau local. La consequence n'est pas cosmetique — la
|
||||||
|
# fenetre utile du journal tombait a QUARANTE-QUATRE SECONDES, moins que le temps de
|
||||||
|
# reproduire un defaut. Un journal noye ne dit rien, exactement comme un journal mort.
|
||||||
|
#
|
||||||
|
# Une regle de silence ne change AUCUN comportement : ce qu'elle bloque etait deja
|
||||||
|
# refuse par le defaut. Elle ne fait que taire une trace que personne ne lira. C'est
|
||||||
|
# pourquoi elle se declare ici, avec son MOTIF : sans lui, une regle `block` muette
|
||||||
|
# dans un pare-feu est indiscernable d'un oubli.
|
||||||
|
for s in (intrants.get("opnsense_silences") or []):
|
||||||
|
_dest = str(s.get("destination") or "any")
|
||||||
|
# Une liste d'adresses devient un ALIAS, pas une regle par adresse : l'alias
|
||||||
|
# nomme l'intention, et le boitier montre alors POURQUOI c'est tu.
|
||||||
|
if s.get("destinations"):
|
||||||
|
_nom_alias = f"SETOPS_SILENCE_{str(s['nom']).upper()}"
|
||||||
|
alias[_nom_alias] = {
|
||||||
|
"type": "network",
|
||||||
|
"contenu": [str(x) for x in s["destinations"]],
|
||||||
|
"description": f"Silence — {s.get('motif') or s['nom']}",
|
||||||
|
}
|
||||||
|
_dest = _nom_alias
|
||||||
|
regles.append({
|
||||||
|
"sens": "in",
|
||||||
|
"interface": str(s.get("interface") or if_wan),
|
||||||
|
"protocole": str(s.get("protocole") or "any"),
|
||||||
|
"source": str(s.get("source") or "any"),
|
||||||
|
"destination": _dest,
|
||||||
|
"ports": [str(p) for p in (s.get("ports") or [])],
|
||||||
|
"role": f"silence:{s.get('nom') or 'bruit'}",
|
||||||
|
"tenant": "SILENCE",
|
||||||
|
"action": "block",
|
||||||
|
"journaliser": False,
|
||||||
|
# 900 : APRES tous les `pass` (sequence 1) et Wireguard (101). OPNsense evalue
|
||||||
|
# en `quick` — un blocage large place avant eux fermerait le courrier, le web
|
||||||
|
# et l'acces distant d'un seul coup.
|
||||||
|
"sequence": str(s.get("sequence") or 900),
|
||||||
|
"chiffrement": "sans objet",
|
||||||
|
"raison": str(s.get("motif") or "bruit de journal, deja refuse par defaut"),
|
||||||
|
})
|
||||||
|
|
||||||
# Adresse du noeud de sortie primaire sur le lien de frontiere, calculee une fois :
|
# Adresse du noeud de sortie primaire sur le lien de frontiere, calculee une fois :
|
||||||
# elle sert au prochain saut des routes ET au commentaire qui l'explique.
|
# elle sert au prochain saut des routes ET au commentaire qui l'explique.
|
||||||
_ip_sortie = adresse_noeud_de_sortie(underlay_mod.charger(), transit)
|
_ip_sortie = adresse_noeud_de_sortie(underlay_mod.charger(), transit)
|
||||||
|
|
@ -1011,10 +1055,15 @@ def rendre(devis: dict) -> str:
|
||||||
# Dans OPNsense une regle est TOUJOURS `in` sur l'interface d'arrivee : c'est
|
# Dans OPNsense une regle est TOUJOURS `in` sur l'interface d'arrivee : c'est
|
||||||
# la que le paquet penetre le pare-feu. Le sens du flux (entrant/sortant vis-a-vis
|
# la que le paquet penetre le pare-feu. Le sens du flux (entrant/sortant vis-a-vis
|
||||||
# de l'ecosysteme) se lit alors dans l'interface, pas dans un mot-cle.
|
# de l'ecosysteme) se lit alors dans l'interface, pas dans un mot-cle.
|
||||||
|
# L'ACTION S'AFFICHE (2026-08-27) : le rendu disait « pass » pour tout, ce qui
|
||||||
|
# etait vrai tant que l'outil ne savait qu'autoriser. Un devis qui affiche
|
||||||
|
# « pass » devant une regle de blocage serait pire qu'un devis muet.
|
||||||
|
_act = str(rg.get("action") or "pass")
|
||||||
|
_mut = "" if rg.get("journaliser", False) or _act == "pass" else " [non consigne]"
|
||||||
out.append(
|
out.append(
|
||||||
f"pass in on {rg['interface']:8} proto {rg['protocole']:3} "
|
f"{_act:4} in on {rg['interface']:8} proto {rg['protocole']:3} "
|
||||||
f"from {rg['source']:24} to {rg['destination']:24} port {ports:7} "
|
f"from {rg['source']:24} to {rg['destination']:24} port {ports:7} "
|
||||||
f"# {rg['role']} ({rg['chiffrement']})"
|
f"# {rg['role']} ({rg['chiffrement']}){_mut}"
|
||||||
)
|
)
|
||||||
out.append(f"# {rg['raison']}")
|
out.append(f"# {rg['raison']}")
|
||||||
sortants = sum(1 for r in devis["regles"] if r["sens"] == "out")
|
sortants = sum(1 for r in devis["regles"] if r["sens"] == "out")
|
||||||
|
|
|
||||||
|
|
@ -1396,6 +1396,66 @@ def preuve_carte_dit_vrai() -> tuple[bool, str]:
|
||||||
f"{len(_carte_chiffres_mesures())} chiffres correspondent a la mesure.")
|
f"{len(_carte_chiffres_mesures())} chiffres correspondent a la mesure.")
|
||||||
|
|
||||||
|
|
||||||
|
def preuve_silences_declares() -> tuple[bool, str]:
|
||||||
|
"""Un `block` muet doit etre DECLARE, place APRES les `pass`, et porter son motif.
|
||||||
|
|
||||||
|
POURQUOI (mesure du 2026-08-27). Le journal de la frontiere ecrivait 982 000 entrees
|
||||||
|
par jour, dont 82 % un balayage Internet contre le port VNC. Sa fenetre utile etait
|
||||||
|
tombee a QUARANTE-QUATRE SECONDES : j'y ai cherche la trace d'un flux du site vers les
|
||||||
|
hyperviseurs, je n'ai rien trouve, et j'en ai conclu a tort qu'aucune regle ne
|
||||||
|
bloquait. Un journal noye ment aussi surement qu'un journal mort.
|
||||||
|
|
||||||
|
La reponse — taire ce qui est deja refuse — introduit dans l'outil sa PREMIERE regle
|
||||||
|
de blocage. Elle amene deux dangers que cette preuve garde :
|
||||||
|
|
||||||
|
1. L'ORDRE. OPNsense evalue en `quick` : la premiere regle qui correspond gagne. Un
|
||||||
|
blocage large emis avant les `pass` du devis fermerait d'un coup le courrier, le
|
||||||
|
web et l'acces distant. Les silences doivent donc porter une sequence STRICTEMENT
|
||||||
|
superieure a celle de toute regle `pass`.
|
||||||
|
|
||||||
|
2. LE MOTIF. Une regle `block` sans raison ecrite est indiscernable d'un oubli pour
|
||||||
|
qui relira cette frontiere dans six mois. Le silence se justifie ou n'existe pas.
|
||||||
|
|
||||||
|
On rejoue le devis en memoire — rien n'est ecrit, rien n'est joint sur le reseau.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sys.path.insert(0, str(RACINE / "scripts"))
|
||||||
|
import devis_opnsense as DO
|
||||||
|
devis = DO.construire(DO.decouvrir_du_site())
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return False, (f"Le devis de la frontiere n'a pas pu etre rejoue : {e}. "
|
||||||
|
f"Sans lui, on ne sait pas ce que la frontiere recevrait.")
|
||||||
|
|
||||||
|
regles = devis.get("regles") or []
|
||||||
|
silences = [r for r in regles if str(r.get("action") or "pass") != "pass"]
|
||||||
|
if not silences:
|
||||||
|
return True, ("Aucune regle de blocage au devis — rien a garder. La preuve "
|
||||||
|
"redeviendra parlante des qu'un silence sera declare.")
|
||||||
|
|
||||||
|
seq_pass = [int(r.get("sequence") or 1) for r in regles
|
||||||
|
if str(r.get("action") or "pass") == "pass"]
|
||||||
|
plancher = max(seq_pass) if seq_pass else 1
|
||||||
|
fautes = []
|
||||||
|
for r in silences:
|
||||||
|
seq = int(r.get("sequence") or 1)
|
||||||
|
if seq <= plancher:
|
||||||
|
fautes.append(
|
||||||
|
f"{r['interface']}/{r['role']} : sequence {seq} <= {plancher}, la plus "
|
||||||
|
f"haute des regles `pass`. En `quick`, ce blocage gagnerait AVANT elles.")
|
||||||
|
if r.get("journaliser"):
|
||||||
|
fautes.append(f"{r['interface']}/{r['role']} : consigne — ce n'est pas un silence.")
|
||||||
|
raison = str(r.get("raison") or "").strip()
|
||||||
|
if len(raison) < 30:
|
||||||
|
fautes.append(
|
||||||
|
f"{r['interface']}/{r['role']} : motif absent ou trop court "
|
||||||
|
f"({len(raison)} car.). Un blocage muet sans raison ecrite est un oubli.")
|
||||||
|
if fautes:
|
||||||
|
return False, " | ".join(fautes)
|
||||||
|
return True, (f"{len(silences)} silence(s) declare(s), tous en sequence > {plancher} "
|
||||||
|
f"(la plus haute des {len(seq_pass)} regles `pass`), tous non consignes "
|
||||||
|
f"et tous motives.")
|
||||||
|
|
||||||
|
|
||||||
def preuve_registre_flux_a_jour() -> tuple[bool, str]:
|
def preuve_registre_flux_a_jour() -> tuple[bool, str]:
|
||||||
"""La matrice d'audit des flux dit ce que le moteur declare AUJOURD'HUI.
|
"""La matrice d'audit des flux dit ce que le moteur declare AUJOURD'HUI.
|
||||||
|
|
||||||
|
|
@ -1792,6 +1852,8 @@ PREUVES: list[dict] = [
|
||||||
"refs": [], "func": preuve_carte_dit_vrai},
|
"refs": [], "func": preuve_carte_dit_vrai},
|
||||||
{"id": "P49", "titre": "Registre des flux : la matrice d'audit est a jour",
|
{"id": "P49", "titre": "Registre des flux : la matrice d'audit est a jour",
|
||||||
"refs": [], "func": preuve_registre_flux_a_jour},
|
"refs": [], "func": preuve_registre_flux_a_jour},
|
||||||
|
{"id": "P50", "titre": "Silences : un refus muet est declare, place en dernier, et motive",
|
||||||
|
"refs": [], "func": preuve_silences_declares},
|
||||||
{"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [],
|
{"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [],
|
||||||
"func": preuve_devis_frontiere_du_site},
|
"func": preuve_devis_frontiere_du_site},
|
||||||
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],
|
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue