Proxy Squid & DNS (unbound)

Rôle

Deux services indépendants, chacun en haute disponibilité propre, hébergés sur 4 VM en zone DMZ :

  • Squid : proxy sortant applicatif filtrant par whitelist de domaines — seul point de sortie Internet autorisé pour l'installation de paquets, dépôts, mises à jour.
  • unbound : résolveur DNS agissant en pur forwarder vers l'Active Directory — seul chemin de résolution DNS pour les zones SOC et admin.

Les deux répondent aux mêmes zones clientes (admin + SOC) mais sont des clusters strictement séparés, sans dépendance l'un envers l'autre.

Architecture

Nœuds et VIP — deux clusters keepalived indépendants

Cluster Nœuds Segment VIP VRID
Squid (squid) INDPRXY001 (10.100.2.122→MASTER prio110), INDPRXY002 (10.100.2.123→BACKUP prio100) seg-proxy (10.100.10.1/29) 10.100.10.4/29 20
DNS (dnsproxy) INDPRXD001 (10.100.2.124→MASTER prio110), INDPRXD002 (10.100.2.125→BACKUP prio100) seg-proxy-dns (10.100.10.9/29) 10.100.10.12/29 21

Pas de HAProxy devant ces VIP

Contrairement à ce qu'on pourrait attendre par analogie avec d'autres services de l'infra, Squid et unbound ne sont pas exposés par le cluster HAProxy (10.100.2.130) : ce sont deux VIP keepalived indépendantes, propres à ce dépôt, chacune avec son propre vrrp_instance (VI_SQUID / VI_DNS) et son propre script de contrôle (killall -0 squid / killall -0 unbound).

flowchart TD
    CLIENTS["Zones clientes : admin (10.100.2.0/23)<br/>+ SOC (10.100.20.0/24)"]

    subgraph SQ[" Cluster Squid — seg-proxy 10.100.10.0/29 — VRID 20 "]
        VIPSQ(["VIP 10.100.10.4"])
        SQ1["INDPRXY001 · .122<br/>MASTER prio 110"]
        SQ2["INDPRXY002 · .123<br/>BACKUP prio 100"]
        VIPSQ === SQ1
        SQ1 -.failover.-> SQ2
    end

    subgraph DN[" Cluster unbound — seg-proxy-dns 10.100.10.8/29 — VRID 21 "]
        VIPDN(["VIP 10.100.10.12"])
        DN1["INDPRXD001 · .124<br/>MASTER prio 110"]
        DN2["INDPRXD002 · .125<br/>BACKUP prio 100"]
        VIPDN === DN1
        DN1 -.failover.-> DN2
    end

    CLIENTS -->|"3128, whitelist dstdomain"| VIPSQ
    CLIENTS -->|"53, DNS"| VIPDN

    VIPSQ --> INTERNET["Internet<br/>domaines whitelistés uniquement"]
    VIPDN --> AD["Active Directory<br/>10.100.2.241 / .242"]

Chaque VM porte un second NIC dédié sur seg-admin (accès SSH/management), séparé du NIC exposé en DMZ — sshd n'écoute plus du tout sur l'IP DMZ. Ce NIC admin est ajouté par Terraform mais son adressage/routage est achevé à la main dans le guest (voir la section Procédure manuelle ci-dessous) :

flowchart LR
    subgraph VM[" Une VM proxy, ex. INDPRXY001 "]
        direction TB
        SVC["NIC service — seg-proxy<br/>.122 · Squid : 3128 seul<br/>route par défaut globale"]
        ADM["NIC admin — seg-admin<br/>.122 · sshd ListenAddress<br/>table de routage dédiée (100)"]
    end

    CLIENT["Clients admin/SOC"] -->|"3128"| SVC
    MGMT["Poste admin<br/>(SNAT FortiGate grp-mgmt-nat)"] -->|"22/tcp"| ADM

    SVC -->|réponse| GWSVC["Gateway seg-proxy .1"]
    ADM -->|"réponse (policy-based routing,<br/>ip rule + table 100)"| GWADM["Gateway seg-admin"]

Zones desservies

Clients autorisés, identiques pour les deux services (défense en profondeur — la DFW restreint déjà l'accès) :

  • 127.0.0.0/8 / 127.0.0.1/32 (local)
  • 10.100.2.0/23 (zone admin)
  • 10.100.20.0/24 (zone SOC)

Squid — whitelist de domaines (dstdomain)

Domaines groupés par usage dans squid_allowed_domains : VMware/Broadcom, Rocky Linux, EPEL/Fedora, HashiCorp/Terraform, Ansible Galaxy, PyPI, endpoints AWS (.amazonaws.com, .aws.amazon.com), SIEM SOC (Splunk, Elastic), threat intelligence (VirusTotal, AlienVault OTX, abuse.ch), TheHive, tarballs Apache (Kafka), Docker Hub, GHCR, Windows Update, PostgreSQL PGDG, GitHub/githubusercontent.com (CDN de redirection Terraform/Galaxy), Confluent (Kafka), dépôt Zabbix.

DNS — forwarding vers l'Active Directory

unbound transmet toutes les requêtes (forward-zone ".") vers les deux contrôleurs AD (10.100.2.241, 10.100.2.242). domain-insecure est déclaré pour infra.indio (domaine interne sans délégation DNSSEC publique).

Provisioning Terraform

Un seul main.tf à la racine du dépôt (pas de sous-dossier terraform/ séparé, à la différence du dépôt haproxy) déploie les 4 VM depuis tmpl-rocky96-hardened, chacune avec deux interfaces réseau : le réseau applicatif (seg-proxy ou seg-proxy-dns selon le nœud, défini par var.nodes[].network) et le réseau admin (seg-admin, var.admin_network).

  • variables.tf : nodes liste name/network/gateway/admin_ip (les 4 VM), vm_cpu=2, vm_ram=2048, vm_netmask=29 (segments proxy), vm_dns=[10.100.2.241, 10.100.2.242].
  • Providers : hashicorp/vsphere >= 2.6.0, e-breuninger/netbox >= 5.0.0, < 6.0.0. Terraform >= 1.5.0.
  • outputs.tf : uniquement vms (nom → IP).

Configuration Ansible

site.yml définit deux plays : squid (rôles squid + keepalived) et dnsproxy (rôles dnsproxy + keepalived).

  • Rôle squid : installe le paquet, déploie allowed_domains (liste de squid_allowed_domains) et squid.conf (validé par squid -k parse -f %s), ouvre le port 3128 dans firewalld (zone drop). La config applique : ACL réseaux clients, restriction des Safe_ports/SSL_ports, ACL dstdomain sur le fichier whitelist, deny all en dernier, durcissement (via off, forwarded_for delete, httpd_suppress_version_string on).
  • Rôle dnsproxy : installe unbound, déploie dnsproxy.conf (validé par unbound-checkconf), ouvre le service dns dans firewalld (zone drop).
  • Rôle keepalived (générique, réutilisé par les deux clusters via keepalived_instance/keepalived_check_process) : vrrp_script chk_svc (killall -0 <process>), authentification PASS, VIP/masque.
  • group_vars/squid.yml et group_vars/dnsproxy.yml portent chacun un keepalived_auth_pass chiffré en ansible-vault ($ANSIBLE_VAULT), déchiffré via vault_password_file = /root/.proxy_vault_pass (ansible.cfg, hors dépôt).

Procédure manuelle

Le NIC admin (seg-admin) est bien créé par Terraform (2ᵉ bloc network_interface dans main.tf), mais tout le reste — adressage IP, routage et restriction SSH — est appliqué à la main dans le guest : le bloc clone.customize ne gère que le NIC applicatif (DMZ/DNS) et est de toute façon ignoré après la création initiale (lifecycle.ignore_changes). Procédure telle qu'appliquée le 2026-07-05 sur les 4 VM (INDPRXY001/002, INDPRXD001/002), reproductible pour toute VM future ajoutée à ce schéma multi-homed :

  1. Adresser le NIC admin avec l'IP statique prévue (admin_ip de variables.tf, 10.100.2.122-.125) :

    nmcli con mod <profil-admin> ipv4.addresses <ip-admin>/25 ipv4.method manual
    nmcli con up <profil-admin>
    
  2. Assouplir rp_filter sur le NIC admin. Sans gateway par défaut sur ce 2ᵉ NIC, le noyau rejette silencieusement (« martian source », visible dans dmesg) les paquets entrants dont la route retour ne repasse pas par la même interface — cas systématique ici puisque la source vue par le NIC admin est l'IP SNAT du FortiGate (10.15.91.14), hors du subnet seg-admin :

    sysctl -w net.ipv4.conf.<if-admin>.rp_filter=2   # loose ; persister sous /etc/sysctl.d/
    

    La valeur effective étant max(all, interface), net.ipv4.conf.all.rp_filter=1 (défaut Rocky) n'oblige à changer que la valeur de l'interface admin.

  3. Router par stratégie (policy-based routing). Le fix rp_filter seul ne suffit pas : sans route dédiée, la réponse (SYN-ACK) ressort par le NIC de service via la route par défaut globale, avec l'IP source du NIC admin — une combinaison invalide que l'appelant rejette (routage asymétrique). Fix définitif :

    nmcli con mod <profil-admin> +ipv4.routes "0.0.0.0/0 <gateway-admin> table=100"
    nmcli con mod <profil-admin> ipv4.routing-rules "priority 100 from <ip-admin> table 100"
    nmcli con up <profil-admin>
    
  4. Restreindre sshd au NIC admin : déployer /etc/ssh/sshd_config.d/01-admin-only-listen.conf avec ListenAddress <ip-admin> (+ 127.0.0.1), puis systemctl reload sshd. Cette restriction applicative complète (ne remplace pas) la suppression côté NSX de la règle mgmt-to-dmz-ssh : la défense en profondeur repose sur les deux couches — plus aucun chemin, réseau ou applicatif, n'autorise SSH vers l'IP exposée en DMZ.

  5. Diagnostiquer un timeout résiduel après ces étapes : tcpdump -i <nic-service> host <ip-admin> côté serveur pendant une tentative de connexion — si le SYN-ACK apparaît sur le NIC de service au lieu du NIC admin, l'étape 3 est incomplète ou la règle de routage a été perdue après un redémarrage réseau (vérifier ip rule show et ip route show table 100).

L'IP admin (pas l'IP de service) est ensuite ce qu'ansible_host référence dans inventory.ini (voir Architecture ci-dessus) — donc ce que reçoivent Ansible/ansible_host pour toute connexion de gestion ultérieure.

Effet de bord sur le rôle Ansible ipa_client

Ces 4 VM sont aussi enrôlées comme clients FreeIPA (voir FreeIPA & trust AD). Le rôle ipa_client (/root/identity) templait /etc/hosts avec {{ ansible_host }} — casse dès qu'ansible_host pointe vers l'IP admin, puisque le FQDN doit résoudre vers l'IP de service, pas l'IP de management utilisée pour la connexion SSH d'Ansible. Corrigé par l'introduction d'une variable dédiée ipa_client_ip (IP de service) découplée d'ansible_host (IP admin).

Procédure de déploiement

cd /root/proxy
cp terraform.tfvars.example terraform.tfvars
terraform init
terraform plan
terraform apply       # crée les 4 VM (2 NIC chacune)
# configurer manuellement l'IP du NIC admin (seg-admin) dans le guest si nécessaire

cd ansible
ansible-playbook site.yml   # déploie squid+keepalived puis dnsproxy+keepalived

Points d'attention

  • Piège Squid déjà rencontré (2026-07-14) : Squid 5.5 refuse de démarrer si la whitelist contient une entrée qui est un sous-domaine d'une entrée déjà présente (« is a subdomain of… »). Un ajout de pkg-containers.githubusercontent.com (déjà couvert par .githubusercontent.com) a cassé Squid sur les 2 nœuds simultanément, ~15 minutes de panne totale. Toujours vérifier qu'un nouveau domaine n'est pas déjà couvert par une entrée existante avant de déployer un changement de squid_allowed_domains.
  • interface-automatic: yes obligatoire pour unbound : sans ce réglage, un socket UDP bindé sur 0.0.0.0 répond avec l'IP primaire de l'interface au lieu de l'IP de destination reçue (la VIP) — le client rejette la réponse (timeout côté dig), alors que le paquet a bien circulé. Le TCP n'est pas affecté (un socket TCP accepté hérite nativement de l'IP de destination exacte), ce qui rend le symptôme trompeur si le diagnostic ne teste qu'en TCP.
  • domain-insecure obligatoire pour infra.indio : ce domaine interne n'a pas de délégation DNSSEC publique — sans cette exception, le validateur DNSSEC (actif par défaut) rejette les réponses forwardées par l'AD en « bogus », d'où un SERVFAIL systématique.
  • Le NIC admin dédié (seg-admin) n'est configuré que manuellement dans le guest : le bloc Terraform clone.customize ne gère que le NIC applicatif (DMZ), et il est de toute façon ignoré après la création initiale (lifecycle.ignore_changes). Piège classique de VM multi-homed (routage asymétrique, rp_filter) si le NIC admin est mal configuré après coup — voir la section Procédure manuelle ci-dessus pour le détail complet (rp_filter en mode loose, routage par stratégie, restriction sshd, diagnostic tcpdump).
  • keepalived_auth_pass est chiffré en ansible-vault dans les deux group_vars — un déploiement sans vault_password_file accessible (/root/.proxy_vault_pass) échoue au déchiffrement, pas silencieusement.
  • Contrairement au dépôt HAProxy (qui génère ansible/inventory.ini automatiquement depuis Terraform), ce dépôt ne montre pas de mécanisme équivalent dans main.tf : l'inventaire (ansible/inventory.ini) semble maintenu manuellement — à vérifier avant toute modification de la liste de nœuds.
  • La whitelist Squid liste les endpoints AWS (.amazonaws.com, .aws.amazon.com) : c'est le chemin de sortie utilisé par plusieurs intégrations vers AWS de l'infra (voir AWS), dont l'Elastic Agent du lab de détection aws-s3-siem, configuré pour ne sortir que via 10.100.10.4:3128.
  • Piège de boucle egress pour ces 4 VM elles-mêmes (2026-07-16) : ces VM ne peuvent pas utiliser leur propre VIP Squid (10.100.10.4:3128) comme HTTP_PROXY/HTTPS_PROXY pour sortir vers Internet — elles sont elles-mêmes les backends de cette VIP, la requête boucle vers elles-mêmes/leur pair et Squid la refuse (403) ou elle timeout selon le nœud. Symptôme observé : échec du téléchargement du RPM zabbix-release (repo.zabbix.com) lors du déploiement de l'agent Zabbix sur ces 4 VM uniquement, alors que le reste de la flotte (derrière le vrai proxy Squid) fonctionnait normalement. Fix appliqué : dépôt Nexus yum proxy dédié, accessible via repo.infra.infra et déjà couvert par la règle DFW existante dmz-to-repo/soc-*-to-repo — voir Nexus. Pattern à reproduire pour tout futur logiciel tiers nécessitant un dépôt YUM externe sur ces 4 VM spécifiquement.