
Ce chapitre déploie le bastion SSH du capstone : une VM minimale dans le subnet public de Net-A, attachée à une EIP fixe pré-allouée hors Terraform (Pré-requis B), exposée au seul CIDR /32 de l'opérateur, puis durcie par Ansible via un drop-in sshd_config.d. Le bastion est le point de rebond unique vers les VMs privées des trois Nets ; il doit être petit, étroit, et auditable. Public visé : intermédiaire à avancé, avec les Chapitres 1 (Nets) et 2 (Peerings) appliqués sur le compte cible. À la sortie de ce chapitre, vous avez une chaîne SSH complète : poste opérateur → bastion (EIP fixe, sshd durci) → ProxyJump vers les VMs privées des Chapitres 4 et 5.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Coder un stack Terraform
10-bastion/qui consomme 2 sources d'état distinctes : le00-nets/viaterraform_remote_stateet l'EIP fixe viadata "outscale_public_ip". - Restreindre l'ingress SSH au CIDR /32 de l'opérateur via
outscale_security_group_rule— règle non négociable du pilier Security. - Cibler le bastion par groupe Ansible dynamique (
role_bastion) sans inventaire statique. - Appliquer un drop-in
sshd_config.d/10-capstone-hardening.confvalidé parsshd -tavantrestart. - Diagnostiquer les pièges classiques (datasource EIP non sélective,
ansible_userJinja-non-quoté, ProxyJump cassé).
Prérequis
Section intitulée « Prérequis »- Les Chapitres 1 et 2 appliqués (72 ressources réseau actives).
- EIP fixe bastion allouée hors Terraform (Pré-requis B) avec les tags
purpose=bastion-fixed-ipetproject=capstone. - Connaissance de la Référence inventaire dynamique
osc_vm(Volet 3). - Une clé publique SSH locale (typiquement
~/.ssh/id_ed25519.pub) — la clé privée ne quitte jamais le poste opérateur.
Pourquoi un bastion plutôt que SSH direct sur les VMs
Section intitulée « Pourquoi un bastion plutôt que SSH direct sur les VMs »Trois raisons structurelles, alignées sur le pilier Security du WAF :
- Réduction de surface. Les VMs privées (frontends, backends, BDD) n'ont aucune IP publique ni route 0.0.0.0/0 vers l'IGW. Pas de SG ingress 22/tcp à ouvrir sur Internet pour 12 VMs — un seul SG ingress sur un seul hôte.
- Centralisation des logs. Toutes les sessions interactives passent par le bastion. Un seul point de log à exporter vers le SIEM, un seul
journalctl -u sshà inspecter en cas d'incident. - Rotation simplifiée des accès. Révoquer l'accès d'un opérateur = supprimer son compte sur le bastion. Sans bastion, il faudrait propager la révocation sur 12 VMs.
Le prix à payer : une dépendance opérationnelle (le bastion doit tourner pour qu'on puisse intervenir). On compense au Chapitre 6 par un runbook de bastion de secours activable en moins de 5 minutes.
Pourquoi l'EIP fixe vit hors Terraform
Section intitulée « Pourquoi l'EIP fixe vit hors Terraform »Le bastion est le point de contact stable de toute la chaîne d'accès. Son IP publique est partagée avec :
- Le
~/.ssh/configde chaque opérateur (HostName 171.33.111.200). - Les règles d'allowlist amont (firewall corporate, WAF, services managés).
- D'éventuelles entrées DNS internes (
bastion.capstone.local).
Si cette IP change à chaque terraform destroy/apply, toute la chaîne casse — c'est le scénario le plus pénible à diagnostiquer un dimanche soir. La discipline est de dissocier le cycle de vie :
Terraform crée et détruit la VM + le link autant de fois que nécessaire ; l'EIP elle-même ne bouge pas. C'est le pattern « ressource pivot externe » qu'on retrouvera au Chapitre 5 pour l'EIP flottante HA.
Le code Terraform — 5 ressources, 2 sources d'état
Section intitulée « Le code Terraform — 5 ressources, 2 sources d'état »Le stack tient en 8 fichiers et 5 ressources créées : outscale_keypair, outscale_security_group, outscale_security_group_rule, outscale_vm, outscale_public_ip_link.
Répertoireterraform/
Répertoire10-bastion/
- provider.tf
- variables.tf
- locals.tf
- data.tf
- keypair.tf
- security-group.tf
- bastion.tf
- outputs.tf
- terraform.tfvars.example
Le datasource EIP — un filtre tags multi-valeurs
Section intitulée « Le datasource EIP — un filtre tags multi-valeurs »data "outscale_public_ip" "bastion" { filter { name = "tags" values = ["purpose=bastion-fixed-ip", "project=${var.project}"] }}Le piège est de mettre deux blocs filter distincts (un par tag). Sur OUTSCALE, deux blocs filter { name = "tags" ... } sont interprétés comme une conjonction de filtres OR sur le même champ et peuvent retourner plusieurs EIPs — le datasource échoue alors avec your query returned multiple results. Le bon pattern est un seul bloc avec une liste de valeurs : OUTSCALE applique alors un AND naturel (toutes les valeurs doivent matcher).
Variables et validation forte
Section intitulée « Variables et validation forte »variable "operator_cidr" { type = string description = "CIDR de l'IP publique de l'opérateur autorisé en SSH (ex. 86.229.137.201/32)." validation { condition = var.operator_cidr != "0.0.0.0/0" error_message = "operator_cidr ne doit jamais valoir 0.0.0.0/0 — ouvrir SSH au monde entier sur le bastion casse le pilier Security." }}
variable "bastion_public_key" { type = string description = "Clé publique SSH (contenu de ~/.ssh/id_ed25519.pub)."}Le validation { condition } bloque l'apply si l'opérateur essaye 0.0.0.0/0. C'est une garde simple mais efficace — la pression du temps en environnement de production fait souvent contourner les bonnes pratiques, et un message Terraform explicite rappelle la règle au bon moment.
La VM bastion + le link EIP
Section intitulée « La VM bastion + le link EIP »resource "outscale_vm" "bastion" { image_id = var.bastion_omi_id vm_type = var.bastion_vm_type keypair_name = outscale_keypair.bastion.keypair_name subnet_id = local.subnet_a_public_id security_group_ids = [outscale_security_group.bastion_ssh.security_group_id]
dynamic "tags" { for_each = merge(local.base_tags, { Name = "${var.project}-bastion-a" net = "a" tier = "public" role = "bastion" }) content { key = tags.key value = tags.value } }}
resource "outscale_public_ip_link" "bastion" { vm_id = outscale_vm.bastion.vm_id public_ip = data.outscale_public_ip.bastion.public_ip}Quelques points à noter. Le tag role=bastion est mandatory — c'est lui qui fait apparaître la VM dans le groupe Ansible role_bastion plus loin. La ressource outscale_public_ip_link est la seule qui touche à l'EIP ; un terraform destroy du stack supprime la VM + le link, mais pas l'EIP (qui vit hors Terraform). Au prochain apply, on relie une nouvelle VM à la même EIP — l'IP publique est inchangée pour les opérateurs.
Pas de secret dans le code
Section intitulée « Pas de secret dans le code »Les valeurs sensibles ou rattachées à un opérateur (sa clé publique SSH, son IP /32) ne sont pas committées. Deux options :
# Option 1 : variables d'environnement TF_VAR_*export TF_VAR_operator_cidr="$(curl -s https://ifconfig.me/ip)/32"export TF_VAR_bastion_public_key="$(cat ~/.ssh/id_ed25519.pub)"terraform applyoperator_cidr = "86.229.137.201/32"bastion_public_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... bob@laptop"Le dépôt versionne uniquement terraform.tfvars.example avec des placeholders. Le .gitignore du capstone exclut *.tfvars (sauf *.example) — et un *.tfvars accidentellement commité est rattrapé par le hook pre-commit du dépôt.
Étapes — apply pas à pas
Section intitulée « Étapes — apply pas à pas »-
Vérifier que l'EIP fixe existe (Pré-requis B effectué).
Fenêtre de terminal oapi-cli ReadPublicIps --Filters.Tags '["purpose=bastion-fixed-ip"]' \| jq '.PublicIps[].PublicIp'Doit retourner exactement une IP. Sinon, allouer ou nettoyer les doublons.
-
Initialiser le stack et appliquer.
Fenêtre de terminal cd terraform/10-bastion/terraform initexport TF_VAR_operator_cidr="$(curl -s https://ifconfig.me/ip)/32"export TF_VAR_bastion_public_key="$(cat ~/.ssh/id_ed25519.pub)"terraform applyPlan attendu : 5 ressources créées (keypair, SG, SG rule, VM, public_ip_link). Compter ~2 minutes (la VM met ~1 min 30 s à
running). -
Récupérer l'IP publique du bastion (qui doit matcher l'EIP fixe).
Fenêtre de terminal terraform output bastion_public_ip# → "171.33.111.200" -
Tester la connexion SSH depuis le poste opérateur.
Fenêtre de terminal ssh outscale@$(terraform output -raw bastion_public_ip) "whoami && hostname"Sortie attendue :
outscale+ le hostname interne de la VM. Si vous obtenezPermission denied (publickey), la clé exposée dansTF_VAR_bastion_public_keyne correspond pas à la clé privée locale — re-vérifier~/.ssh/id_ed25519.pub. -
Déclarer le bastion dans
~/.ssh/config.Host capstone-bastionHostName 171.33.111.200User outscaleIdentityFile ~/.ssh/id_ed25519Host 10.10.* 10.11.* 10.12.*ProxyJump capstone-bastionUser outscaleIdentityFile ~/.ssh/id_ed25519Cette config rend le ProxyJump implicite sur tout host des 3 plages CIDR du capstone — Ansible et
sshpartageront le même rebond.
Le durcissement SSH — Ansible piloté par inventaire dynamique
Section intitulée « Le durcissement SSH — Ansible piloté par inventaire dynamique »Le bastion tourne sur l'OMI Ubuntu 24.04 officielle Outscale (pas l'OMI durcie du Pré-requis A — un bastion fait peu de choses, on ne lui impose pas une OMI applicative). On applique donc un durcissement minimal sshd post-déploiement avec Ansible. La cible n'est pas une IP fixe en dur dans un inventory.ini — c'est le groupe dynamique role_bastion produit par le plugin osc_vm.
L'inventaire dynamique — config
Section intitulée « L'inventaire dynamique — config »plugin: osc_vm
filters: Tags: - "project=capstone" VmStateNames: - running
hostnames: - tag:Name - VmId
exclude_tags: - os=talos # Talos n'a pas de SSH
compose: ansible_host: public_ip | default(private_ips[0]) ansible_user: "'outscale'"
keyed_groups: - { key: vm_tags.role, prefix: role } - { key: vm_tags.tier, prefix: tier } - { key: vm_tags.net, prefix: net } - { key: vm_tags.env, prefix: env }
cache: truecache_plugin: jsonfilecache_connection: .osc_vm_cachecache_timeout: 300Trois subtilités à connaître :
- Pas de
tag:project: capstonedansfilters— le plugin attend la forme OAPITags: ["key=value"](CamelCase pluriel comme dans le schémaFiltersVm). exclude_tagsest une liste, pas un dict —["os=talos"]et non{os: talos}.compose:est du Jinja. Pour assigner une string littérale commeoutscale, il faut doubler le quoting :"'outscale'"(double-quote YAML, single-quote Jinja). Sinon Ansible évalueoutscalecomme une variable inexistante,ansible_userreste vide, et la connexion SSH bascule sur l'utilisateur courant —Permission deniedgaranti.
Le playbook — drop-in sshd_config.d
Section intitulée « Le playbook — drop-in sshd_config.d »- name: Durcissement SSH du bastion capstone hosts: role_bastion become: true
handlers: - name: Restart sshd ansible.builtin.service: name: ssh state: restarted
tasks: - name: Déposer le drop-in de durcissement SSH ansible.builtin.copy: dest: /etc/ssh/sshd_config.d/10-capstone-hardening.conf owner: root group: root mode: "0644" backup: true content: | PasswordAuthentication no PermitEmptyPasswords no PubkeyAuthentication yes AuthenticationMethods publickey PermitRootLogin no X11Forwarding no AllowTcpForwarding yes # nécessaire pour ProxyJump LogLevel VERBOSE Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512 HostKeyAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256 ClientAliveInterval 300 ClientAliveCountMax 2 LoginGraceTime 30 AllowUsers outscale Banner /etc/issue.net validate: "sshd -t -f %s" notify: Restart sshdQuatre éléments structurants :
- Drop-in plutôt qu'édition de
sshd_config. Ubuntu 24.04 charge/etc/ssh/sshd_config.d/*.confparIncludenatif. On dépose un seul fichier identifiable, qu'on peut supprimer pour revenir à la conf de base — pas de patch desshd_configqu'il faut ensuite réverser. validate: "sshd -t -f %s"— Ansible appellesshd -tsur le nouveau fichier avant de l'écrire en place. Si la conf est cassée, le fichier n'est pas modifié et le service n'est pas redémarré. Sans cette validation, une faute de frappe peut sortir le bastion d'accès SSH, et il n'y a plus de moyen d'y revenir hors console.AllowTcpForwarding yes— non négociable pour un bastion. Sans, leProxyJumpcasse. C'est la seule option « permissive » du fichier, et elle est documentée par un commentaire.AuthenticationMethods publickey— combiné àPubkeyAuthentication yesetPasswordAuthentication no, ça verrouille la chaîne d'auth à clé publique uniquement. Aucun autre mécanisme n'est essayé, même comme repli.
Lancement et validation
Section intitulée « Lancement et validation »cd ansible/ansible-inventory --graph # vérifier que role_bastion contient 1 hostansible role_bastion -m ping # smoke testansible-playbook playbooks/harden-ssh-bastion.ymlSortie attendue de la dernière commande : 9 tasks, 4 changed (drop-in + banner + service + handler restart). Le test final sshd -t doit retourner OK — sinon le playbook a aborté avant le restart, le bastion reste joignable avec sa conf précédente.
ProxyJump — la chaîne complète
Section intitulée « ProxyJump — la chaîne complète »Une fois le bastion durci, l'accès aux VMs privées du capstone passe systématiquement par lui :
Ce sera le modèle d'accès des Chapitres 4, 5 et 6 — aucune VM privée ne sera jamais directement joignable depuis Internet. Au Chapitre 4, les Security Groups des frontends/backends/BDD autoriseront 22/tcp depuis le SG du bastion (pattern SG-to-SG), pas depuis une plage CIDR.
Pièges courants
Section intitulée « Pièges courants »| Symptôme | Cause | Solution |
|---|---|---|
your query returned multiple results (datasource EIP) | Deux blocs filter { name = "tags" } séparés | Un seul bloc avec liste multi-valeurs : values = ["a=b", "c=d"] |
Permission denied (publickey) après terraform apply | TF_VAR_bastion_public_key ≠ clé locale | Re-vérifier cat ~/.ssh/id_ed25519.pub ; refaire terraform apply -refresh-only |
Permission denied (publickey) via Ansible alors que ssh outscale@bastion marche | ansible_user: "outscale" non quoté en Jinja → variable vide | Quoter : ansible_user: "'outscale'" |
Bastion injoignable après harden-ssh | Erreur de syntaxe sshd, validate non utilisé | Toujours validate: "sshd -t -f %s" ; en cas d'incident, console OUTSCALE pour réparer |
ProxyJump ne fonctionne pas | AllowTcpForwarding no dans le drop-in | Doit valoir yes sur un bastion — c'est l'exception acceptée |
EIP supprimée par accident à terraform destroy | EIP gérée par Terraform au lieu d'être en datasource | Toujours utiliser data "outscale_public_ip" filtré par tag — Terraform ne crée que le _link |
0 hosts in role_bastion | Tag role=bastion absent ou cache osc_vm périmé | Vérifier terraform plan côté tags, rm -rf .osc_vm_cache puis ansible-inventory --graph |
À retenir
Section intitulée « À retenir »- L'EIP fixe vit hors Terraform — datasource filtré par tag
purpose=bastion-fixed-ip. Seuloutscale_public_ip_linkest versionné. - Le datasource
outscale_public_ipfiltre via un seul blocfilteravec valeurs multi-tags (AND), pas plusieurs blocs. - L'
operator_cidrest validé en HCL (validation { condition }) pour bloquer un0.0.0.0/0accidentel. - Le bastion est ciblé par groupe dynamique
role_bastion(tagrole=bastion), pas par IP en dur. - Le drop-in
/etc/ssh/sshd_config.d/10-capstone-hardening.confest validé parsshd -tavant restart — règle de survie pour ne jamais perdre l'accès. AllowTcpForwarding yessur un bastion est la seule option permissive — sans, leProxyJumpcasse.- Pas de string Jinja sans quotes dans
compose:—ansible_user: "'outscale'", sinonPermission denied.