Phase 1 : socle & sécurité (WireGuard, Ansible, OpenTofu)

- Reconnaissance : le serveur héberge déjà 6 autres projets derrière un
  Caddy partagé (80/443) ; tripoli-edge sera un service interne (décision
  prise avec l'utilisateur), pas un second frontal.
- WireGuard : canal admin Mac <-> serveur (10.100.0.0/24, port 58212/udp
  après avoir découvert que 51820 était filtré par le pare-feu Hetzner
  Cloud, distinct de ufw).
- Ansible : rôle base (confirme fail2ban/unattended-upgrades/ufw déjà en
  place, sans toucher aux règles des autres projets) + rôle wireguard
  (clé privée générée sur la cible, jamais stockée dans ce dépôt).
- OpenTofu : serveur Hetzner référencé en data source lecture seule
  (jamais recréé/détruit - serveur partagé et déjà en prod) ; DNS
  tripoli.cloud géré via le provider OVH.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-09-16 22:11:31 +02:00
parent 5dbd3b96f6
commit 2fa16083c3
17 changed files with 427 additions and 1 deletions

1
.gitignore vendored
View file

@ -16,7 +16,6 @@ target/
*.tfstate.* *.tfstate.*
*.tfvars *.tfvars
!*.tfvars.example !*.tfvars.example
.terraform.lock.hcl
# Secrets # Secrets
*.pem *.pem

41
README.md Normal file
View file

@ -0,0 +1,41 @@
# Tripoli Cloud
Cloud personnel (photos, sauvegardes, partage de fichiers) construit pour
démontrer des compétences SRE proches de la stack de Clever Cloud (Rust,
netlink, WireGuard, CI/CD). **Un seul serveur, pas un cluster** — ce README
reste honnête sur ce qui est réellement en place vs prévu.
## Serveur
Hetzner CPX22 (2 vCPU, 3.7 Go RAM, 75 Go disque), Ubuntu 26.04, `65.109.142.188`
/ `tripoli.cloud`. **Ce serveur héberge déjà six autres projets en
production** (Forgejo, lecheminvert.tech, check-my-irve, iot-alarm-system,
voltvision, monitoring Prometheus/Grafana) derrière un Caddy partagé qui
tient les ports 80/443. Décision assumée : le futur `tripoli-edge` (Rust)
sera un service **interne**, derrière ce Caddy — pas un second frontal en
concurrence sur 80/443. Reprendre ces ports casserait les sites existants.
Pas de virtualisation imbriquée (KVM/QEMU) sur ce VPS Hetzner Cloud — non
tenté, non simulé.
## État réel
| Composant | État |
|---|---|
| Prototype frontend (`app/`) | Fait — React/TS/Vite/Tailwind, maquette |
| Reconnaissance serveur (Phase 0) | Fait |
| WireGuard (canal admin Mac ↔ serveur) | Fait — `10.100.0.0/24`, port 58212/udp (51820 filtré par le pare-feu Hetzner Cloud, voir `ansible/group_vars/all.yml`) |
| Pare-feu / fail2ban / unattended-upgrades | Déjà en place avant ce projet ; confirmés/complétés par Ansible |
| SSH public | Toujours ouvert (choix explicite, pas encore restreint au VPN) |
| Ansible (rôle de base) | Fait — `ansible/` |
| OpenTofu (serveur + DNS) | Code écrit — `tofu/` ; serveur en donnée en lecture seule (jamais recréé/détruit), DNS géré via le provider OVH. `plan`/`apply` pas encore exécutés (nécessitent des identifiants OVH/Hetzner non fournis) |
| `tripoli-edge` (Rust, Phase 2) | Pas commencé |
| `tripoli-api` (Rust, Phase 3) | Pas commencé |
| `tripoli-netctl` (Rust, Phase 4) | Pas commencé |
| Observabilité dédiée (Phase 5) | Pas commencé (une stack Prometheus/Grafana existe déjà sur le serveur, partagée avec les autres projets) |
| Bird/BGP (Phase 6, bonus) | Pas commencé |
## Dépôt
Code poussé sur `forge.adam.belkacemi.me/adam/tripoli.cloud` (même
instance Forgejo que les autres projets hébergés sur ce serveur).

39
ansible/README.md Normal file
View file

@ -0,0 +1,39 @@
# Ansible — socle Tripoli Cloud
Un seul hôte (`tripoli_cloud`), pas d'inventaire dynamique : ce serait
simuler une flotte qui n'existe pas. Le serveur héberge aussi cinq autres
projets ; ce dépôt ne gère que ce qui concerne Tripoli Cloud (voir les
commentaires dans `roles/base/tasks/main.yml`).
## Lancer
```bash
ansible-galaxy collection install -r requirements.yml # une fois
ansible-playbook site.yml --diff
```
`ansible_host` pointe pour l'instant sur l'IP publique (`inventory/hosts.yml`)
car le SSH public reste ouvert par choix explicite. Une fois le SSH public
fermé (`ssh_restrict_to_wireguard: true` dans `group_vars/all.yml`), changer
`ansible_host` pour `10.100.0.1` et lancer les playbooks depuis une machine
dont le tunnel WireGuard est monté — sinon Ansible ne pourra plus joindre le
serveur.
## Secrets
Aucun secret dans ce dépôt, volontairement :
- La clé privée WireGuard du serveur est générée **sur le serveur** par le
rôle `wireguard` (`wg genkey`, `creates:` la rend idempotente) et n'en sort
jamais. Pas de vault, pas de fichier chiffré à gérer.
- Les clés publiques des pairs (Mac admin, etc.) ne sont pas sensibles :
elles vivent en clair dans `group_vars/all.yml`.
## `ssh_restrict_to_wireguard`
À `false` (valeur actuelle) : le rôle ne touche pas à la règle `22/tcp`
existante (ouverte à "Anywhere", posée manuellement avant Ansible).
À `true` : le rôle retire cette règle et n'autorise plus le 22/tcp que
depuis `10.100.0.0/24`. Vérifier que le tunnel fonctionne avant de basculer
ce flag — sinon risque de perdre l'accès root normal (recours : console de
secours Hetzner, indépendante du réseau).

4
ansible/ansible.cfg Normal file
View file

@ -0,0 +1,4 @@
[defaults]
inventory = inventory/hosts.yml
host_key_checking = True
retry_files_enabled = False

View file

@ -0,0 +1,18 @@
---
# WireGuard : canal d'administration, pas un VPN "route tout le trafic".
# Chaque pair n'a le droit de joindre que l'autre bout du tunnel (/32),
# jamais le reste du réseau du serveur.
wireguard_listen_port: 58212 # port non standard : 51820 (défaut) est filtré
# en amont par le pare-feu Hetzner Cloud sur ce projet ; voir README.
wireguard_server_address: "10.100.0.1/24"
wireguard_peers:
- name: mac-admin
public_key: "lG5XN+YazR4qgFH75kK41ouQGP00GBaygC1h85NrFmg="
allowed_ips: "10.100.0.2/32"
# Tant que false, le SSH public (22/tcp depuis Anywhere) reste ouvert.
# À ne passer à true qu'après avoir vérifié que le tunnel WireGuard
# fonctionne depuis la machine d'administration - sinon risque de perdre
# l'accès root normal (recours : console de secours Hetzner).
ssh_restrict_to_wireguard: false
wireguard_subnet: "10.100.0.0/24"

View file

@ -0,0 +1,13 @@
---
# Un seul serveur : pas de groupe de production/inventaire dynamique, ce
# serait simuler une flotte qui n'existe pas.
all:
hosts:
tripoli_cloud:
# IP publique pour l'instant : l'accès SSH public reste ouvert
# (décision explicite, pas encore restreint au tunnel WireGuard).
# Une fois le SSH public fermé, remplacer par 10.100.0.1 (IP WireGuard)
# et lancer les playbooks depuis une machine dont le tunnel est monté.
ansible_host: 65.109.142.188
ansible_user: root
ansible_ssh_private_key_file: ~/.ssh/hetzner

3
ansible/requirements.yml Normal file
View file

@ -0,0 +1,3 @@
---
collections:
- name: community.general

View file

@ -0,0 +1,53 @@
---
# Ce rôle ne fait que confirmer/maintenir un socle déjà en place sur ce
# serveur (partagé avec d'autres projets) : il n'active PAS ufw depuis
# zéro et ne touche à aucune règle existante en dehors de celles listées
# ici. Reproduire tout le pare-feu depuis ce rôle casserait les autres
# sites hébergés (Forgejo, iot-alarm, voltvision, etc.).
- name: S'assurer que fail2ban est installé et actif
ansible.builtin.apt:
name: fail2ban
state: present
- name: S'assurer que fail2ban est démarré et activé au boot
ansible.builtin.systemd:
name: fail2ban
state: started
enabled: true
- name: S'assurer que les mises à jour de sécurité automatiques sont actives
ansible.builtin.apt:
name: unattended-upgrades
state: present
- name: S'assurer que ufw est actif (deny incoming par défaut)
community.general.ufw:
state: enabled
policy: deny
direction: incoming
- name: Autoriser le port WireGuard (règle spécifique à Tripoli Cloud)
community.general.ufw:
rule: allow
port: "{{ wireguard_listen_port }}"
proto: udp
comment: "tripoli-cloud : WireGuard admin"
- name: SSH restreint au tunnel WireGuard uniquement
when: ssh_restrict_to_wireguard
block:
- name: Retirer la règle SSH publique (Anywhere)
community.general.ufw:
rule: allow
port: "22"
proto: tcp
delete: true
- name: Autoriser SSH uniquement depuis le sous-réseau WireGuard
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ wireguard_subnet }}"
comment: "tripoli-cloud : SSH admin via WireGuard uniquement"

View file

@ -0,0 +1,5 @@
---
- name: redémarrer wireguard
ansible.builtin.systemd:
name: wg-quick@wg0
state: restarted

View file

@ -0,0 +1,61 @@
---
# La clé privée du serveur est générée SUR le serveur et n'en sort
# jamais : pas de vault, pas de secret dans ce dépôt. `creates:` rend la
# génération idempotente - un ré-exécution ne régénère pas une clé déjà
# en place.
- name: Installer le paquet wireguard
ansible.builtin.apt:
name: wireguard
state: present
update_cache: true
- name: Restreindre les permissions du dossier /etc/wireguard
ansible.builtin.file:
path: /etc/wireguard
state: directory
mode: "0700"
- name: Générer la clé privée si absente
ansible.builtin.shell: umask 077 && wg genkey > /etc/wireguard/privatekey
args:
creates: /etc/wireguard/privatekey
- name: Restreindre les permissions de la clé privée
ansible.builtin.file:
path: /etc/wireguard/privatekey
mode: "0600"
- name: Dériver la clé publique correspondante
ansible.builtin.shell: wg pubkey < /etc/wireguard/privatekey > /etc/wireguard/publickey
args:
creates: /etc/wireguard/publickey
- name: Lire la clé privée (jamais affichée dans les logs)
ansible.builtin.slurp:
path: /etc/wireguard/privatekey
register: wg_private_key_raw
no_log: true
- name: Déployer wg0.conf
ansible.builtin.template:
src: wg0.conf.j2
dest: /etc/wireguard/wg0.conf
mode: "0600"
no_log: true
notify: redémarrer wireguard
- name: Activer et démarrer wg-quick@wg0
ansible.builtin.systemd:
name: wg-quick@wg0
state: started
enabled: true
- name: Lire la clé publique du serveur (information non sensible)
ansible.builtin.slurp:
path: /etc/wireguard/publickey
register: wg_public_key_raw
- name: Afficher la clé publique du serveur
ansible.builtin.debug:
msg: "Clé publique WireGuard du serveur : {{ (wg_public_key_raw.content | b64decode).strip() }}"

View file

@ -0,0 +1,12 @@
[Interface]
Address = {{ wireguard_server_address }}
ListenPort = {{ wireguard_listen_port }}
PrivateKey = {{ (wg_private_key_raw.content | b64decode).strip() }}
{% for peer in wireguard_peers %}
[Peer]
# {{ peer.name }}
PublicKey = {{ peer.public_key }}
AllowedIPs = {{ peer.allowed_ips }}
{% endfor %}

7
ansible/site.yml Normal file
View file

@ -0,0 +1,7 @@
---
- name: Socle Tripoli Cloud
hosts: tripoli_cloud
become: true
roles:
- base
- wireguard

70
tofu/.terraform.lock.hcl generated Normal file
View file

@ -0,0 +1,70 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/hetznercloud/hcloud" {
version = "1.69.0"
constraints = "~> 1.48"
hashes = [
"h1:/pWxd5Osefkg7cjjoocgUGsgV+ogMNR5Bj8NSUDLXUU=",
"h1:3LoUpRjqu3O/vSVTdHPQSMXvGSAA9g4O3emY8qhQPpw=",
"h1:3eLWWMWbF/q/UYAvhFbRfL6RwAs9cACPvi0N4W+vi14=",
"h1:H9WTbWGVkHW9oixJWfNOD9E6TdDVRwmVjFiHDqfe6hk=",
"h1:HGJANuVqrAGQiSN/m6DODhf05D7rSugUpriG2bPhBuM=",
"h1:MLxpeLlRL97wDLqL+WNpb7L9Vk+g8aUHLjXspFPR+tg=",
"h1:PWhmd5g+OPBPdwQoFkG+KFmppYSR6ujPof+zwCjfMPI=",
"h1:XuuF9gGyXrmeINI2/SdgfoaJntzP231hsM+MSbaQ5QM=",
"h1:e4gDEHjE7p660CTFwnkWS3ibVNV54nT7e1TliOJ0cqw=",
"h1:owcgdkfmWRbaGgiIl2HY5AFQdFENckbPcn91NJpr1r4=",
"h1:sRkgVfXIDHNIyGR/kOVjIa45/sxH2JzCHNzDQlKgdF4=",
"h1:xxSPOhL3DApttfBxP8GeH91tf1ywGw56NeNqpkD2P2k=",
"h1:z7i3QamSF7nU+9dj+5+g7JTRwtos+kAfkwQpZjbx+/Q=",
"zh:023c0580c46b48a1dce8aeb5832ec9896ce53ee019a2b296d854873f92aad7d9",
"zh:02fa14eebb1c48b52b9acb33fb987425513537b70a50dac5639aecfe62c4b66f",
"zh:0f534ee078e5f0f3c21e99cc020c3a9be565fceed4936d88b978f7b7d4b981fc",
"zh:42dcf6859d097179c30cbe346931be731548a51ef7c73d08cd4af69a044bd60f",
"zh:6d319ee6c9e64437634f3e682196dab24025ea91a7332b40972279e9c6e67f64",
"zh:71a1dc59a47ab1b4127696e8047b54bb4bc8f92b8994ecc13509eaeb7e13a729",
"zh:781310b68c74dede2e225096d7ada9a0b4b9252b6cafa4dc1ff064dd9ea4c817",
"zh:7c4e0411e6e10ca6bd49c8e699c7c61cd9750d32d46ee5472d01faa30fbd222f",
"zh:c4c70587a7632aea65d6b1bb0bbe8da2145a7acb6d16a7dd0e0dbbde6195c8ca",
"zh:caaab2e9bb7cf8bb478b143bc0156c40729d720d3e2c77cf021e515fbeca3a6a",
"zh:cbd1c2040111b595cfa5daa201166560b4988eb0227dd6be17e8ae72a5ff1df0",
"zh:eba595371b98a66c1bf64dfb9f9256e6dc365e990e7202c50b6977fb3d24f822",
"zh:fd477dca1d5b0bc7f41cce6b9f3e3250b58597cc178a70fa3136717343ffeede",
]
}
provider "registry.opentofu.org/ovh/ovh" {
version = "1.8.0"
constraints = "~> 1.1"
hashes = [
"h1:Cw+yHbVdi3SErNVKDfjkT7uJrkrz1iDoLKbtUYcD/vY=",
"h1:Emge4OtI8D3utNeWm9ZGp1dXJZGNM4XkfucpeECH/OM=",
"h1:EnUJhaLpRyNo9jyFbqNysVuB41aUHU2fPyDZviNyjQw=",
"h1:JiQ+u8Weik9cagC9zwQzgg50dSQCWIVSOcm6cBb/GLI=",
"h1:L70RPaCt9ynhgDA+ysLpg9XGUspDe/FV/i0bZwSDSQc=",
"h1:OY1R8TWQKdAa4/IlAFIhkz1mrjdCU4h7yjkdcaG3EVY=",
"h1:PxCb7XQsQSM+F7zL2uvQUU3c/GlHUX3achf8lZFE4GQ=",
"h1:SL4jA6hX+8gQmzogggo/T928Atu2uWxTSfwij5swnKg=",
"h1:UMNAYIM6tuCeexI0uNVvlY1BY38xELgDGR7mrl18gHk=",
"h1:gQAf5ZrxwGhPgEO8MYeExH05YNZaIwLGDPY7GUq89rs=",
"h1:giGZL9GqvPoLmwSSJei6xvM11NkPtRyYH4GvEH7PaRA=",
"h1:uforg5SSBOVHu0bHCVuKJhqv6TqI1q2oLiWid1WJDB0=",
"h1:yCfHzFrXA4RFEQUEpbfzWzeT0sa05yHz1U36I2EaYtQ=",
"h1:zq2m7JCCjXoIYoYJrSyD4Sn7D6bQErm05cbw28xRhdE=",
"zh:13de915a1de2f2d52daf3843be04b73b8c28102b4798b6519226ceba6de462a1",
"zh:229a6b42faaac23855d5b894a3d1116aa4bfe959870bf52820eba645dd84d240",
"zh:37c04f19df0462ae399d63da2a20efe16c8234302ef6ef7999f4ab9b17f82c84",
"zh:4145c35b7b06a3557c7d06694b4608356abb6c368b5ce94dea407b548c181213",
"zh:6848937828ec8f38fad2776abdfbcffc3cd353fbfb5885a2c1987775781e522b",
"zh:6d071a2ddcaddb318452b46782ae2f239acb1c83953248521858b59ff9651c98",
"zh:825ac2271e7f532d40e4633380fbe6ea7efca733b1e25ed01ed02e10bbb6e2bc",
"zh:9d24c56db0ced569615f84462d362a984d930cf21bbdbe69cf029cd89b9b179a",
"zh:a87773b85c2c732f8d03b65058cb595ec78a7fd5ad29ea61e69d4d3141675c40",
"zh:be454013f64a42f24cb1d7e14090dd03257fc387f0fff0e2992db77b47c125cf",
"zh:d4613672416be037a929e92d4c32e4d40eb5a7db4e2b67c689be6a00d91a7f46",
"zh:e435a038d9dc53bd3249c6f155311ea0bcbbba753e65236b2b5b630ae5917eb0",
"zh:ea0d3097edfbfd3e55140a04d570436fd62f2c18b7d4abbed3eb7d8a6781c875",
"zh:f88df12b81d7cbc320cf40dbca3cc45293cc74b8535362ed1a8360c487d744ee",
]
}

40
tofu/README.md Normal file
View file

@ -0,0 +1,40 @@
# OpenTofu — serveur & DNS en code
## Ce que ça gère
- `data "hcloud_server"` : référence **en lecture seule** le serveur
Hetzner existant (`tripoli-cloud`). Volontairement pas une `resource` :
ce serveur héberge six autres projets en production, et le gérer comme
une ressource Tofu ferait courir le risque qu'un `apply` mal aligné le
recrée ou le détruise. Cette IaC documente le serveur, elle ne le pilote
pas.
- `ovh_domain_zone_record` : les enregistrements A de `tripoli.cloud` et
`www.tripoli.cloud`, pointés sur l'IP du serveur ci-dessus. Seule la zone
`tripoli.cloud` est touchée — les autres domaines du même compte OVH
(lecheminvert.tech, etc.) ne sont pas dans le champ de ce projet.
## Identifiants
Aucun token dans ce dépôt ni dans un `.tfvars`. Les providers lisent
directement l'environnement :
```bash
export HCLOUD_TOKEN=...
export OVH_ENDPOINT=ovh-eu
export OVH_APPLICATION_KEY=...
export OVH_APPLICATION_SECRET=...
export OVH_CONSUMER_KEY=...
```
## Lancer
```bash
tofu init
tofu plan
tofu apply
```
`tofu init` et `tofu validate` ont déjà été exécutés dans cette session
(sans toucher à l'infra réelle) pour vérifier la syntaxe. `plan`/`apply`
nécessitent les identifiants ci-dessus, qui n'ont pas été demandés ni
extraits automatiquement.

30
tofu/main.tf Normal file
View file

@ -0,0 +1,30 @@
# Le serveur existe déjà et héberge six autres projets en production
# (Forgejo, lecheminvert, check-my-irve, iot-alarm-system, voltvision,
# monitoring). Le déclarer en `resource "hcloud_server"` ferait courir
# le risque qu'un `tofu apply` mal aligné avec l'état réel décide de le
# recréer/détruire - inacceptable sur un serveur partagé et déjà en
# prod pour d'autres projets. On le référence en lecture seule via une
# data source : cette IaC documente le serveur, elle ne le pilote pas.
data "hcloud_server" "tripoli_cloud" {
name = "tripoli-cloud"
}
# Le domaine tripoli.cloud est enregistré et sa zone DNS gérée chez OVH
# (registrar + DNS), pas chez Hetzner. Seule la zone tripoli.cloud est
# touchée ici - les autres domaines du même compte OVH (lecheminvert.tech,
# etc.) ne sont pas dans le champ de ce projet.
resource "ovh_domain_zone_record" "root" {
zone = "tripoli.cloud"
subdomain = ""
fieldtype = "A"
target = data.hcloud_server.tripoli_cloud.ipv4_address
ttl = 3600
}
resource "ovh_domain_zone_record" "www" {
zone = "tripoli.cloud"
subdomain = "www"
fieldtype = "A"
target = data.hcloud_server.tripoli_cloud.ipv4_address
ttl = 3600
}

8
tofu/outputs.tf Normal file
View file

@ -0,0 +1,8 @@
output "server_ipv4" {
value = data.hcloud_server.tripoli_cloud.ipv4_address
description = "IP publique du serveur (source de vérité : Hetzner Cloud, pas un état local)"
}
output "dns_root_record" {
value = ovh_domain_zone_record.root.target
}

23
tofu/versions.tf Normal file
View file

@ -0,0 +1,23 @@
terraform {
required_version = ">= 1.6.0"
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1.48"
}
ovh = {
source = "ovh/ovh"
version = "~> 1.1"
}
}
}
# Pas de bloc "provider" avec un token en dur : hcloud lit HCLOUD_TOKEN et
# ovh lit OVH_ENDPOINT / OVH_APPLICATION_KEY / OVH_APPLICATION_SECRET /
# OVH_CONSUMER_KEY dans l'environnement. Rien à mettre dans ce dépôt ni
# dans un fichier .tfvars.
provider "hcloud" {}
provider "ovh" {
endpoint = "ovh-eu"
}