Aller au contenu
Cloud medium

Chapitre 3 — Bastion SSH durci sur OUTSCALE en Terraform + Ansible

17 min de lecture

logo 3ds outscale

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.

  • Coder un stack Terraform 10-bastion/ qui consomme 2 sources d'état distinctes : le 00-nets/ via terraform_remote_state et l'EIP fixe via data "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.conf validé par sshd -t avant restart.
  • Diagnostiquer les pièges classiques (datasource EIP non sélective, ansible_user Jinja-non-quoté, ProxyJump cassé).
  • 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-ip et project=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.

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/config de 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 :

Pattern EIP fixe pré-allouée hors Terraform : Pré-requis B alloue + tague l'EIP, Terraform la référence en datasource pour résister aux cycles destroy/apply

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).

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.

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.

Les valeurs sensibles ou rattachées à un opérateur (sa clé publique SSH, son IP /32) ne sont pas committées. Deux options :

Fenêtre de terminal
# 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 apply
terraform.tfvars (gitignored)
operator_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.

  1. 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.

  2. Initialiser le stack et appliquer.

    Fenêtre de terminal
    cd terraform/10-bastion/
    terraform init
    export TF_VAR_operator_cidr="$(curl -s https://ifconfig.me/ip)/32"
    export TF_VAR_bastion_public_key="$(cat ~/.ssh/id_ed25519.pub)"
    terraform apply

    Plan attendu : 5 ressources créées (keypair, SG, SG rule, VM, public_ip_link). Compter ~2 minutes (la VM met ~1 min 30 s à running).

  3. 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"
  4. 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 obtenez Permission denied (publickey), la clé exposée dans TF_VAR_bastion_public_key ne correspond pas à la clé privée locale — re-vérifier ~/.ssh/id_ed25519.pub.

  5. Déclarer le bastion dans ~/.ssh/config.

    Host capstone-bastion
    HostName 171.33.111.200
    User outscale
    IdentityFile ~/.ssh/id_ed25519
    Host 10.10.* 10.11.* 10.12.*
    ProxyJump capstone-bastion
    User outscale
    IdentityFile ~/.ssh/id_ed25519

    Cette config rend le ProxyJump implicite sur tout host des 3 plages CIDR du capstone — Ansible et ssh partageront 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.

ansible/inventory/capstone.osc_vm.yml
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: true
cache_plugin: jsonfile
cache_connection: .osc_vm_cache
cache_timeout: 300

Trois subtilités à connaître :

  • Pas de tag:project: capstone dans filters — le plugin attend la forme OAPI Tags: ["key=value"] (CamelCase pluriel comme dans le schéma FiltersVm).
  • exclude_tags est une liste, pas un dict — ["os=talos"] et non {os: talos}.
  • compose: est du Jinja. Pour assigner une string littérale comme outscale, il faut doubler le quoting : "'outscale'" (double-quote YAML, single-quote Jinja). Sinon Ansible évalue outscale comme une variable inexistante, ansible_user reste vide, et la connexion SSH bascule sur l'utilisateur courant — Permission denied garanti.
ansible/playbooks/harden-ssh-bastion.yml (extrait)
- 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 sshd

Quatre éléments structurants :

  • Drop-in plutôt qu'édition de sshd_config. Ubuntu 24.04 charge /etc/ssh/sshd_config.d/*.conf par Include natif. On dépose un seul fichier identifiable, qu'on peut supprimer pour revenir à la conf de base — pas de patch de sshd_config qu'il faut ensuite réverser.
  • validate: "sshd -t -f %s" — Ansible appelle sshd -t sur 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, le ProxyJump casse. C'est la seule option « permissive » du fichier, et elle est documentée par un commentaire.
  • AuthenticationMethods publickey — combiné à PubkeyAuthentication yes et PasswordAuthentication no, ça verrouille la chaîne d'auth à clé publique uniquement. Aucun autre mécanisme n'est essayé, même comme repli.
Fenêtre de terminal
cd ansible/
ansible-inventory --graph # vérifier que role_bastion contient 1 host
ansible role_bastion -m ping # smoke test
ansible-playbook playbooks/harden-ssh-bastion.yml

Sortie 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.

Une fois le bastion durci, l'accès aux VMs privées du capstone passe systématiquement par lui :

Chaîne ProxyJump SSH : poste opérateur → bastion (SG /32 opérateur) → VM privée (SG-to-SG bastion)

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.

SymptômeCauseSolution
your query returned multiple results (datasource EIP)Deux blocs filter { name = "tags" } séparésUn seul bloc avec liste multi-valeurs : values = ["a=b", "c=d"]
Permission denied (publickey) après terraform applyTF_VAR_bastion_public_key ≠ clé localeRe-vérifier cat ~/.ssh/id_ed25519.pub ; refaire terraform apply -refresh-only
Permission denied (publickey) via Ansible alors que ssh outscale@bastion marcheansible_user: "outscale" non quoté en Jinja → variable videQuoter : ansible_user: "'outscale'"
Bastion injoignable après harden-sshErreur de syntaxe sshd, validate non utiliséToujours validate: "sshd -t -f %s" ; en cas d'incident, console OUTSCALE pour réparer
ProxyJump ne fonctionne pasAllowTcpForwarding no dans le drop-inDoit valoir yes sur un bastion — c'est l'exception acceptée
EIP supprimée par accident à terraform destroyEIP gérée par Terraform au lieu d'être en datasourceToujours utiliser data "outscale_public_ip" filtré par tag — Terraform ne crée que le _link
0 hosts in role_bastionTag 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
  • L'EIP fixe vit hors Terraform — datasource filtré par tag purpose=bastion-fixed-ip. Seul outscale_public_ip_link est versionné.
  • Le datasource outscale_public_ip filtre via un seul bloc filter avec valeurs multi-tags (AND), pas plusieurs blocs.
  • L'operator_cidr est validé en HCL (validation { condition }) pour bloquer un 0.0.0.0/0 accidentel.
  • Le bastion est ciblé par groupe dynamique role_bastion (tag role=bastion), pas par IP en dur.
  • Le drop-in /etc/ssh/sshd_config.d/10-capstone-hardening.conf est validé par sshd -t avant restart — règle de survie pour ne jamais perdre l'accès.
  • AllowTcpForwarding yes sur un bastion est la seule option permissive — sans, le ProxyJump casse.
  • Pas de string Jinja sans quotes dans compose:ansible_user: "'outscale'", sinon Permission denied.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracing. Aujourd'hui, ce site ne couvre même pas mes frais d'hébergement, d'électricité, de matériel, de logiciels, mais surtout de cafés.

Un soutien régulier, même symbolique, m'aide à garder ces ressources gratuites et à continuer de produire des guides de qualité. Merci pour votre appui.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn