
Ce chapitre déploie les 3 frontends Nginx du capstone : une VM par AZ (eu-west-2a, b, c), placée dans le subnet privé de chaque Net, accédée uniquement via le bastion (SSH) et via les futurs LBU (HTTPS 443/tcp passthrough). Chaque frontend présente un certificat distinct signé par une CA interne générée au playbook — c'est le SSL bout en bout qui rend pertinent le passthrough du Chapitre 5 : le LBU ne déchiffre rien, le client final voit le certificat du frontend qui sert effectivement la requête. Public visé : intermédiaire à avancé, avec les Chapitres 1, 2 et 3 appliqués sur le compte cible. À la sortie de ce chapitre, vous pouvez curl -k https://<ip-privée-d-un-nginx>/ depuis le bastion et voir la page de démo de l'AZ correspondante.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Coder un stack Terraform
20-app/qui crée 3 frontends + 3 SG distincts (un par Net — contrainte OUTSCALE). - Distinguer SG-to-SG intra-Net (bastion → frontend Net-A) et CIDR cross-Net (bastion → frontend Net-B/C via peering).
- Gérer une CA interne reproductible côté
localhostaveccommunity.crypto, sans dépendance Internet. - Émettre un certificat serveur par AZ signé par la CA, sans qu'aucune clé privée serveur ne reste sur le poste opérateur après le push.
- Utiliser le ProxyJump bastion dans Ansible via
group_vars/tier_private.yml— pas d'IP en dur dans la config SSH. - Diagnostiquer les pièges classiques : SG scoped au Net, cycle Terraform sur la recréation de SG attaché, ControlMaster SSH saturé.
Prérequis
Section intitulée « Prérequis »- Les Chapitres 1, 2 et 3 appliqués (72 + 5 ressources actives).
- Bastion durci avec
AllowTcpForwarding yes(Chapitre 3) — le ProxyJump en dépend. - Collection Ansible :
ansible-galaxy collection install community.crypto. - L'inventaire dynamique
osc_vmconfiguré.
Pourquoi 3 frontends, pas 1
Section intitulée « Pourquoi 3 frontends, pas 1 »Chaque frontend vit dans un Net distinct avec sa propre AZ. Trois raisons structurelles :
- Reliability — perte d'AZ tolérée. La perte de
eu-west-2an'affecte quenginx-a; les deux autres restent up. Le LBU au Chapitre 5 prendra cette AZ comme défaillante et arrêtera de lui envoyer du trafic. - Performance — latence locale. Le LBU de Net-A discute avec
nginx-asans traverser de peering — le RTT est minimal. Le peering inter-Net devient le repli, pas le chemin nominal. - Cost / Sustainability — pas de NAT cross-AZ inutile. Une page servie depuis Net-A ne génère aucun trafic NAT-egress dans Net-B ou Net-C. Le facturable reste local.
Le prix à payer : 3 VMs à patcher au lieu d'1, 3 certificats à renouveler. Au Chapitre 5, le for_each sur les 3 LBU absorbe la complexité — c'est pourquoi on l'a structuré ainsi dès le Chapitre 1.
Pourquoi le subnet privé et pas le public
Section intitulée « Pourquoi le subnet privé et pas le public »Les frontends n'ont aucune IP publique. Le seul ingress autorisé sur eux :
- 22/tcp depuis le subnet public de Net-A (où vit le bastion).
- 443/tcp depuis les 3 subnets publics (où vivront les LBU au Chapitre 5).
Aucune route 0.0.0.0/0 vers IGW depuis le subnet privé. La sortie internet (pour apt update au playbook) passe par le NAT Service de chaque Net créé au Chapitre 1. Surface d'attaque entrante minimale — un attaquant qui scanne eu-west-2 ne voit jamais les frontends, seulement les 3 IPs publiques des LBU (Chapitre 5) et l'EIP du bastion.
Le piège n°1 — SG scoped au Net
Section intitulée « Le piège n°1 — SG scoped au Net »Sur OUTSCALE, un Security Group appartient à un Net (mentionné dans net_id). On ne peut pas attacher un SG de Net-A à une VM de Net-B. C'est la première contrainte qui surprend quand on vient d'AWS où VPC peering laisse cette flexibilité (avec PeeringConnections SG cross-VPC).
Conséquence pour le capstone : on a besoin de 3 SG distincts, un par Net, avec les mêmes règles. La compaction for_each rend ça lisible :
resource "outscale_security_group" "nginx_frontends" { for_each = local.nets # 3 entrées : a, b, c
description = "SG frontends Nginx ${var.project} - ${each.key}" security_group_name = "${var.project}-nginx-frontends-${each.key}" net_id = each.value.net_id
# tags...}
resource "outscale_vm" "nginx_frontend" { for_each = local.frontends subnet_id = each.value.subnet_id security_group_ids = [outscale_security_group.nginx_frontends[each.key].security_group_id] # ...}Une clé partagée (each.key = a, b, c) entre les SG et les VMs permet d'écrire une référence symétrique. Si on renommait les clés, les 6 ressources se renommeraient ensemble et garderaient la cohérence.
Le piège n°2 — SG-to-SG cross-Net impossible
Section intitulée « Le piège n°2 — SG-to-SG cross-Net impossible »Au Chapitre 3, le bastion utilise une règle SG-to-SG propre : son SG en source, le SG du frontend en destination. Élégant, indépendant des CIDRs, idéal pour une infra mouvante.
Problème : sur OUTSCALE, un SG-to-SG ne traverse pas un peering. La règle « SG-source = bastion-ssh (Net-A) → SG-cible = nginx-frontends-b (Net-B) » est rejetée par l'API. Le seul moyen pour que le bastion (Net-A) atteigne les frontends de Net-B et Net-C est de pointer un CIDR.
Le compromis adopté pour le capstone : autoriser 22/tcp depuis le CIDR du subnet public de Net-A (où vit le bastion). C'est un /24 stable, géré par le stack 00-nets, donc pas de fuite quand le bastion est recréé.
locals { bastion_subnet_cidr = local.subnets["a_public"].cidr # 10.10.0.0/24}
resource "outscale_security_group_rule" "ssh_from_bastion_subnet" { for_each = local.nets # une règle par Net flow = "Inbound" security_group_id = outscale_security_group.nginx_frontends[each.key].security_group_id from_port_range = 22 to_port_range = 22 ip_protocol = "tcp" ip_range = local.bastion_subnet_cidr}C'est moins fin qu'un SG-to-SG (n'importe quelle VM qui apparaîtrait dans le subnet public de Net-A pourrait théoriquement SSH sur les frontends), mais le subnet public ne contient que le bastion dans ce capstone. La règle reste défendable côté audit.
Pourquoi SSL bout en bout et pas terminé sur le LBU
Section intitulée « Pourquoi SSL bout en bout et pas terminé sur le LBU »C'est une décision d'architecture, pas un détail technique. Trois critères :
| Aspect | SSL terminé sur le LBU | SSL bout en bout (passthrough) |
|---|---|---|
| Confidentialité du flux LBU↔frontend | En clair (HTTP) ou re-chiffré (HTTPS) | Chiffré nativement |
| Visibilité du LBU sur le payload | Totale (header injection, WAF L7, sticky cookie) | Aucune (TCP transparent) |
| Coût opérationnel TLS | Centralisé sur le LBU (1 cert) | Distribué (1 cert / frontend) |
| Surface attaque sur le LBU | Cert privé + clé sur le LBU | LBU agnostique du contenu |
| Conformité (HDS, secteur santé) | Déchiffrement intermédiaire = parfois litigieux | Chemin TLS continu, audit simple |
Pour le capstone qui vise une posture proche de SecNumCloud / HDS, le passthrough est l'option qui simplifie l'audit conformité — aucune machine intermédiaire ne voit jamais le payload en clair. Le coût opérationnel est absorbé par Ansible (le playbook gère 3 certs aussi facilement qu'1).
Le code Terraform — 11 ressources
Section intitulée « Le code Terraform — 11 ressources »Répertoireterraform/
Répertoire20-app/
- provider.tf
- variables.tf
- locals.tf
- security-group.tf
- nginx-frontends.tf
- outputs.tf
Total : 3 SG + 3 SG rule SSH + 3 SG rule HTTPS × 3 CIDRs (= 9) + 3 VMs = 18 ressources. Le 9 du HTTPS vient du produit cartésien (3 SG × 3 CIDRs publics) — chaque SG accepte le 443 depuis n'importe lequel des 3 subnets publics, parce qu'un LBU peut router cross-Net via peering :
locals { https_rules = merge([ for net_key, _ in local.nets : { for cidr in local.public_cidrs : "${net_key}_from_${replace(cidr, "/", "_")}" => { net_key = net_key cidr = cidr } } ]...)}
resource "outscale_security_group_rule" "https_from_public_subnets" { for_each = local.https_rules
flow = "Inbound" security_group_id = outscale_security_group.nginx_frontends[each.value.net_key].security_group_id from_port_range = 443 to_port_range = 443 ip_protocol = "tcp" ip_range = each.value.cidr}C'est dense, mais ça scale : passer à 4 Nets = 4 SG, 16 règles HTTPS. Le code est inchangé.
Le ProxyJump bastion dans Ansible
Section intitulée « Le ProxyJump bastion dans Ansible »Le bastion expose une EIP fixe, mais l'IP elle-même peut changer entre projets (ou entre régions). On résout dynamiquement via les hostvars de l'inventaire osc_vm. Un seul fichier dans group_vars/:
ansible_ssh_common_args: >- -o ProxyJump=outscale@{{ hostvars["capstone-bastion-a"].public_ip }} -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/nullToutes les VMs taguées tier=private (les 3 frontends, plus tard les backends et la BDD) héritent automatiquement du ProxyJump. Si on régénère le bastion avec une nouvelle EIP, rien à changer côté playbook — la résolution se refait au prochain run.
La CA interne capstone — community.crypto côté localhost
Section intitulée « La CA interne capstone — community.crypto côté localhost »Le playbook gère deux niveaux de PKI :
- CA interne (
localhost) — la racine. Une seule fois dans la vie du projet. Idempotente — relancer ne re-génère pas la CA. - Cert serveur par frontend (
localhost) — généré, signé par la CA, copié sur la VM avecmode: 0600etno_log: true.
- name: CA interne capstone (localhost) hosts: localhost connection: local tasks: - community.crypto.openssl_privatekey: path: "{{ ca_dir }}/ca.key" size: 4096 mode: "0600"
- community.crypto.x509_certificate: path: "{{ ca_dir }}/ca.crt" privatekey_path: "{{ ca_dir }}/ca.key" provider: selfsigned selfsigned_not_after: "+3650d" ownca_create_authority_key_identifier: true mode: "0644"Le répertoire .ca/ est gitignored — la clé privée de la CA ne quitte jamais le poste opérateur. En production, elle vivrait dans un coffre-fort (HashiCorp Vault, hashicorp/cosign, OUTSCALE OOS chiffré avec clé client) — pour le capstone, elle reste locale et c'est documenté.
Le cert serveur — delegate_to: localhost
Section intitulée « Le cert serveur — delegate_to: localhost »- name: Frontends Nginx HTTPS hosts: role_nginx_frontend become: true tasks: - community.crypto.openssl_privatekey: path: "{{ ca_dir }}/{{ inventory_hostname }}.key" size: 2048 delegate_to: localhost become: false
- community.crypto.openssl_csr: path: "{{ ca_dir }}/{{ inventory_hostname }}.csr" privatekey_path: "{{ ca_dir }}/{{ inventory_hostname }}.key" common_name: "{{ inventory_hostname }}" subject_alt_name: - "DNS:{{ inventory_hostname }}" - "IP:{{ ansible_default_ipv4.address }}" delegate_to: localhost become: false
- community.crypto.x509_certificate: path: "{{ ca_dir }}/{{ inventory_hostname }}.crt" csr_path: "{{ ca_dir }}/{{ inventory_hostname }}.csr" ownca_path: "{{ ca_dir }}/ca.crt" ownca_privatekey_path: "{{ ca_dir }}/ca.key" provider: ownca ownca_not_after: "+365d" delegate_to: localhost become: false
# Push sur la VM avec mode 0600 + no_log - ansible.builtin.copy: src: "{{ ca_dir }}/{{ inventory_hostname }}.key" dest: /etc/nginx/ssl/server.key mode: "0600" no_log: true notify: Restart nginxdelegate_to: localhost exécute la tâche côté poste opérateur mais avec le contexte de la VM cible (les variables inventory_hostname, ansible_default_ipv4.address restent celles de la VM). C'est le pattern idiomatique pour une PKI Ansible-driven : on génère localement avec la CA puis on pousse.
Étapes — apply pas à pas
Section intitulée « Étapes — apply pas à pas »-
Vérifier que le bastion est durci et joignable.
Fenêtre de terminal ssh outscale@$(terraform -chdir=terraform/10-bastion output -raw bastion_public_ip) 'whoami' -
Apply Terraform
20-app/.Fenêtre de terminal cd terraform/20-app/terraform initterraform plan # → 18 ressources à créerterraform applyPlan attendu : 18 to add. Compter ~3 minutes (les 3 VMs montent en parallèle).
-
Vérifier l'inventaire dynamique depuis
ansible/.Fenêtre de terminal cd ../ansible/rm -rf .osc_vm_cacheansible-inventory --graph# → role_nginx_frontend doit contenir capstone-nginx-{a,b,c} -
Lancer le playbook.
Fenêtre de terminal ansible-galaxy collection install community.cryptoansible-playbook playbooks/deploy-nginx-frontends.ymlCompter ~2 minutes (install Nginx via apt = la phase la plus longue, ~30 s par hôte).
-
Tester depuis le bastion.
Fenêtre de terminal ssh outscale@<bastion_public_ip> 'for ip in $(cd /home/outscale; echo "10.10.1.X 10.11.1.X 10.12.1.X"); doecho "=== $ip ==="curl -sk https://$ip/healthzcurl -sk https://$ip/ | grep "Frontend Nginx"done'Sortie attendue :
ok\n× 3 + 3 lignes<h1>Frontend Nginx — capstone OUTSCALE</h1>. Sur ce capstone, les IPs résolues sont10.10.1.229,10.11.1.92,10.12.1.59.
Pièges courants
Section intitulée « Pièges courants »| Symptôme | Cause | Solution |
|---|---|---|
The SecurityGroupId 'sg-XXX' doesn't exist in this Net | SG attaché à une VM d'un autre Net | Utiliser for_each = local.nets pour créer un SG par Net |
The Security Group is the unique Security Group of the following VMs (au destroy/rename) | Refactor SG en for_each avec VM existante | Détruire la VM d'abord, state rm du SG, ré-apply |
Connection timed out during banner exchange (Ansible) | ControlMaster SSH saturé / bastion MaxStartups | rm -f ~/.ansible/cp/* + ControlPersist=600s |
Permission denied (publickey) sur les frontends via ProxyJump | Keypair capstone-bastion pas réutilisée | Vérifier var.frontend_keypair_name = "capstone-bastion" |
curl: (35) error:14094410:SSL routines:ssl3_read_bytes:sslv3 alert handshake failure | Le client teste TLS 1.0/1.1 (banni dans la conf Nginx) | Tester avec --tlsv1.2 ; sur clients legacy, ajuster ssl_protocols |
nginx -t OK mais le site retourne 502 / déconnecte | TLS-passthrough du LBU pas encore en place | Normal au Chapitre 4 — le LBU arrive au Chapitre 5 |
À retenir
Section intitulée « À retenir »- 3 SG nginx-frontends, un par Net — contrainte OUTSCALE (SG scoped au Net).
- SG-to-SG ne traverse pas un peering — fallback CIDR du subnet bastion (10.10.0.0/24 stable).
- 3 frontends en subnets privés, sortie Internet uniquement via NAT (apt updates au playbook).
- CA interne
capstone-internal-cagénérée parcommunity.crypto, idempotente, gitignored. - Cert serveur signé localement puis poussé en
mode 0600+no_log: true— la clé privée ne traîne pas dans les logs. ControlPersist=600s+ServerAliveInterval=20dansansible.cfgquand on enchaîne des plays via ProxyJump.- Le passthrough TLS au Chapitre 5 dépend de cette PKI — sans certs valides côté frontend, le LBU ne peut pas faire son rôle.