Socle système et Docker Engine
Le socle, c'est quoi ?
Le socle est ce que reçoit toute VM Nébuleuse avant la moindre application : un système à jour, un accès SSH restreint, un pare-feu, puis Docker avec son réseau partagé. Il est porté par deux rôles, base-hardening et docker-engine, appliqués dans cet ordre.
| Rôle | Rôle dans le socle |
|---|---|
base-hardening |
Mises à jour, SSH restreint au réseau d'administration, pare-feu ufw |
docker-engine |
Docker depuis le dépôt officiel, journaux limités, réseau proxy |
VM Debian 12 vierge → base-hardening → docker-engine → machine prête à recevoir Traefik
Concrètement, la VM livrée par le provisioning possède déjà un utilisateur deploy autorisé par clé SSH et membre de sudo sans mot de passe. Le socle ne crée pas cet utilisateur : il en dépend, et il le verrouille comme seul compte autorisé à se connecter.
Prérequis
- Les pages précédentes sont en place : inventaire, variables communes (
admin_network_cidr) etansible ... -m pingfonctionnel. - L'utilisateur
deployse connecte par clé et dispose desudosans mot de passe. - L'adresse
ansible_hostde chaque client est son adresse du réseau d'administration, car c'est celle sur laquelle SSH écoutera.
Arborescence des deux rôles
roles/
├── base-hardening/
│ ├── defaults/main.yml
│ ├── handlers/main.yml
│ ├── tasks/main.yml
│ └── templates/sshd-nebuleuse.conf.j2
└── docker-engine/
├── defaults/main.yml
├── handlers/main.yml
├── tasks/main.yml
└── templates/daemon.json.j2
Le rôle base-hardening
Variables
# roles/base-hardening/defaults/main.yml
# Seul utilisateur autorisé à ouvrir une session SSH.
base_ssh_user: deploy
# Adresse sur laquelle SSH écoute : celle du réseau d'administration.
# "ansible_host" vaut cette adresse dans l'inventaire (voir page Inventaire).
base_ssh_listen_address: "{{ ansible_host }}"
# Réseau autorisé à joindre SSH (défini une fois pour tous dans group_vars/all).
base_admin_network_cidr: "{{ admin_network_cidr }}"
# Ports ouverts à tout Internet : uniquement le web.
base_public_tcp_ports:
- 80
- 443
Configuration SSH
# roles/base-hardening/templates/sshd-nebuleuse.conf.j2
# Fichier géré par Ansible : toute modification manuelle sera écrasée.
# Pas de connexion directe en root.
PermitRootLogin no
# Authentification par clé uniquement : aucun mot de passe n'est accepté.
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
# Un seul compte peut se connecter.
AllowUsers {{ base_ssh_user }}
# SSH n'écoute que sur l'adresse d'administration, jamais sur l'adresse publique.
ListenAddress {{ base_ssh_listen_address }}
Note — Sous Debian 12, le fichier principal /etc/ssh/sshd_config commence par une directive Include /etc/ssh/sshd_config.d/*.conf. Pour chaque paramètre, SSH retient la première valeur lue : le fichier déposé dans ce dossier l'emporte donc sur le fichier principal, sans avoir à le modifier.
Tâches
# roles/base-hardening/tasks/main.yml
- name: Mettre à jour l'index des paquets et les paquets installés
ansible.builtin.apt:
update_cache: true
# "dist" équivaut à "apt dist-upgrade" : gère aussi les changements de dépendances.
upgrade: dist
# Ne relit l'index qu'au bout d'une heure : évite un téléchargement inutile à chaque passage.
cache_valid_time: 3600
- name: Installer les paquets du socle
ansible.builtin.apt:
name:
- ufw # pare-feu de l'hôte
- unattended-upgrades # installation automatique des mises à jour de sécurité
- ca-certificates # certificats racine pour les téléchargements HTTPS
- curl # utilisé par d'autres rôles
state: present
- name: Activer les mises à jour de sécurité automatiques
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
owner: root
group: root
mode: "0644"
- name: Déposer la configuration SSH durcie
ansible.builtin.template:
src: sshd-nebuleuse.conf.j2
dest: /etc/ssh/sshd_config.d/10-nebuleuse.conf
owner: root
group: root
mode: "0644"
# Vérifie la syntaxe avant d'écraser le fichier : une erreur ici couperait l'accès.
validate: /usr/sbin/sshd -t -f %s
notify: Redémarrer ssh
# Le pare-feu : l'ordre compte. La règle SSH est créée AVANT l'activation
# du pare-feu, sinon la connexion en cours serait coupée.
- name: Refuser par défaut tout trafic entrant
community.general.ufw:
default: deny
direction: incoming
- name: Autoriser SSH depuis le réseau d'administration uniquement
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ base_admin_network_cidr }}"
- name: Autoriser le web (HTTP et HTTPS) depuis Internet
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop: "{{ base_public_tcp_ports }}"
- name: Activer ufw
community.general.ufw:
state: enabled
# roles/base-hardening/handlers/main.yml
# Un handler ne s'exécute qu'à la fin des tâches, et seulement si une tâche l'a notifié.
- name: Redémarrer ssh
ansible.builtin.service:
name: ssh # le service s'appelle "ssh" sous Debian, pas "sshd"
state: restarted
Le rôle docker-engine
Variables
# roles/docker-engine/defaults/main.yml
# Nom du réseau partagé entre Traefik et les applications (voir group_vars/all).
docker_engine_proxy_network: "{{ docker_proxy_network }}"
# Taille maximale d'un fichier de journal de conteneur, et nombre de fichiers conservés.
# Sans limite, les journaux d'un conteneur bavard finissent par remplir le disque.
docker_engine_log_max_size: "10m"
docker_engine_log_max_files: "3"
Configuration du démon
# roles/docker-engine/templates/daemon.json.j2
{
"log-driver": "json-file",
"log-opts": {
"max-size": "{{ docker_engine_log_max_size }}",
"max-file": "{{ docker_engine_log_max_files }}"
},
"live-restore": true
}
Le paramètre live-restore garde les conteneurs en marche pendant un redémarrage ou une mise à jour du démon Docker : une mise à jour du moteur ne coupe donc pas les services des clients.
Tâches
# roles/docker-engine/tasks/main.yml
- name: Créer le dossier des clés de dépôt
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: "0755"
- name: Récupérer la clé de signature du dépôt Docker
ansible.builtin.get_url:
url: https://download.docker.com/linux/debian/gpg
dest: /etc/apt/keyrings/docker.asc
mode: "0644"
- name: Déclarer le dépôt officiel Docker
ansible.builtin.apt_repository:
# "signed-by" lie ce dépôt à sa clé : les paquets sont vérifiés avec elle.
# ansible_distribution_release vaut "bookworm" sur Debian 12.
repo: >-
deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc]
https://download.docker.com/linux/debian {{ ansible_distribution_release }} stable
filename: docker
state: present
- name: Installer Docker Engine et le plugin Compose
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin # fournit la commande "docker compose"
- python3-docker # bibliothèque Python requise par les modules community.docker
state: present
update_cache: true
- name: Déposer la configuration du démon Docker
ansible.builtin.template:
src: daemon.json.j2
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
notify: Redémarrer docker
- name: Activer et démarrer Docker
ansible.builtin.service:
name: docker
state: started
enabled: true
# Les handlers sont lancés ici plutôt qu'en fin de rôle : le réseau ci-dessous
# doit être créé sur un démon déjà configuré.
- name: Appliquer les changements de configuration du démon
ansible.builtin.meta: flush_handlers
- name: Créer le réseau partagé entre Traefik et les applications
community.docker.docker_network:
name: "{{ docker_engine_proxy_network }}"
driver: bridge
# roles/docker-engine/handlers/main.yml
- name: Redémarrer docker
ansible.builtin.service:
name: docker
state: restarted
Note — L'utilisateur deploy n'est volontairement pas ajouté au groupe docker. Appartenir à ce groupe revient à avoir les droits root sur la machine. Ansible utilise déjà sudo, ce qui laisse une trace de chaque commande.
Docker et le pare-feu : ce qu'il faut savoir
Docker gère lui-même ses règles de pare-feu. Quand un conteneur publie un port (comme Traefik sur 80 et 443), Docker l'ouvre directement, sans passer par ufw. Ainsi, une règle ufw ne bloque pas un port publié par un conteneur.
Ce comportement est connu, et il explique deux choix du projet : 1. Seul Traefik publie des ports vers l'extérieur. Les autres conteneurs restent sur le réseau proxy, sans port publié. 2. Les blocages dynamiques (Fail2ban) ne passent pas par ufw mais par la chaîne DOCKER-USER. Docker crée cette chaîne et ne la modifie jamais ; les règles qu'on y place sont donc appliquées avant celles de Docker.
Le playbook du socle
Pour tester dès maintenant, un site.yml minimal suffit. Il sera complété à la page de déploiement.
# site.yml
- name: Socle Nébuleuse
hosts: clients
become: true # les tâches s'exécutent en root via sudo
roles:
- { role: base-hardening, tags: [base] }
- { role: docker-engine, tags: [docker] }
# Simulation : montre ce qui changerait, sans rien modifier.
ansible-playbook site.yml --limit client-alpha --check --diff
# Déploiement réel.
ansible-playbook site.yml --limit client-alpha
Vérification
# Depuis le serveur Ansible : un second passage ne doit rien modifier.
# La ligne finale doit afficher "changed=0".
ansible-playbook site.yml --limit client-alpha
# Sur la VM : valeurs SSH effectives.
sudo sshd -T | grep -E 'listenaddress|passwordauthentication|permitrootlogin|allowusers'
# Sur la VM : règles du pare-feu.
sudo ufw status verbose
# Sur la VM : version de Docker, pilote de journaux et live-restore.
docker version --format '{{.Server.Version}}'
docker info --format 'journaux={{.LoggingDriver}} live-restore={{.LiveRestoreEnabled}}'
# Sur la VM : le réseau partagé existe et la chaîne DOCKER-USER est disponible.
docker network ls --filter name=proxy
sudo iptables -L DOCKER-USER -n
Résultat attendu pour sshd -T : listenaddress 10.99.0.21:22, passwordauthentication no, permitrootlogin no, allowusers deploy.
Pièges
- Se couper l'accès en modifiant SSH : une erreur de
ListenAddressou une règle de pare-feu oubliée coupe la connexion. La validationsshd -tet l'ordre « règle avant activation » limitent le risque. Avant de fermer une session de test, ouvrir une seconde connexion pour vérifier que l'accès fonctionne encore. ansible_hostqui n'est pas l'adresse d'administration : SSH n'écouterait pas là où Ansible se connecte. L'inventaire de la première page respecte la règle ; une machine ajoutée avec son adresse publique fausserait le rôle.aptbloqué par un autre processus : juste après le démarrage d'une VM neuve,unattended-upgradespeut tenir le verrouapt. Ansible patiente un moment, puis échoue avec « Could not get lock ». Relancer le playbook suffit.- Pare-feu
ufwqui « ne bloque rien » pour un conteneur : voir la section précédente. Ce n'est pas un défaut de configuration. docker_networken erreur sur la bibliothèque Python : le paquetpython3-dockerdoit être installé avant toute tâchecommunity.docker. Il fait partie de la liste des paquets du rôle.- Journaux de conteneurs qui remplissent le disque : sans
max-size, aucune rotation n'est faite. La configurationdaemon.jsonne s'applique qu'aux conteneurs créés après elle ; les conteneurs existants doivent être recréés.
Page suivante : Bastion Teleport : agent et enrôlement.