first
Terraform - plan / plan (push) Failing after 6s

This commit is contained in:
hcornet committed 2026-09-16 10:16:01 +02:00
1 parent d92805786b
commit ee0a1766f4
21 files changed
+1503 -418

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
* text=auto eol=lf
*.sh text eol=lf
*.tf text eol=lf
*.tftpl text eol=lf
*.yml text eol=lf
+112
View File
@@ -0,0 +1,112 @@
name: Terraform - deployer la VM Ubuntu
on:
workflow_dispatch:
inputs:
vm:
description: "Nom de la VM, tel que le fichier vms/<nom>.tfvars"
type: string
required: true
default: ubuntu-01
action:
description: "plan pour vérifier, apply pour créer ou mettre à jour la VM"
type: choice
required: true
default: plan
options:
- plan
- apply
concurrency:
group: terraform-proxmox-ubuntu-24-04
cancel-in-progress: false
env:
TF_VERSION: "1.9.8"
TF_IN_AUTOMATION: "true"
TF_INPUT: "false"
OPENBAO_ROLE_ID: ${{ secrets.OPENBAO_ROLE_ID }}
OPENBAO_SECRET_ID: ${{ secrets.OPENBAO_SECRET_ID }}
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Récupérer le dépôt
uses: actions/checkout@v4
- name: Installer les outils
run: |
set -euo pipefail
if ! command -v jq >/dev/null || ! command -v unzip >/dev/null; then
apt-get update -qq
apt-get install -y -qq curl jq unzip
fi
curl -fsSL -o /tmp/terraform.zip \
"https://releases.hashicorp.com/terraform/${TF_VERSION}/terraform_${TF_VERSION}_linux_amd64.zip"
unzip -oq /tmp/terraform.zip -d /usr/local/bin
terraform version
- name: Charger les secrets depuis OpenBao
run: ./scripts/openbao-env.sh
- name: Initialiser le backend S3
run: terraform init
- name: Sélectionner l'espace de travail de la VM
run: |
set -euo pipefail
test -f "vms/${{ inputs.vm }}.tfvars" || {
echo "vms/${{ inputs.vm }}.tfvars n'existe pas. VM déclarées :"
ls vms/*.tfvars | xargs -n1 basename
exit 1
}
terraform workspace select -or-create "${{ inputs.vm }}"
- name: Calculer le plan
run: terraform plan -var-file="vms/${{ inputs.vm }}.tfvars" -lock-timeout=120s -out=tfplan
- name: Résumé du plan
run: terraform show -no-color tfplan | tail -n 40
- name: Appliquer le plan
if: ${{ inputs.action == 'apply' }}
run: terraform apply -lock-timeout=120s -auto-approve tfplan
- name: Afficher le point d'entrée
if: ${{ inputs.action == 'apply' }}
run: |
echo "VM : $(terraform output -raw vm_fqdn) ($(terraform output -raw vm_ipv4_address))"
echo "SSH : $(terraform output -raw ssh_command)"
echo "cloud-init tourne encore 3 à 6 minutes après la fin de ce job."
- name: Extraire le mot de passe root
if: ${{ inputs.action == 'apply' }}
run: |
set -euo pipefail
umask 077
terraform output -raw root_password > mot-de-passe-root.txt
echo "Mot de passe root publie en artefact : a deposer dans Vaultwarden, puis supprimer l artefact."
- name: Publier le mot de passe root
if: ${{ inputs.action == 'apply' }}
continue-on-error: true
uses: actions/upload-artifact@v3
with:
name: mot-de-passe-root-${{ inputs.vm }}
path: mot-de-passe-root.txt
retention-days: 1
- name: Effacer la copie locale du mot de passe
if: ${{ always() }}
run: rm -f mot-de-passe-root.txt
- name: Publier l'inventaire Ansible
if: ${{ inputs.action == 'apply' }}
continue-on-error: true
uses: actions/upload-artifact@v3
with:
name: inventaire-ansible-${{ inputs.vm }}
path: generated/
retention-days: 7
+70
View File
@@ -0,0 +1,70 @@
name: Terraform - detruire la VM Ubuntu
on:
workflow_dispatch:
inputs:
vm:
description: "Nom de la VM à détruire, tel que le fichier vms/<nom>.tfvars"
type: string
required: true
default: ubuntu-01
confirmation:
description: "Saisir DETRUIRE pour confirmer la suppression de la VM et de son disque"
type: string
required: true
concurrency:
group: terraform-proxmox-ubuntu-24-04
cancel-in-progress: false
env:
TF_VERSION: "1.9.8"
TF_IN_AUTOMATION: "true"
TF_INPUT: "false"
OPENBAO_ROLE_ID: ${{ secrets.OPENBAO_ROLE_ID }}
OPENBAO_SECRET_ID: ${{ secrets.OPENBAO_SECRET_ID }}
jobs:
destroy:
runs-on: ubuntu-latest
steps:
- name: Vérifier la confirmation
run: |
if [ "${{ inputs.confirmation }}" != "DETRUIRE" ]; then
echo "Confirmation absente ou incorrecte : rien n'a été détruit."
exit 1
fi
- name: Récupérer le dépôt
uses: actions/checkout@v4
- name: Installer les outils
run: |
set -euo pipefail
if ! command -v jq >/dev/null || ! command -v unzip >/dev/null; then
apt-get update -qq
apt-get install -y -qq curl jq unzip
fi
curl -fsSL -o /tmp/terraform.zip \
"https://releases.hashicorp.com/terraform/${TF_VERSION}/terraform_${TF_VERSION}_linux_amd64.zip"
unzip -oq /tmp/terraform.zip -d /usr/local/bin
terraform version
- name: Charger les secrets depuis OpenBao
run: ./scripts/openbao-env.sh
- name: Initialiser le backend S3
run: terraform init
- name: Sélectionner l'espace de travail de la VM
run: |
set -euo pipefail
test -f "vms/${{ inputs.vm }}.tfvars" || {
echo "vms/${{ inputs.vm }}.tfvars n'existe pas."
exit 1
}
terraform workspace select "${{ inputs.vm }}"
- name: Détruire
run: terraform destroy -var-file="vms/${{ inputs.vm }}.tfvars" -lock-timeout=120s -auto-approve
+63
View File
@@ -0,0 +1,63 @@
name: Terraform - plan
on:
pull_request:
branches: [main]
push:
branches: [main]
# Un seul job Terraform à la fois : l'état est partagé, autant ne pas se battre
# avec le verrou du backend.
concurrency:
group: terraform-proxmox-ubuntu-24-04
cancel-in-progress: false
env:
TF_VERSION: "1.9.8"
TF_IN_AUTOMATION: "true"
TF_INPUT: "false"
OPENBAO_ROLE_ID: ${{ secrets.OPENBAO_ROLE_ID }}
OPENBAO_SECRET_ID: ${{ secrets.OPENBAO_SECRET_ID }}
jobs:
plan:
runs-on: ubuntu-latest
steps:
- name: Récupérer le dépôt
uses: actions/checkout@v4
- name: Installer les outils
run: |
set -euo pipefail
if ! command -v jq >/dev/null || ! command -v unzip >/dev/null; then
apt-get update -qq
apt-get install -y -qq curl jq unzip
fi
curl -fsSL -o /tmp/terraform.zip \
"https://releases.hashicorp.com/terraform/${TF_VERSION}/terraform_${TF_VERSION}_linux_amd64.zip"
unzip -oq /tmp/terraform.zip -d /usr/local/bin
terraform version
- name: Charger les secrets depuis OpenBao
run: ./scripts/openbao-env.sh
- name: Vérifier le formatage
run: terraform fmt -check -recursive -diff
- name: Initialiser le backend S3
run: terraform init
- name: Valider la configuration
run: terraform validate
- name: Calculer le plan de chaque VM déclarée
run: |
set -euo pipefail
for fichier in vms/*.tfvars; do
vm=$(basename "$fichier" .tfvars)
echo "::group::$vm"
terraform workspace select -or-create "$vm"
terraform plan -var-file="$fichier" -lock-timeout=120s
echo "::endgroup::"
done
+16 -416
View File
@@ -1,416 +1,16 @@
# ---> VisualStudio
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
.terraform/
*.tfstate
*.tfstate.*
crash.log
terraform.tfvars
*.auto.tfvars
generated/
override.tf
override.tf.json
backend.hcl
tfplan
# Seules les cles publiques sont versionnees
key/*
!key/*.pub
!key/README.md
+123
View File
@@ -0,0 +1,123 @@
# CI/CD — dépôt `terraform-proxmox-serveur-ubuntu-24-04`
La chaîne s'arrête à la création du serveur. Ansible n'est pas déclenché par la CI :
il est joué depuis Semaphore, indépendamment.
## Répartition
| Étape | Outil | Déclencheur |
|---|---|---|
| `fmt`, `validate`, `plan` | Gitea Actions | pull request et push sur `main` |
| `apply` | Gitea Actions | lancement manuel, `action=apply` |
| `destroy` | Gitea Actions | lancement manuel, confirmation littérale `DETRUIRE` |
| Configuration applicative | Semaphore | manuel, après la fin de cloud-init |
## Préalables
### 1. OpenBao
Une clé est à **ajouter au secret existant** `secret/proxmox` : le dépôt du snippet
cloud-init passe par SSH, le mot de passe du nœud est donc nécessaire.
```bash
bao kv patch secret/proxmox ssh_password='<mot de passe root du nœud pve>'
```
Le secret doit finir par contenir : `api_url`, `api_token`, `node_name`, `ssh_password`.
L'adresse du nœud est déduite de `api_url`, il n'y a rien d'autre à saisir.
Politique et AppRole dédiés au projet, en lecture seule sur les deux secrets utiles :
```bash
bao policy write terraform-proxmox-ubuntu - <<'POLICY'
path "secret/data/proxmox" { capabilities = ["read"] }
path "secret/metadata/proxmox" { capabilities = ["read"] }
path "secret/data/aws" { capabilities = ["read"] }
path "secret/metadata/aws" { capabilities = ["read"] }
POLICY
bao write auth/approle/role/terraform-proxmox-ubuntu \
token_policies=terraform-proxmox-ubuntu token_ttl=1h token_max_ttl=4h
bao read auth/approle/role/terraform-proxmox-ubuntu/role-id
bao write -f auth/approle/role/terraform-proxmox-ubuntu/secret-id
```
Si vous préférez ne pas multiplier les rôles, le couple `role_id` / `secret_id` déjà
utilisé par le dépôt Debian convient tel quel : la politique y donne accès aux deux
mêmes secrets, `secret/proxmox` et `secret/aws`.
### 2. Secrets et variables Gitea
Gitea ne détient que de quoi ouvrir OpenBao.
**Secrets du dépôt** (Paramètres → Actions → Secrets) :
| Nom | Valeur |
|---|---|
| `OPENBAO_ROLE_ID` | sortie de `role-id` |
| `OPENBAO_SECRET_ID` | sortie de `secret-id` |
Aucune variable de dépôt n'est nécessaire. L'adresse d'OpenBao a une valeur par défaut
dans `scripts/openbao-env.sh`, et le backend S3 est écrit en dur dans `state.tf` : ni le
bucket ni la région ne sont des secrets.
La clé d'état, `backend/terraform-proxmox-serveur-ubuntu-24-04/terraform.tfstate`, est
**différente** de celle des autres projets. Deux dépôts partageant la même clé
écraseraient mutuellement leur état.
### 3. Bit d'exécution du script
Le piège déjà rencontré sur `proxmox-create-vm` : le transfert depuis Windows perd le
bit d'exécution, et le job échoue sur un « Permission denied ».
```powershell
git update-index --chmod=+x scripts/openbao-env.sh
git commit -m "Rendre openbao-env.sh executable"
```
Le fichier `.gitattributes` livré fige les fins de ligne en LF, pour la même raison.
### 4. Clé publique
Chaque fichier `vms/<nom>.tfvars` est versionné et référence les clés publiques : vérifier
le `ssh-ed25519 AAAA...` d'exemple avant le premier `apply`. Sans quoi la VM sera
créée avec une clé inutilisable et il faudra la recréer — cloud-init ne rejoue rien.
## Fonctionnement des workflows
`scripts/openbao-env.sh` s'authentifie en AppRole, lit les deux secrets et pousse dans
l'environnement du job : `TF_VAR_pve_endpoint`, `TF_VAR_pve_api_token`,
`TF_VAR_pve_node`, `TF_VAR_pve_node_address`, `TF_VAR_pve_ssh_password`,
`AWS_ACCESS_KEY_ID` et `AWS_SECRET_ACCESS_KEY`. Chaque valeur sensible est passée en
`::add-mask::` avant d'être exportée : elle apparaît en `***` si un outil l'affiche.
Le script s'arrête avec un message explicite quand une clé manque dans OpenBao, plutôt
que de laisser Terraform échouer sur une authentification vide.
`deploy.yml` calcule toujours un plan, et ne l'applique que si `action=apply` — c'est
le plan enregistré qui est appliqué, pas un second calcul. À la fin, l'inventaire
Ansible est publié en artefact (`inventaire-ansible`), utile surtout comme trace :
l'adresse étant fixe, Semaphore peut se contenter d'un inventaire statique.
## Côté Semaphore
La configuration Semaphore est décrite dans le dépôt qu'elle concerne :
`ansible/CICD.md`.
## Enchaînement d'un test complet
1. `deploy.yml` avec `action=apply` — environ 3 minutes.
2. Attendre la fin de cloud-init : `ssh [email protected] 'cloud-init status --wait'`.
3. Lancer le modèle Semaphore `site.yml` — 10 à 20 minutes.
4. Déposer les exports d'Enclume, relancer le modèle `--tags clapi`.
5. Pour repartir propre : `destroy.yml` avec la confirmation `DETRUIRE`, puis reprendre
à l'étape 1.Aucune variable de dépôt n'est nécessaire : l'adresse d'OpenBao a une valeur par
défaut dans `scripts/openbao-env.sh`, et le backend S3 est écrit en dur dans
`state.tf`. Ni le bucket ni la région ne sont des secrets.
La clé d'état, `backend/terraform-proxmox-serveur-ubuntu-24-04/terraform.tfstate`, est
**différente** de celle des autres projets : deux dépôts partageant la même clé
écraseraient mutuellement leur état.
+76
View File
@@ -0,0 +1,76 @@
# Modèle de comptes
La commande `sudo` est **inutilisable** sur cette machine. L'escalade de privilèges
passe par `su -`.
| Compte | Accès SSH | Privilèges | Usage |
|---|---|---|---|
| `hcornet` | clé | aucun | administration humaine, puis `su -` |
| `invite` | clé | aucun | consultation |
| `ansible` | clé | aucun | compte de service, sans droits |
| `root` | **clé uniquement** | tous | c'est par là qu'Ansible travaille |
`PermitRootLogin prohibit-password` : root est joignable par clé, jamais par mot de
passe. Le mot de passe root ne sert donc qu'en local — `su -` ou la console Proxmox.
`AllowUsers` est généré à partir des comptes réellement créés : un compte absent ne
peut pas ouvrir de session, même s'il existe sur le système.
## Pourquoi sudo n'est pas purgé, contrairement à Debian
Sur Ubuntu, `sudo` est une **dépendance d'`ubuntu-minimal`**. Le purger entraînerait la
suppression du métapaquet, et l'`autoremove` qui suit pourrait alors emporter
`netplan.io`, `udev` ou `vim-tiny` — de quoi rendre la machine inutilisable.
Le cloud-init applique donc :
```bash
dpkg-statoverride --update --add root root 0000 /usr/bin/sudo
rm -f /etc/sudoers.d/90-cloud-init-users
```
Le binaire perd son bit setuid et tous ses droits : plus personne ne peut l'exécuter.
Le paquet reste installé, la dépendance est satisfaite, et `dpkg-statoverride` réapplique
ces droits à chaque mise à jour du paquet — c'est précisément sa raison d'être.
Vérification :
```bash
ls -l /usr/bin/sudo # doit afficher ----------
sudo -n true # doit échouer
```
Pour revenir en arrière, si le besoin apparaissait :
```bash
dpkg-statoverride --remove /usr/bin/sudo
chmod 4755 /usr/bin/sudo
```
## Où vivent les clés
Un fichier `.pub` par identité dans `key/`, référencé par les fichiers `vms/*.tfvars`.
Ce sont les mêmes clés que dans le dépôt Debian : les recopier évite d'en gérer un
second jeu.
Un compte dont `ssh_key_files` est vide n'est pas créé. Deux garde-fous avant création :
un fichier référencé mais absent fait échouer le plan, et une valeur qui n'est pas une
clé publique est refusée par une précondition.
`root_ssh_key_files` doit contenir la clé que Semaphore présente, sans quoi le playbook
se verra refuser la connexion après chaque recréation.
## Le mot de passe root
Généré par Terraform à la création, en 24 caractères, et publié en artefact
`mot-de-passe-root-<vm>` par le workflow `deploy`, avec une rétention d'un jour :
le télécharger, le déposer dans Vaultwarden, puis supprimer l'artefact.
Il est conservé en clair dans l'état Terraform sur S3 — contrepartie de la génération
automatique.
## Côté Ansible
La machine n'ayant pas de `sudo` utilisable, un playbook doit s'y connecter
directement en root : `remote_user: root` et `become: false`, comme dans le dépôt
`ansible-centreon`. L'inventaire généré par Terraform pointe déjà sur `root`.
+64 -2
View File
@@ -1,3 +1,65 @@
# terraform-proxmox-serveur-ubuntu
# terraform-proxmox-serveur-ubuntu-24-04
Installation d'un serveur Ubuntu sur proxmox
Crée sur le nœud Proxmox `pve` une VM Ubuntu Server 24.04 LTS réduite au strict
nécessaire, durcie par cloud-init, et prête à être reprise par Ansible.
Jumeau du dépôt `terraform-proxmox-serveur-debian-12` : même structure, mêmes
conventions, même CI/CD. Seules la distribution et les quelques points où elle diffère
de Debian ont changé.
## Ce que le code met en place
| Point | Choix retenu |
|---|---|
| Système | Ubuntu Server 24.04 LTS *cloudimg* (image officielle Canonical) |
| Comptes | comptes unix sans mot de passe, sudo NOPASSWD **absent**, clé publique uniquement |
| root | joignable en SSH par clé seulement, mot de passe réservé à `su -` et à la console |
| sudo | neutralisé par `dpkg-statoverride` (voir [COMPTES.md](COMPTES.md)) |
| Services exposés | **22/tcp et ICMP uniquement**, et seulement depuis `ssh_allowed_cidrs` |
| Pare-feu | nftables, politique `drop` en entrée et en transit, avec `/etc/nftables.d/` pour les ajouts d'Ansible |
| Mises à jour | `unattended-upgrades` activé |
| Agent | `qemu-guest-agent` |
Le compte `ubuntu` livré par l'image n'est jamais créé, et son fichier sudoers est
retiré.
## Une VM par fichier
Un fichier `vms/<nom>.tfvars` et un espace de travail Terraform par machine, chacune
avec son propre état. Tout est décrit dans **[VMS.md](VMS.md)**.
Attention aux collisions avec le dépôt Debian, qui gère déjà la VM 120 en `10.0.5.20` :
`vm_id`, `vm_hostname` et `vm_ipv4_address` doivent être uniques sur l'infrastructure,
et rien ne le vérifie automatiquement.
## Prérequis
Identiques à ceux du dépôt Debian, et déjà en place sur votre nœud :
1. jeton d'API Proxmox avec `VM.*`, `Datastore.*`, `Sys.Audit` et `SDN.Use` ;
2. accès SSH au nœud, le dépôt du snippet cloud-init ne passant pas par l'API ;
3. contenu `snippets` **et** `import` activés sur le stockage `local` ;
4. adresse IP exclue de la plage DHCP d'OPNsense.
La CI et les secrets sont décrits dans **[CICD.md](CICD.md)**.
## Après l'apply
`terraform apply` produit la VM démarrée, qui exécute cloud-init pendant **3 à 6
minutes**, et `generated/<nom>-inventory.yml` pour Ansible.
```bash
ssh [email protected] "cloud-init status --wait; command -v sudo || echo 'sudo absent'; sudo -n true 2>&1 | head -1"
```
`sudo` existe toujours en tant que fichier, mais son exécution est refusée à tout le
monde. C'est la seconde commande qui le vérifie.
## Points d'attention
- **cloud-init ne rejoue rien.** Modifier le template après création ne change pas une
VM existante. Pour repartir propre : `destroy` puis `apply`.
- **Extension de l'image.** Canonical publie son image cloud en `.img` alors qu'il
s'agit d'un qcow2. Le contenu `import` de Proxmox n'accepte pas cette extension : le
fichier est donc renommé en `.qcow2` au téléchargement, sans conversion.
- **Clés d'hôte.** Chaque recréation en change : prévoir `ssh-keygen -R <ip>`.
+63
View File
@@ -0,0 +1,63 @@
# Créer plusieurs VM avec ce dépôt
Une VM = un fichier `vms/<nom>.tfvars` + un espace de travail Terraform du même nom.
Chaque VM a donc **son propre état** : en détruire une ne touche pas aux autres.
```
vms/
└── ubuntu-01.tfvars VM 122, Ubuntu 24.04, 10.0.5.22
```
Dans le bucket S3, les états se rangent sous
`env:/<nom>/backend/terraform-proxmox-serveur-ubuntu-24-04/…`, automatiquement.
## Ajouter une VM
Copier `vms/ubuntu-01.tfvars`, le renommer, puis adapter. Trois valeurs doivent être
uniques **sur l'ensemble de l'infrastructure**, y compris vis-à-vis du dépôt Debian, et
rien ne le vérifie :
- `vm_id`
- `vm_hostname`
- `vm_ipv4_address`
Répartition actuelle : le dépôt Debian occupe 120 / `10.0.5.20` et 121 / `10.0.5.21`,
celui-ci démarre à 122 / `10.0.5.22`.
Un `git push` déclenche le plan de **toutes** les VM déclarées : c'est le filet qui
attrape une erreur de syntaxe ou un fichier de clé manquant avant tout déploiement.
Pour créer la VM, lancer `deploy` en saisissant son nom dans le champ `vm`. Le workflow
refuse un nom sans fichier correspondant et liste les VM connues.
## Changer de version d'Ubuntu
Deux lignes, dans le fichier de la VM concernée :
```hcl
ubuntu_image_url = "https://cloud-images.ubuntu.com/releases/26.04/release/ubuntu-26.04-server-cloudimg-amd64.img"
ubuntu_image_file_name = "ubuntu-26.04-server-cloudimg-amd64.qcow2"
```
Le nom de fichier doit garder une extension `.qcow2`, `.raw` ou `.vmdk` : Canonical
publie en `.img`, que le contenu `import` de Proxmox refuse, alors que le format réel
est bien un qcow2. Le renommage suffit, il n'y a pas de conversion.
Les versions LTS disponibles au moment de l'écriture : **24.04** (Noble Numbat) et
**26.04** (Resolute Raccoon). Remplacer `24.04` par la version voulue dans les deux
lignes, le numéro apparaissant à la fois dans le chemin et dans le nom du fichier.
## Commandes hors CI
```bash
docker run --rm -it -v "$PWD":/work -w /work \
-e TF_VAR_pve_api_token -e TF_VAR_pve_ssh_password \
-e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY \
hashicorp/terraform:latest workspace list
```
Chaque commande doit ensuite recevoir `-var-file=vms/<nom>.tfvars`, avec l'espace de
travail correspondant sélectionné. Se tromper de couple espace/fichier est la seule
vraie façon de se faire mal : Terraform comparerait l'état d'une VM à la définition
d'une autre et proposerait de tout recréer. C'est pour cela que les workflows dérivent
toujours les deux du même nom.
+79
View File
@@ -0,0 +1,79 @@
locals {
fqdn = "${var.vm_hostname}.${var.dns_domain}"
# Les clés sont lues dans les fichiers du répertoire key/. Un fichier
# référencé mais absent fait échouer le plan, ce qui est le comportement
# voulu : mieux vaut un plan rouge qu'une VM inaccessible.
# Un compte sans aucun fichier de clé n'est pas créé.
comptes = [
for compte in var.local_accounts : {
name = compte.name
gecos = compte.gecos
ssh_keys = [for fichier in compte.ssh_key_files : trimspace(file("${path.module}/${fichier}"))]
}
if length(compte.ssh_key_files) > 0
]
cles_root = [for fichier in var.root_ssh_key_files : trimspace(file("${path.module}/${fichier}"))]
toutes_les_cles = concat(local.cles_root, flatten([for compte in local.comptes : compte.ssh_keys]))
# root d'abord, puis les comptes réellement créés : c'est la liste exhaustive
# des comptes autorisés à ouvrir une session SSH.
allow_users = join(" ", concat(["root"], [for compte in local.comptes : compte.name]))
}
# Mot de passe root : sert à « su - » et à la console Proxmox, jamais en SSH.
# Il est généré une fois puis conservé dans l'état Terraform, à récupérer par
# le workflow de déploiement pour être déposé dans Vaultwarden.
resource "random_password" "root" {
length = var.root_password_length
special = true
min_upper = 2
min_lower = 2
min_numeric = 2
min_special = 2
override_special = "!@#%^&*()-_=+[]{}:,.?"
}
# Le fichier est déposé dans le contenu "snippets" du stockage, via SSH.
# Attention : cloud-init ne rejoue la configuration qu'au premier démarrage.
# Modifier ce snippet après coup n'a donc aucun effet sur une VM déjà installée.
resource "proxmox_virtual_environment_file" "user_data" {
content_type = "snippets"
datastore_id = var.snippet_datastore_id
node_name = var.pve_node
source_raw {
file_name = "${var.vm_hostname}-user-data.yaml"
data = templatefile("${path.module}/templates/user-data.yaml.tftpl", {
hostname = var.vm_hostname
fqdn = local.fqdn
timezone = var.timezone
locale = var.locale
comptes = local.comptes
cles_root = local.cles_root
allow_users = local.allow_users
admin_cidrs = var.ssh_allowed_cidrs
root_password_hash = random_password.root.bcrypt_hash
})
}
lifecycle {
# Garde-fou : une valeur qui n'est pas une clé publique produirait une VM
# sur laquelle personne ne pourrait se connecter, et cloud-init ne rejoue rien.
precondition {
condition = alltrue([
for cle in local.toutes_les_cles :
can(regex("^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp[0-9]+|sk-ssh-ed25519) AAAA", cle))
])
error_message = "Un des fichiers de key/ ne contient pas une clé publique valide."
}
precondition {
condition = length(local.comptes) > 0
error_message = "Aucun compte n'a de clé : la machine serait inaccessible."
}
}
}
+19
View File
@@ -0,0 +1,19 @@
# Clés publiques
Un fichier `.pub` par identité, repris par les fichiers `vms/*.tfvars`. Seules des clés
**publiques** ont leur place ici : elles sont versionnées, ce qui est leur usage normal.
| Fichier | Utilisé par |
|---|---|
| `hcornet.pub` | compte `hcornet` et accès root |
| `ansible.pub` | compte `ansible` et accès root de Semaphore |
| `invite.pub` | compte `invite`, à déposer pour activer le compte |
Ce sont les mêmes clés que dans le dépôt Debian : recopier les fichiers depuis
`terraform-proxmox-serveur-debian-12/key/` évite d'en gérer un second jeu.
Un compte dont la liste `ssh_key_files` est vide n'est pas créé du tout.
Un fichier référencé mais absent fait échouer le plan, et une valeur qui n'est pas une
clé publique est refusée par une précondition — avant toute création de VM, puisque
cloud-init ne rejoue rien.
+1
View File
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFDIXkfS5dM3po9Jz7NKgd3t1H4vqh0/cVoxrAP42ivd thedj@ZEU
+124
View File
@@ -0,0 +1,124 @@
resource "proxmox_virtual_environment_download_file" "ubuntu_cloud_image" {
content_type = "import"
datastore_id = var.image_datastore_id
node_name = var.pve_node
url = var.ubuntu_image_url
file_name = var.ubuntu_image_file_name
checksum = var.ubuntu_image_checksum
checksum_algorithm = var.ubuntu_image_checksum == null ? null : var.ubuntu_image_checksum_algorithm
# L'image n'est pas retéléchargée à chaque apply, mais un fichier déjà
# présent et non géré par Terraform est repris plutôt que de faire échouer le plan.
overwrite = false
overwrite_unmanaged = true
upload_timeout = 1800
}
resource "proxmox_virtual_environment_vm" "serveur" {
name = var.vm_hostname
description = "Serveur Ubuntu - gere par Terraform, ne pas modifier a la main"
tags = ["terraform", "ubuntu2404"]
node_name = var.pve_node
vm_id = var.vm_id
on_boot = true
started = true
stop_on_destroy = true
# i440fx et non q35 : sur q35, Proxmox presente le lecteur cloud-init (ide2)
# via AHCI, que le noyau de l'image genericcloud ne detecte pas. ds-identify
# ne trouve alors aucune source NoCloud et desactive cloud-init en silence.
machine = "pc"
bios = "seabios"
scsi_hardware = "virtio-scsi-single"
boot_order = ["scsi0"]
operating_system {
type = "l26"
}
agent {
enabled = true
trim = true
}
cpu {
cores = var.vm_cores
type = "host"
}
memory {
dedicated = var.vm_memory
}
disk {
datastore_id = var.vm_datastore_id
import_from = proxmox_virtual_environment_download_file.ubuntu_cloud_image.id
interface = "scsi0"
size = var.vm_disk_size
file_format = "qcow2"
discard = "on"
iothread = true
ssd = true
}
network_device {
bridge = var.vm_bridge
model = "virtio"
}
# Console série : permet un « qm terminal 120 » depuis le nœud si le réseau
# de la VM est cassé, sans ouvrir de service supplémentaire.
serial_device {}
initialization {
datastore_id = var.vm_datastore_id
interface = "ide2"
dns {
domain = var.dns_domain
servers = var.dns_servers
}
ip_config {
ipv4 {
address = "${var.vm_ipv4_address}/${var.vm_ipv4_prefix}"
gateway = var.vm_ipv4_gateway
}
}
user_data_file_id = proxmox_virtual_environment_file.user_data.id
}
lifecycle {
# Proxmox renvoie un type de machine versionné (pc-i440fx-11.0+pve2) et
# réordonne le boot : sans cela, chaque plan proposerait une modification.
ignore_changes = [
machine,
boot_order,
]
}
}
resource "local_file" "ansible_inventory" {
filename = coalesce(var.ansible_inventory_path, "generated/${var.vm_hostname}-inventory.yml")
file_permission = "0644"
content = yamlencode({
all = {
children = {
(var.ansible_group) = {
hosts = {
(var.vm_hostname) = {
ansible_host = var.vm_ipv4_address
ansible_user = var.ansible_remote_user
}
}
}
}
}
})
depends_on = [proxmox_virtual_environment_vm.serveur]
}
+35
View File
@@ -0,0 +1,35 @@
output "vm_id" {
description = "VMID de la machine créée."
value = proxmox_virtual_environment_vm.serveur.vm_id
}
output "vm_ipv4_address" {
description = "Adresse IPv4 fixe de la machine."
value = var.vm_ipv4_address
}
output "vm_fqdn" {
description = "Nom pleinement qualifié de la machine."
value = local.fqdn
}
output "comptes" {
description = "Comptes ouverts sur la machine, hors root."
value = [for compte in local.comptes : compte.name]
}
output "ssh_command" {
description = "Commande de connexion une fois cloud-init terminé."
value = "ssh ${local.comptes[0].name}@${var.vm_ipv4_address}"
}
output "root_password" {
description = "Mot de passe root, à déposer dans Vaultwarden puis à ne plus consulter."
value = random_password.root.result
sensitive = true
}
output "ansible_inventory_file" {
description = "Inventaire généré, à recopier dans le dépôt Ansible."
value = local_file.ansible_inventory.filename
}
+18
View File
@@ -0,0 +1,18 @@
provider "proxmox" {
endpoint = var.pve_endpoint
api_token = var.pve_api_token
insecure = var.pve_insecure
# Le dépôt d'un snippet cloud-init passe obligatoirement par SSH :
# l'API Proxmox ne permet pas de téléverser dans le contenu "snippets".
ssh {
agent = false
username = var.pve_ssh_username
password = var.pve_ssh_password
node {
name = var.pve_node
address = var.pve_node_address
}
}
}
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
#
# Lit les secrets nécessaires au déploiement dans OpenBao et les pousse dans
# l'environnement du job Gitea. Aucune valeur n'est affichée : chaque secret est
# masqué dans le journal avant d'être exporté.
#
# Attendus en entrée (variables d'environnement) :
# OPENBAO_ADDR variable de dépôt Gitea, ex. https://openbao.tips-of-mine.com
# OPENBAO_ROLE_ID secret Gitea
# OPENBAO_SECRET_ID secret Gitea
#
set -euo pipefail
# Adresse d'OpenBao : valeur par défaut du homelab, surchargeable par
# une variable d'environnement si besoin.
OPENBAO_ADDR="${OPENBAO_ADDR:-https://openbao.tips-of-mine.com}"
: "${OPENBAO_ROLE_ID:?variable OPENBAO_ROLE_ID absente}"
: "${OPENBAO_SECRET_ID:?variable OPENBAO_SECRET_ID absente}"
: "${GITHUB_ENV:?ce script est prévu pour un job Gitea Actions}"
masquer() { printf '::add-mask::%s\n' "$1"; }
exporter() { printf '%s=%s\n' "$1" "$2" >>"${GITHUB_ENV}"; }
exiger() {
# $1 = valeur, $2 = description
if [ -z "$1" ] || [ "$1" = "null" ]; then
echo "Secret manquant dans OpenBao : $2" >&2
exit 1
fi
}
# --- Authentification AppRole -------------------------------------------------
BAO_TOKEN="$(
curl -sS --fail-with-body \
--request POST \
--data "{\"role_id\":\"${OPENBAO_ROLE_ID}\",\"secret_id\":\"${OPENBAO_SECRET_ID}\"}" \
"${OPENBAO_ADDR}/v1/auth/approle/login" | jq -r '.auth.client_token // empty'
)"
exiger "${BAO_TOKEN}" "jeton AppRole (vérifier role_id / secret_id)"
masquer "${BAO_TOKEN}"
lire_secret() {
curl -sS --fail-with-body \
-H "X-Vault-Token: ${BAO_TOKEN}" \
"${OPENBAO_ADDR}/v1/secret/data/$1" | jq -r '.data.data'
}
# --- Accès Proxmox ------------------------------------------------------------
PROXMOX="$(lire_secret proxmox)"
API_URL="$(jq -r '.api_url // empty' <<<"${PROXMOX}")"
API_TOKEN="$(jq -r '.api_token // empty' <<<"${PROXMOX}")"
NODE_NAME="$(jq -r '.node_name // empty' <<<"${PROXMOX}")"
SSH_PASSWORD="$(jq -r '.ssh_password // empty' <<<"${PROXMOX}")"
exiger "${API_URL}" "secret/proxmox → api_url"
exiger "${API_TOKEN}" "secret/proxmox → api_token"
exiger "${NODE_NAME}" "secret/proxmox → node_name"
exiger "${SSH_PASSWORD}" "secret/proxmox → ssh_password (à ajouter, le dépôt du snippet cloud-init passe par SSH)"
# Adresse du nœud déduite de l'URL de l'API, pour ne pas dupliquer l'information.
NODE_ADDRESS="$(sed -E 's#^https?://([^:/]+).*#\1#' <<<"${API_URL}")"
exiger "${NODE_ADDRESS}" "adresse du nœud déduite de api_url"
masquer "${API_TOKEN}"
masquer "${SSH_PASSWORD}"
exporter TF_VAR_pve_endpoint "${API_URL}"
exporter TF_VAR_pve_api_token "${API_TOKEN}"
exporter TF_VAR_pve_node "${NODE_NAME}"
exporter TF_VAR_pve_node_address "${NODE_ADDRESS}"
exporter TF_VAR_pve_ssh_password "${SSH_PASSWORD}"
# --- Backend S3 ---------------------------------------------------------------
AWS="$(lire_secret aws)"
AWS_KEY="$(jq -r '.AWS_ACCESS_KEY_ID // empty' <<<"${AWS}")"
AWS_SECRET="$(jq -r '.AWS_SECRET_ACCESS_KEY // empty' <<<"${AWS}")"
exiger "${AWS_KEY}" "secret/aws → AWS_ACCESS_KEY_ID"
exiger "${AWS_SECRET}" "secret/aws → AWS_SECRET_ACCESS_KEY"
masquer "${AWS_KEY}"
masquer "${AWS_SECRET}"
exporter AWS_ACCESS_KEY_ID "${AWS_KEY}"
exporter AWS_SECRET_ACCESS_KEY "${AWS_SECRET}"
echo "Secrets chargés depuis OpenBao : Proxmox (4 valeurs), backend S3 (2 valeurs)."
+13
View File
@@ -0,0 +1,13 @@
terraform {
# Convention du homelab : un bucket unique, une cle par projet.
# Les identifiants AWS ne sont pas ici : la CI les exporte en variables
# d'environnement apres les avoir lus dans OpenBao (secret/aws).
backend "s3" {
bucket = "tips-of-mine-terraform-bucket-backend"
key = "backend/terraform-proxmox-serveur-ubuntu-24-04/terraform.tfstate"
region = "eu-north-1"
# Pas de verrouillage, comme sur les autres projets : aucune table DynamoDB
# en place. Le garde-fou est le "concurrency" des workflows.
}
}
+185
View File
@@ -0,0 +1,185 @@
#cloud-config
# Généré par Terraform — toute modification manuelle sera écrasée.
# Rappel : ce fichier n'est appliqué qu'au tout premier démarrage de la VM.
hostname: ${hostname}
fqdn: ${fqdn}
prefer_fqdn_over_hostname: true
manage_etc_hosts: true
timezone: ${timezone}
# La clé "users" remplace la liste par défaut : le compte "ubuntu" livré avec
# l'image cloud n'est donc jamais créé, ni son fichier sudoers.
#
# Aucun compte n'appartient au groupe sudo, et la commande sudo est neutralisée
# plus bas. Pour administrer : ouvrir une session avec son compte, puis
# « su - » avec le mot de passe root.
users:
%{ for compte in comptes ~}
- name: ${compte.name}
gecos: "${compte.gecos}"
shell: /bin/bash
lock_passwd: true
ssh_authorized_keys:
%{ for cle in compte.ssh_keys ~}
- "${cle}"
%{ endfor ~}
%{ endfor ~}
# Root garde un mot de passe, utilisable par « su - » et sur la console Proxmox.
# En SSH, seule la clé est acceptée (prohibit-password plus bas).
disable_root: false
ssh_pwauth: false
ssh_deletekeys: true
ssh_genkeytypes: [ed25519, rsa]
chpasswd:
expire: false
users:
- name: root
password: "${root_password_hash}"
type: hash
package_update: true
package_upgrade: true
# Strict minimum : agent invité, pare-feu, mises à jour de sécurité,
# et les prérequis d'Ansible et de l'installateur Centreon.
packages:
- qemu-guest-agent
- nftables
- unattended-upgrades
- apt-listchanges
- locales
- python3-apt
- ca-certificates
- curl
- wget
- gnupg
- lsb-release
- apt-transport-https
write_files:
- path: /root/.ssh/authorized_keys
owner: root:root
permissions: "0600"
content: |
%{ for cle in cles_root ~}
${cle}
%{ endfor ~}
- path: /etc/ssh/sshd_config.d/99-durcissement.conf
owner: root:root
permissions: "0600"
content: |
# Durcissement SSH — cloud-init
# root n'est joignable que par clé : le mot de passe root ne sert qu'en
# local, par « su - » ou sur la console.
PermitRootLogin prohibit-password
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no
PubkeyAuthentication yes
AuthenticationMethods publickey
AllowUsers ${allow_users}
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
- path: /etc/nftables.conf
owner: root:root
permissions: "0640"
content: |
#!/usr/sbin/nft -f
# Jeu de règles principal — cloud-init.
# En entrée, seuls SSH et ICMP sont ouverts. Les ports applicatifs
# (web, SNMP, gorgone) sont ajoutés par Ansible dans /etc/nftables.d/.
flush ruleset
table inet filter {
set reseaux_admin {
type ipv4_addr
flags interval
elements = { ${join(", ", admin_cidrs)} }
}
chain input {
type filter hook input priority filter; policy drop;
iif "lo" accept
ct state established,related accept
ct state invalid drop
ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } accept
ip6 nexthdr ipv6-icmp accept
tcp dport 22 ip saddr @reseaux_admin ct state new accept
include "/etc/nftables.d/*.nft"
counter comment "paquets rejetes en entree"
}
chain forward {
type filter hook forward priority filter; policy drop;
}
chain output {
type filter hook output priority filter; policy accept;
}
}
- path: /etc/nftables.d/00-placeholder.nft
owner: root:root
permissions: "0640"
content: |
# Fragments de règles ajoutés par Ansible. Ne rien mettre ici.
- path: /etc/apt/apt.conf.d/20auto-upgrades
owner: root:root
permissions: "0644"
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
- path: /etc/sysctl.d/99-durcissement.conf
owner: root:root
permissions: "0644"
content: |
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.tcp_syncookies = 1
kernel.dmesg_restrict = 1
kernel.kptr_restrict = 2
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
runcmd:
- chmod 700 /root/.ssh
- sed -i "s|^# *${locale} UTF-8|${locale} UTF-8|" /etc/locale.gen
- locale-gen
- update-locale LANG=${locale}
- sysctl --system > /dev/null
- systemctl enable --now qemu-guest-agent
- systemctl enable --now nftables
- systemctl enable --now unattended-upgrades
- systemctl restart ssh.socket || systemctl restart ssh
# sudo n'est pas purgé : il est une dépendance d'ubuntu-minimal, et le
# supprimer entraînerait le métapaquet puis, à l'autoremove, netplan.io, udev
# ou vim-tiny. On lui retire donc setuid et tous ses droits : la commande
# devient inutilisable par quiconque, le paquet reste en place et la
# dépendance est satisfaite. dpkg-statoverride survit aux mises à jour.
- dpkg-statoverride --update --add root root 0000 /usr/bin/sudo
- rm -f /etc/sudoers.d/90-cloud-init-users
- apt-get -y autoremove --purge
- apt-get -y clean
final_message: "cloud-init termine apres $UPTIME s - ${fqdn} est pret pour Ansible"
+282
View File
@@ -0,0 +1,282 @@
############################################
# Accès au nœud Proxmox
############################################
variable "pve_endpoint" {
description = "URL de l'API Proxmox (ex. https://10.0.4.5:8006/)."
type = string
}
variable "pve_api_token" {
description = "Jeton d'API au format user@realm!tokenid=uuid. À fournir par TF_VAR_pve_api_token."
type = string
sensitive = true
}
variable "pve_insecure" {
description = "Ne pas vérifier le certificat TLS de l'API (nécessaire si l'endpoint est appelé par IP)."
type = bool
default = true
}
variable "pve_node" {
description = "Nom du nœud Proxmox cible."
type = string
default = "pve"
}
variable "pve_node_address" {
description = "Adresse IP du nœud, utilisée pour la connexion SSH de dépôt du snippet."
type = string
default = "10.0.4.5"
}
variable "pve_ssh_username" {
description = "Compte SSH sur le nœud Proxmox (doit pouvoir écrire dans le répertoire des snippets)."
type = string
default = "root"
}
variable "pve_ssh_password" {
description = "Mot de passe SSH du nœud. À fournir par TF_VAR_pve_ssh_password."
type = string
sensitive = true
}
############################################
# Image Ubuntu
############################################
variable "ubuntu_image_url" {
description = "URL de l'image cloud Ubuntu Server. L'image est un qcow2, malgré son extension .img."
type = string
default = "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img"
}
variable "ubuntu_image_file_name" {
description = <<-EOT
Nom du fichier stocké sur le nœud. Le contenu import de Proxmox n'accepte que
les extensions .qcow2, .raw et .vmdk : l'image Ubuntu, publiée en .img, est
donc renommée ici. Son format réel ne change pas, c'est bien un qcow2.
EOT
type = string
default = "ubuntu-24.04-server-cloudimg-amd64.qcow2"
}
variable "ubuntu_image_checksum" {
description = "Empreinte de l'image (facultatif). Laisser à null pour ne pas vérifier."
type = string
default = null
sensitive = false
}
variable "ubuntu_image_checksum_algorithm" {
description = "Algorithme de l'empreinte fournie (sha256, sha512...)."
type = string
default = "sha512"
}
variable "image_datastore_id" {
description = "Stockage recevant l'image téléchargée (doit accepter le contenu iso)."
type = string
default = "local"
}
variable "snippet_datastore_id" {
description = "Stockage recevant le snippet cloud-init (doit accepter le contenu snippets)."
type = string
default = "local"
}
############################################
# Définition de la VM
############################################
variable "vm_id" {
description = "VMID de la machine."
type = number
default = 120
}
variable "vm_hostname" {
description = "Nom court de la machine (nom de VM et hostname système)."
type = string
default = "ubuntu-01"
}
variable "vm_datastore_id" {
description = "Stockage du disque système et du disque cloud-init."
type = string
default = "local"
}
variable "vm_cores" {
description = <<-EOT
Nombre de cœurs. Les besoins applicatifs se vérifient côté Ansible, pas ici :
ce dépôt crée des VM pour n'importe quel usage.
EOT
type = number
default = 2
validation {
condition = var.vm_cores >= 1
error_message = "Il faut au moins un cœur."
}
}
variable "vm_memory" {
description = "Mémoire en Mio. Ubuntu démarre à partir de 512 Mio ; les besoins applicatifs se vérifient côté Ansible."
type = number
default = 4096
validation {
condition = var.vm_memory >= 512
error_message = "En dessous de 512 Mio, l'image cloud Ubuntu ne démarre pas correctement."
}
}
variable "vm_disk_size" {
description = "Taille du disque système en Gio."
type = number
default = 40
}
variable "vm_bridge" {
description = "Bridge de rattachement de la carte réseau."
type = string
default = "vmbr3"
}
############################################
# Réseau
############################################
variable "vm_ipv4_address" {
description = "Adresse IPv4 fixe de la VM."
type = string
default = "10.0.5.20"
}
variable "vm_ipv4_prefix" {
description = "Longueur du préfixe IPv4."
type = number
default = 24
}
variable "vm_ipv4_gateway" {
description = "Passerelle IPv4 (interface LAN d'OPNsense)."
type = string
default = "10.0.5.1"
}
variable "dns_servers" {
description = "Serveurs DNS poussés par cloud-init."
type = list(string)
default = ["10.0.5.1"]
}
variable "dns_domain" {
description = "Domaine de recherche et suffixe du FQDN."
type = string
default = "tips-of-mine.local"
}
############################################
# Système et accès
############################################
variable "timezone" {
description = "Fuseau horaire du système, appliqué par cloud-init."
type = string
default = "Europe/Paris"
}
variable "locale" {
description = "Locale générée sur le système."
type = string
default = "fr_FR.UTF-8"
}
variable "local_accounts" {
description = <<-EOT
Comptes unix créés par cloud-init. Aucun n'a de privilège : la commande sudo
est retirée du système. Pour administrer, on ouvre une session avec son
compte puis on bascule par « su - » avec le mot de passe root.
ssh_key_files référence des fichiers du répertoire key/, relatifs à la racine
du dépôt. Un compte sans aucun fichier de clé n'est pas créé : c'est le moyen
de déclarer un compte à l'avance sans l'activer.
EOT
type = list(object({
name = string
gecos = optional(string, "")
ssh_key_files = list(string)
}))
validation {
condition = length(var.local_accounts) > 0
error_message = "Au moins un compte est nécessaire pour accéder à la machine."
}
validation {
condition = !contains([for c in var.local_accounts : c.name], "root")
error_message = "Le compte root se configure par root_ssh_key_files, pas par local_accounts."
}
}
variable "root_ssh_key_files" {
description = <<-EOT
Fichiers de clés publiques autorisées à ouvrir une session SSH directement en
root, sudo n'existant pas sur la machine. C'est par là qu'Ansible obtient ses
droits, et c'est aussi la voie d'accès root d'un administrateur qui y dépose
sa clé.
EOT
type = list(string)
validation {
condition = length(var.root_ssh_key_files) > 0
error_message = "Sans clé root, aucune configuration ne serait possible sur la machine."
}
}
variable "ansible_group" {
description = "Groupe d'inventaire Ansible dans lequel ranger la machine."
type = string
default = "serveurs"
}
variable "ansible_remote_user" {
description = "Compte utilisé par Ansible dans l'inventaire généré."
type = string
default = "root"
}
variable "root_password_length" {
description = "Longueur du mot de passe root généré. Il sert à « su - » et à la console Proxmox."
type = number
default = 24
}
variable "ssh_allowed_cidrs" {
description = "Réseaux autorisés à joindre le port 22 (pare-feu local nftables)."
type = list(string)
default = ["10.0.4.0/24", "10.0.5.0/24"]
}
############################################
# Sortie pour Ansible
############################################
variable "ansible_inventory_path" {
description = <<-EOT
Chemin du fichier d'inventaire généré à la fin de l'apply. Laissé à null,
il vaut generated/<nom de la VM>-inventory.yml : chaque VM a le sien.
EOT
type = string
default = null
}
+18
View File
@@ -0,0 +1,18 @@
terraform {
required_version = ">= 1.6.0"
required_providers {
proxmox = {
source = "bpg/proxmox"
version = ">= 0.69.0"
}
random = {
source = "hashicorp/random"
version = ">= 3.6.0"
}
local = {
source = "hashicorp/local"
version = ">= 2.4.0"
}
}
}
+50
View File
@@ -0,0 +1,50 @@
# Valeurs non sensibles, versionnées : c'est ce fichier que la CI passe en -var-file.
# Les secrets (jeton d'API, mot de passe SSH du nœud) arrivent par TF_VAR_*, depuis OpenBao.
#
# Trois valeurs doivent être uniques sur l'infrastructure, rien ne le vérifie
# automatiquement : vm_id, vm_hostname et vm_ipv4_address. Attention, le dépôt
# Debian gère déjà la VM 120 en 10.0.5.20.
vm_id = 122
vm_hostname = "ubuntu-01"
vm_bridge = "vmbr3"
vm_ipv4_address = "10.0.5.22"
vm_ipv4_gateway = "10.0.5.1"
dns_servers = ["10.0.5.1"]
dns_domain = "tips-of-mine.local"
vm_cores = 2
vm_memory = 2048
vm_disk_size = 20
ansible_group = "serveurs"
ssh_allowed_cidrs = ["10.0.4.0/24", "10.0.5.0/24"]
# Comptes de la machine. Aucun n'a de privilège : sudo est neutralisé.
# Pour administrer, ouvrir une session avec son compte puis « su - ».
# Les clés vivent dans key/, un fichier .pub par identité.
# Une liste ssh_key_files vide signifie : compte déclaré mais non créé.
local_accounts = [
{
name = "hcornet"
gecos = "Hubert Cornet - administration"
ssh_key_files = ["key/hcornet.pub"]
},
{
name = "invite"
gecos = "Compte de consultation - aucun privilege"
ssh_key_files = []
},
{
name = "ansible"
gecos = "Compte de service - sans privilege"
ssh_key_files = ["key/ansible.pub"]
},
]
# Clés autorisées à ouvrir une session directement en root.
# key/ansible.pub est celle que Semaphore présente : sans elle ici, le playbook
# se voit refuser la connexion après chaque recréation de la VM.
root_ssh_key_files = ["key/hcornet.pub", "key/ansible.pub"]