diff --git a/.gitignore b/.gitignore index 26c4977..501b8ee 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,6 @@ target/ *.tfstate.* *.tfvars !*.tfvars.example -.terraform.lock.hcl # Secrets *.pem diff --git a/README.md b/README.md new file mode 100644 index 0000000..3708339 --- /dev/null +++ b/README.md @@ -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). diff --git a/ansible/README.md b/ansible/README.md new file mode 100644 index 0000000..a35e943 --- /dev/null +++ b/ansible/README.md @@ -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). diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000..c7f2ae9 --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,4 @@ +[defaults] +inventory = inventory/hosts.yml +host_key_checking = True +retry_files_enabled = False diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml new file mode 100644 index 0000000..afa04f9 --- /dev/null +++ b/ansible/group_vars/all.yml @@ -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" diff --git a/ansible/inventory/hosts.yml b/ansible/inventory/hosts.yml new file mode 100644 index 0000000..27549ae --- /dev/null +++ b/ansible/inventory/hosts.yml @@ -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 diff --git a/ansible/requirements.yml b/ansible/requirements.yml new file mode 100644 index 0000000..afc836d --- /dev/null +++ b/ansible/requirements.yml @@ -0,0 +1,3 @@ +--- +collections: + - name: community.general diff --git a/ansible/roles/base/tasks/main.yml b/ansible/roles/base/tasks/main.yml new file mode 100644 index 0000000..ec4ed7e --- /dev/null +++ b/ansible/roles/base/tasks/main.yml @@ -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" diff --git a/ansible/roles/wireguard/handlers/main.yml b/ansible/roles/wireguard/handlers/main.yml new file mode 100644 index 0000000..7a1dc38 --- /dev/null +++ b/ansible/roles/wireguard/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: redémarrer wireguard + ansible.builtin.systemd: + name: wg-quick@wg0 + state: restarted diff --git a/ansible/roles/wireguard/tasks/main.yml b/ansible/roles/wireguard/tasks/main.yml new file mode 100644 index 0000000..e7e554b --- /dev/null +++ b/ansible/roles/wireguard/tasks/main.yml @@ -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() }}" diff --git a/ansible/roles/wireguard/templates/wg0.conf.j2 b/ansible/roles/wireguard/templates/wg0.conf.j2 new file mode 100644 index 0000000..ef29560 --- /dev/null +++ b/ansible/roles/wireguard/templates/wg0.conf.j2 @@ -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 %} diff --git a/ansible/site.yml b/ansible/site.yml new file mode 100644 index 0000000..ed9ea6f --- /dev/null +++ b/ansible/site.yml @@ -0,0 +1,7 @@ +--- +- name: Socle Tripoli Cloud + hosts: tripoli_cloud + become: true + roles: + - base + - wireguard diff --git a/tofu/.terraform.lock.hcl b/tofu/.terraform.lock.hcl new file mode 100644 index 0000000..7747c4e --- /dev/null +++ b/tofu/.terraform.lock.hcl @@ -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", + ] +} diff --git a/tofu/README.md b/tofu/README.md new file mode 100644 index 0000000..4abe807 --- /dev/null +++ b/tofu/README.md @@ -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. diff --git a/tofu/main.tf b/tofu/main.tf new file mode 100644 index 0000000..e12ce71 --- /dev/null +++ b/tofu/main.tf @@ -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 +} diff --git a/tofu/outputs.tf b/tofu/outputs.tf new file mode 100644 index 0000000..bf054f4 --- /dev/null +++ b/tofu/outputs.tf @@ -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 +} diff --git a/tofu/versions.tf b/tofu/versions.tf new file mode 100644 index 0000000..5158f12 --- /dev/null +++ b/tofu/versions.tf @@ -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" +}