Set-OPS-Public/roles/client_journal/templates/config.alloy.j2
Daniel Allaire f3bb9caee3 chiffrement : metriques et journaux du site en TLS, et la frontiere certifiee par sa propre autorite
Metriques et journaux (CHANGELOG 142). Le site pose les quatre interrupteurs TLS
que chaque locataire pose dans ses group_vars : node_exporter et Loki servaient
en HTTP sur les neuf VM, le certificat etait la, inutilise. test_parite_tls_site.py
compare desormais le site aux locataires. Les hyperviseurs poussent leurs
journaux en HTTPS verifie, avec la racine du site comme simple fichier.
L'exportateur PostgreSQL sert en HTTPS partout, sur l'interrupteur de PostgreSQL
lui-meme, que Prometheus lit pour choisir son schema.

La frontiere (CHANGELOG 143). Une autorite subordonnee signee par la racine du
site, bornee (pathlen 0, noms sous le domaine et adresses du site, contrainte
critique), cle nee sur la boite (scripts/frontiere_pki.py). Syslog vers Loki en
TLS mutuel (seule la frontiere peut ecrire), exportateur en HTTPS, interface web
sur le certificat de services ; les clients d'API verifient la frontiere contre
la racine du site. La lecture du magasin passe par config.xml : l'API de
recherche renvoie les cles privees.

Deploye et mesure au site, chez les deux locataires et sur la frontiere ; 95 preuves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:25:28 -04:00

210 lines
9.3 KiB
Django/Jinja

// Gere par Set-OPS (role client_journal). Ne pas editer a la main.
loki.write "loki" {
endpoint {
url = "{{ client_journal_loki_url }}"
{% if client_journal_loki_tls | default(false) %}
tls_config {
ca_file = "{{ client_journal_loki_ca }}"
}
{% endif %}
}
}
{% set sondeurs = client_journal_sondeurs | map('regex_escape') | join('|') | replace('\\', '\\\\') %}
{% if sondeurs %}
// --- LE BRUIT DE LA SONDE « connectivite » RESTE SUR LA MACHINE (2026-10-03) ---
//
// Chaque minute, chaque machine ouvre une connexion TCP vers les ports que le registre des
// flux lui promet, puis la referme sans rien dire (`nftables_baseline`,
// `sonde-connectivite.sh`). Ceux qui ecoutent le notent : `sshd` (« Connection closed by
// <pair> port <n> ») et les serveurs Go — Forgejo, step-ca — (« TLS handshake error from
// <pair>:<n>: EOF »). Mesure au site : le journal de chaque VM est passe de 2 000 a
// 14 000 lignes par jour le 2026-10-01, et ces deux phrases faisaient 98 % des « erreurs »
// de la forge et de l'autorite. Rien a corriger a la source : `sshd` journalise toute
// connexion fermee avant l'echange de cles, quel que soit le client.
//
// CE QUI EST JETE EST ETROIT : la phrase EXACTE, et seulement depuis une machine qui porte
// la sonde (`client_sante`, derive de l'inventaire). Le meme message venu d'ailleurs — un
// balayage, une machine inconnue — passe. Les lignes restent dans le journal local ; le
// compte de ce qui est jete est `loki_process_dropped_lines_total{reason="sonde_connectivite"}`,
// distinct des pertes que surveille la sonde « journaux ».
loki.process "sondes" {
forward_to = [loki.write.loki.receiver]
stage.drop {
expression = "^Connection closed by ({{ sondeurs }}) port [0-9]+$"
drop_counter_reason = "sonde_connectivite"
}
stage.drop {
expression = "TLS handshake error from ({{ sondeurs }}):[0-9]+: EOF$"
drop_counter_reason = "sonde_connectivite"
}
// LE COURRIEL PARLE PLUS QUE LES AUTRES (mesure chez les deux locataires, 2026-10-03).
// Postfix ecrit trois lignes par connexion de sonde — quatre sur le port chiffre — et
// faisait de `edge-mta-01` la machine la plus bavarde de chaque locataire (7 000 lignes
// par heure). Une VRAIE session venue de la flotte perd sa ligne `connect from`, rien
// d'autre : ses identifiants de file (`client=`), et son `disconnect` qui compte des
// commandes (`commands=N/M`, jamais `0/0`), restent.
stage.drop {
expression = "^connect from [^\\[ ]+\\[({{ sondeurs }})\\]$"
drop_counter_reason = "sonde_connectivite"
}
stage.drop {
expression = "^(NOQUEUE: )?lost connection after CONNECT from [^\\[ ]+\\[({{ sondeurs }})\\]$"
drop_counter_reason = "sonde_connectivite"
}
stage.drop {
expression = "^disconnect from [^\\[ ]+\\[({{ sondeurs }})\\] commands=0/0$"
drop_counter_reason = "sonde_connectivite"
}
stage.drop {
expression = "^SSL_accept error from [^\\[ ]+\\[({{ sondeurs }})\\]: lost connection$"
drop_counter_reason = "sonde_connectivite"
}
// Dovecot LMTP, en TLS des l'accueil : la sonde ferme avant la poignee. Seule la forme
// « fermee avant de commencer » est jetee ; toute autre raison d'echec TLS passe, et une
// livraison reelle se lit toujours a `saved mail to`.
stage.drop {
expression = "^lmtp\\([0-9]+\\): Connect from ({{ sondeurs }})$"
drop_counter_reason = "sonde_connectivite"
}
stage.drop {
expression = "^lmtp\\([0-9]+\\): Error: lmtp-server: conn ({{ sondeurs }}):[0-9]+ \\[[0-9]+\\]: SSL handshake failed: Connection closed$"
drop_counter_reason = "sonde_connectivite"
}
stage.drop {
expression = "^lmtp\\([0-9]+\\): Disconnect from ({{ sondeurs }}): SSL Initialization failed \\(state=GREETING\\)$"
drop_counter_reason = "sonde_connectivite"
}
}
{% endif %}
loki.source.journal "journal" {
forward_to = [{{ 'loki.process.sondes.receiver' if sondeurs else 'loki.write.loki.receiver' }}]
max_age = "12h"
labels = {
job = "systemd-journal",
host = "{{ inventory_hostname }}",
}
}
{% if client_journal_audit_actif %}
// --- LA PISTE D'AUDIT SORT DE LA MACHINE AUDITEE (2026-09-11) ---
//
// `auditd` ecrit dans `/var/log/audit/audit.log` et nulle part ailleurs. Tant que la
// piste reste sur la machine qu'elle surveille, qui compromet la machine possede la
// preuve de l'avoir fait — et le fichier est precisement ce qu'on efface en premier.
//
// POURQUOI PAS LE GREFFON `syslog.conf` D'AUDITD, qui ferait passer les evenements par
// journald (deja collecte) : journald applique une LIMITE DE DEBIT. Une rafale
// d'evenements d'audit — c'est-a-dire exactement le moment qui compte — serait tronquee
// en silence. On lit donc le fichier directement.
//
// AUCUNE PERMISSION A POSER : `/var/log/audit` est `root:adm` en 0750, et l'utilisateur
// `alloy` appartient DEJA au groupe `adm` (mesure le 2026-09-11). Si cela changeait,
// Alloy se tairait sans erreur visible — la sonde `audit` reste le filet cote machine.
loki.source.file "audit" {
targets = [{
__path__ = "{{ client_journal_audit_chemin }}",
job = "audit",
host = "{{ inventory_hostname }}",
}]
forward_to = [loki.write.loki.receiver]
}
{% endif %}
{% if client_journal_syslog_ecoute %}
// --- RECEPTEUR SYSLOG : CE QUI NE PEUT PAS PORTER D'AGENT (2026-09-10) ---
//
// La frontiere OPNsense n'accueille aucun agent. Elle sait emettre du syslog, et rien
// d'autre. Loki ne parle pas syslog : il faut un traducteur, et Alloy en est un.
//
// POURQUOI CE RECEPTEUR VIT ICI plutot que dans un role a lui : c'est la meme machine qui
// pousse deja ses propres journaux vers le meme Loki. Un second agent ne servirait qu'a
// avoir deux configurations a garder en phase.
//
// L'ETIQUETTE `host` VIENT DE L'EMETTEUR, pas de nous : plusieurs equipements peuvent
// pointer ici, et les confondre sous le nom du recepteur rendrait les journaux
// inutilisables au moment ou l'on en a besoin.
loki.source.syslog "frontiere" {
listener {
address = "{{ client_journal_syslog_ecoute }}"
protocol = "{{ client_journal_syslog_protocole }}"
label_structured_data = true
labels = { job = "syslog", host = "{{ client_journal_syslog_emetteur }}" }
{% if client_journal_syslog_tls | bool %}
// TLS, ET UN CERTIFICAT CLIENT EXIGE : un `ca_file` suffit a l'exiger dans ce
// recepteur (`client_auth_type` n'y est pas accepte — `alloy validate`, 2026-10-12).
tls_config {
cert_file = "{{ client_journal_syslog_tls_dir }}/alloy.crt"
key_file = "{{ client_journal_syslog_tls_dir }}/alloy.key"
ca_file = "{{ client_journal_syslog_tls_dir }}/clients.crt"
min_version = "TLS12"
}
{% endif %}
}
forward_to = [loki.relabel.syslog.receiver]
}
// QUI A PARLE ? On veut la meme etiquette `host` que pour une machine qui pousse, sans
// quoi la requete change de forme selon la provenance et personne ne s'en souvient.
//
// DEUX SOURCES, DANS CET ORDRE, ET C'EST DELIBERE.
//
// L'en-tete RFC5424 porte un nom que l'emetteur se donne — pratique quand il est juste,
// et vide ou faux quand il ne l'est pas. Mesure du 2026-09-10 : ni les journaux reels de
// la frontiere, ni un message d'essai forge a la main avec un nom explicite n'ont produit
// d'etiquette. Le champ existe dans la specification ; il n'arrive pas ici.
//
// L'ADRESSE DE LA CONNEXION, ELLE, EST TOUJOURS LA — et elle ne s'invente pas : c'est le
// pare-feu qui l'observe, pas l'emetteur qui la declare. Un nom qu'on se donne peut
// mentir ; une adresse de connexion, non.
//
// CE QUI A FINALEMENT SERVI : l'etiquette posee sur l'ECOUTEUR, depuis la carte de
// l'hebergeur. Aucune des trois regles ci-dessous n'a produit quoi que ce soit — ni sur
// les journaux reels, ni sur un message forge a la main. On garde le relabel : le jour ou
// l'emetteur enverra un nom, il prendra le dessus. En attendant, le nom vient de la ou il
// est DECLARE, pas de la ou on l'espere.
//
// CE QUE CA SUPPOSE, ET QU'IL FAUT SAVOIR : un SEUL emetteur par ecouteur. Le site n'a
// qu'une frontiere ; le jour ou un second equipement pointe ici, il faudra un ecouteur par
// emetteur, ou faire enfin marcher le relabel. La supposition est ecrite pour qu'on la
// retrouve, plutot que decouverte devant des journaux melanges.
//
// La derniere regle qui correspond gagne : le nom l'emporte quand il existe.
loki.relabel "syslog" {
forward_to = [loki.write.loki.receiver]
// `regex` SUR LES TROIS, Y COMPRIS CELLE-CI (mesure du 2026-09-10).
//
// Sans elle, la regle correspond TOUJOURS — meme quand sa source n'existe pas — et pose
// `host = ""`. Loki jette une etiquette vide, et l'etiquette posee sur l'ecouteur juste
// au-dessus disparaissait avec elle. Le symptome : un flux sans `host`, alors que la
// configuration deposee en portait un, visible a l'oeil dans le fichier.
//
// Une regle de relabel sans garde ne « laisse pas passer » : elle EFFACE.
rule {
source_labels = ["__syslog_connection_ip_address"]
target_label = "host"
regex = "(.+)"
}
rule {
source_labels = ["__syslog_connection_hostname"]
target_label = "host"
regex = "(.+)"
}
rule {
source_labels = ["__syslog_message_hostname"]
target_label = "host"
regex = "(.+)"
}
}
{% endif %}