Set-OPS-Public/playbooks/maintenance/devis-frontiere.yml
Daniel Allaire a546b03c3a devis : sixieme instrument — « ce qui n'est pas declare est-il refuse ? »
devis_expositions pose la question positive (une exposition declaree
repond-elle). Celle-ci manquait, et ce n'est pas la meme : un pare-feu peut
servir tout ce qu'on lui demande ET laisser passer tout le reste.

Rien n'y est saisi. Les cibles sont les ports REELLEMENT en ecoute (sonder un
port ferme ne prouverait rien du pare-feu) ; la politique attendue est lue
dans devis_opnsense.py, la source qui configure la frontiere.

scripts/sonde_tcp.py refuse de conclure : un CONTROLE (adresse ou personne
n'ecoute) avant tout verdict — s'il repond, le releve est declare NUL. Et
etablir n'est pas livrer : banniere, sinon requete HTTP, sinon poignee TLS ;
sans reponse le verdict est AMBIGU, jamais « ouvert ».

Deux pieges rencontres en le construisant, corriges et commentes sur place :
la sonde « tenant » s'executait sur le poste (dans un play connection: local,
delegate_to herite de cette connexion) ; et 2 s de lecture faisaient ressortir
un Postfix sain en AMBIGU (postscreen retarde sa banniere expres) — porte a
8 s, compense par du parallelisme.

Premier verdict, frontiere en l'etat : 38 ecarts, dont collab-01:9980 qui
livre vraiment un HTTP 200 depuis le poste. Releve sortant declare NUL.

Verifie : ansible-lint Passed, prouver.py 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:03:28 -04:00

118 lines
4.9 KiB
YAML

---
# Devis d'etancheite de la frontiere — RELEVE seul. Ne modifie rien (D-23).
#
# Question posee : la frontiere laisse-t-elle passer ce que la politique n'autorise PAS ?
# `devis-expositions.yml` pose la question inverse — chaque exposition declaree repond-elle.
# Les deux sont necessaires : un pare-feu peut servir tout ce qu'on lui demande *et*
# laisser passer tout le reste.
#
# LES CIBLES SONT LES PORTS REELLEMENT EN ECOUTE, releves ici. Sonder un port ferme ne
# prouverait rien du pare-feu : le refus viendrait de la machine, pas de la bordure.
#
# LE CONTROLE D'ABORD. La sonde interroge une adresse ou personne n'ecoute avant tout
# verdict ; si elle repond, le releve est declare NUL. C'est la lecon du 2026-08-09 :
# `connect()` a menti deux jours durant a travers un `synproxy` herite.
#
# make frontiere-mesurer
- name: Relevé des ports réellement en écoute
hosts: hotes_actifs
become: true
gather_facts: false
tasks:
- name: Recenser les ports TCP en écoute
ansible.builtin.shell:
executable: /bin/bash
# `|| true` en fin de chaîne : avec `pipefail`, un `grep -v` qui ne garde rien
# tuerait la tâche. Un hôte sans écoute publique est un relevé vide, pas une panne.
cmd: >-
set -o pipefail;
ss -ltnH | awk '{print $4}'
| grep -vE '^127\.|^\[::1\]' | sed 's/.*://' | sort -un | tr '\n' ' ' || true
register: devis_frontiere_ss
changed_when: false
- name: Retenir l'écoute de cet hôte
ansible.builtin.set_fact:
devis_frontiere_ecoute:
ip: "{{ ansible_host }}"
ports: "{{ devis_frontiere_ss.stdout.split() | map('int') | list }}"
- name: Sondage depuis les deux points de vue
hosts: localhost
connection: local
become: false
gather_facts: false
tasks:
- name: Rassembler l'écoute de toute la flotte
ansible.builtin.set_fact:
devis_frontiere_ports: >-
{{ dict(groups['hotes_actifs']
| map('extract', hostvars, 'devis_frontiere_ecoute')
| list | zip(groups['hotes_actifs']) | map('reverse') | list) }}
# Forme `argv` et non `cmd` : le chemin du dépôt peut contenir une espace, et `cmd`
# découpe dessus. Mesuré ici même — « /home/.../Espace Chezlepro/... » cassait.
- name: Dériver les cibles à sonder depuis la politique
ansible.builtin.command:
argv:
- python3
- "{{ playbook_dir }}/../../scripts/devis_frontiere.py"
- --cibles
stdin: "{{ devis_frontiere_ports | to_json }}"
register: devis_frontiere_cibles
changed_when: false
- name: Sonder depuis le VLAN d'administration (le poste de l'exploitant)
ansible.builtin.command:
argv:
- python3
- "{{ playbook_dir }}/../../scripts/sonde_tcp.py"
stdin: >-
{{ {'controle': (devis_frontiere_cibles.stdout | from_json).controle,
'cibles': (devis_frontiere_cibles.stdout | from_json).admin} | to_json }}
register: devis_frontiere_admin
changed_when: false
# Play distinct, et ce n'est pas cosmétique. Dans un play `connection: local`, un
# `delegate_to` hérite de cette connexion : la sonde « tenant » s'exécutait en réalité sur
# le poste. Elle rendait donc la frontière JOIGNABLE — depuis le VLAN d'administration,
# elle l'est. Mesuré et corrigé le 2026-08-09 ; c'est la même famille de piège que le
# `connect()` : vérifier d'où l'instrument mesure avant de croire ce qu'il dit.
- name: Sondage depuis un hôte du tenant (le sens sortant)
# Un seul hôte suffit : le devis mesure la bordure, pas les hôtes. Les règles sortantes
# portent sur l'alias du tenant, donc sur tous ses hôtes indistinctement.
# Motif `groupe[0]` et non un template : un `hosts:` templaté échoue quand l'inventaire
# est vide — c'est ce qui cassait `ansible-lint`, qui s'exécute sans inventaire.
hosts: hotes_actifs[0]
become: false
gather_facts: false
tasks:
- name: Sonder les destinations internes interdites
ansible.builtin.script:
cmd: >-
"{{ playbook_dir }}/../../scripts/sonde_tcp.py"
'{{ {"controle": (hostvars["localhost"].devis_frontiere_cibles.stdout
| from_json).controle,
"cibles": (hostvars["localhost"].devis_frontiere_cibles.stdout
| from_json).tenant} | to_json }}'
args:
executable: python3
register: devis_frontiere_tenant
changed_when: false
- name: Déposer le relevé sur le contrôleur
ansible.builtin.copy:
dest: "{{ playbook_dir }}/../../instance/devis-frontiere.json"
mode: "0600"
content: >-
{{ {
'admin': hostvars['localhost'].devis_frontiere_admin.stdout | from_json,
'tenant': devis_frontiere_tenant.stdout | from_json,
'sonde_depuis': inventory_hostname,
} | to_nice_json }}
delegate_to: localhost
become: false