Broken-by-Design/posts/proxmox-fcos2.md
2024-06-04 17:35:19 +02:00

711 lines
33 KiB
Markdown

---
title: "Expérimentation de Fedora CoreOS sur Proxmox : création de la machine virtuelle d'installation"
slug: proxmox-fcos2
authors: Florian Maury
description: "Un article discutant de la création d'une machine virtuelle permettant l'installation d'autres instances de Fedora CoreOS sur Proxmox et des défis rencontrés"
date: 2024-06-03T00:00:00+02:00
type: posts
draft: false
categories:
- devops
tags:
- proxmox
- fcos
- systemd
lang: fr
---
Ce billet est le deuxième d'une série traitant de la création d'une
infrastructure virtualisée à l'aide de Proxmox[^proxmox] pour la partie
hyperviseur et de Fedora CoreOS[^fcos] pour le système d'exploitation des
machines virtuelles invitées (*guests*). L'infrastructure codifiée
(*Infrastructure as Code*) est réalisée avec OpenTofu[^opentofu] (Hashicorp
Terraform ayant rejoint le côté obscur de la Force).
[^proxmox]: https://www.proxmox.com/en/
[^fcos]: https://fedoraproject.org/coreos/
[^opentofu]: https://opentofu.org/
Dans le premier billet[^firstblog], nous avons évoqué les raisons qui ont mené
au choix de créer une machine virtuelle d'installation. Dans ce billet, nous
allons voir une solution technique preuve de concept satisfaisant ce besoin. Le
code source est publié en licence ouverte, afin que la communauté puisse en
bénéficier [^sourcecode].
[^firstblog]: https://www.broken-by-design.fr/posts/proxmox-fcos1/
[^sourcecode]: https://git.broken-by-design.fr/fmaury/iac
L'objectif est ici d'installer une machine virtuelle (sur Proxmox) qui permettra
d'en installer d'autres. La distribution Fedora CoreOS est utilisée pour ses
atouts de stabilité et de sécurité intrinsèques (et détaillés dans le premier
billet).
Sur cette distribution, nous allons déployer un serveur DHCP qui permettra le
démarrage par le réseau des futures machines de l'infrastructure. Ce serveur
DHCP fournira un script iPXE[^ipxe], une version moderne et en source ouverte de
PXE (Preboot eXecution Environment), ainsi que des options DHCP distinctes en
fonction de la machine à installer. Ces options permettront notamment de fournir
une adresse réticulaire (URL) de type HTTP, afin de récupérer un fichier de
configuration Ignition spécifique à chaque machine à installer. Ce fichier
Ignition permettra la personnalisation de cette machine à partir de l'image
Fedora Core OS générique.
Le serveur HTTP est le second service de cette machine virtuelle d'installation.
Il permet la publication des configurations Ignition, mais aussi du script iPXE
à exécuter, et de l'image de Fedora CoreOS à utiliser pour l'installation.
Le dernier service de cette machine d'installation est un serveur de fichiers
sur lequel Opentofu (ou Terraform si vous y tenez vraiment) pourra déposer des
extensions de configuration DHCP et des fichiers Ignition : un pour chaque
machine virtuelle de l'infrastructure à installer. Dans cette preuve de concept,
le serveur de fichiers est un serveur SFTP. Nous verrons dans la suite de cet
article que c'est un choix par défaut, et qui n'est pas entièrement
satisfaisant.
[^ipxe]: https://ipxe.org/
## Déploiement de services sur Fedora CoreOS
### Extensions par conteneurs
Fedora CoreOS est une distribution optimisée pour l'hébergement de conteneurs.
Son socle (constitué par les namespaces[^ns] par défaut) ne contient que très
peu de programmes, et les interactions entre les différents logiciels le
composant ainsi qu'avec les conteneurs sont fortement limitées par le
durcissement mis en place, notamment avec SELinux.
[^ns]: https://www.man7.org/linux/man-pages/man7/namespaces.7.html
Cette distribution permet bien d'installer des logiciels additionnels sur le
socle, grâce à un système de surcouches (*layering*) de rpm-ostree[^rpm-ostree].
Cette éventualité doit cependant être écartées dans le cas de cette machine
virtuelle d'installation ; en effet, celle-ci démarre sur un LiveCD ISO et la
propriété d'immuabilité de Fedora CoreOS "interdit" l'ajout de nouveaux
programmes sur le système en cours d'exécution.
[^rpm-ostree]: https://coreos.github.io/rpm-ostree/
L'ensemble des services déployés doivent donc l'être grâce à des conteneurs. Ce
n'est pas si différent de ce qui doit être fait dans un cluster Kubernetes, et
il n'est pas étonnant que Red Hat CoreOS soit utilisée comme socle pour la
plateforme OpenShift[^openshift].
[^openshift]: https://www.redhat.com/fr/technologies/cloud-computing/openshift
De même, les volumes des conteneurs ne sont qu'assez rarement des
bind-mount[^bindmounts] de répertoires arbitraires du socle, et plus
généralement des volumes (anonymes ou nommés) du moteur de conteneurs. Cela est
dû aux politiques SELinux[^container_selinux] qui restreignent les interactions
avec les fichiers dans les volumes, qui doivent avoir (principalement) les types
`container_file_t` ou `container_ro_file_t`[^container_te], ce qu'ont rarement
les fichiers du socle.
[^bindmounts]: https://docs.docker.com/storage/bind-mounts/
[^container_selinux]: https://github.com/containers/container-selinux/blob/main/container_selinux.8
[^container_te]: https://github.com/containers/container-selinux/blob/main/container.te
Ainsi, le service DHCP et le service HTTP sont déployés sous la forme de
conteneurs, et en conséquence, le serveur de fichiers doit l'être également,
puisqu'il sert à ajouter du contenu aux volumes exposés aux deux premiers
services.
Une autre conséquence est que si un conteneur doit disposer de fichiers
pré-existants dans des volumes, il faut trouver un moyen de les y placer. La
méthode la plus propre et la plus simple est d'utiliser des conteneurs
d'initialisation[^init_containers], à l'instar de ce qui est fait dans les
déploiements Kubernetes. Podman dispose également de la commande `podman kube`
qui permet d'émuler en partie les déploiements Kubernetes et notamment les
objets ConfigMap[^config_map]. Les ConfigMaps étant plus "limitées" que le
dépôts de fichiers arbitraires, il a été choisi pour cette preuve de concept
d'utiliser exclusivement des conteneurs d'initialisation.
[^init_containers]: https://kubernetes.io/docs/concepts/workloads/pods/init-containers/
[^config_map]: https://kubernetes.io/docs/concepts/configuration/configmap/
### Les Podman Quadlets
Fedora CoreOS dispose par défaut des moteurs Moby (Docker) et Podman.
S'il est bien sûr possible d'utiliser `compose` pour définir les conteneurs, les
orchestrer et les lancer, il est également possible d'utiliser les Podman
Quadlets[^quadlets]. Les Quadlets sont des fichiers de configuration ressemblant
à des unités systemd (*systemd units*), avec des sections en plus. Il en existe
de plusieurs types : conteneurs, pods, images, volumes, réseaux, et même une
couche de compatibilité avec la syntaxe Kubernetes.
[^quadlets]: https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html
Voici par exemple le quadlet pour le volume stockant les baux DHCP de notre
preuve de concept, stocké dans le fichier /etc/containers/systemd/dhcp_data.volume :
```
[Unit]
Description = DHCP Data Volume
[Volume]
VolumeName = dhcp_data
Device=/dev/disk/by-label/dhcp_data
Options=nodev,noexec,nosuid,rootcontext=system_u:object_r:container_file_t:s0
Type=ext4
```
Le quadlet du conteneur d'initialisation téléchargeant la dernière image de Fedora CoreOS est :
```
[Unit]
Description = Download Latest FCOS Image
[Container]
ContainerName = fcos_downloader
Image = image_downloader.image
Exec = download -s stable -f pxe
Volume = fcos_images.volume:/data:z
WorkingDir = /data
[Install]
WantedBy=multi-user.target
```
Ils sont consommés par un générateur de services systemd qui transforme ces
quadlets, situés dans `/etc/containers/systemd`, en services, situés dans
`/run/systemd/generator`.
L'un des intérêts des quadlets est leur intégration au sein de systemd, avec la
possibilité de contrôler de manière assez fine les dépendances avec d'autres
services.
En outre, leur syntaxe permet également de monter automatiquement des systèmes
de fichiers sur des volumes. Couplé à la facilité de créer des partitions avec
Ignition, il est ainsi trivial de créer une partition par volume. Il s'agit
d'une bonne pratique de sécurité qui permet de limiter les risques de dénis de
service ou de corruption entre conteneurs par saturation d'un système de
fichiers commun à plusieurs volumes.
## Difficultés rencontrées
Un projet ne serait pas une aventure, sans son lot d'ornières et d'imprévus.
Quelle aventure se fut !
### Problèmes avec le fournisseur Ignition d'Opentofu
Les fichiers de configuration Ignition sont des documents JSON.
Chaque document JSON décrit une machine complète : partitions, systèmes de
fichiers, fichiers de configuration systemd, fichiers arbitaires, répertoires,
utilisateurs et groupes, etc. Le contenu des fichiers à créer est sérialisé
dans ce document JSON, soit verbatim, soit sous la forme d'une adresse de type
`data:`[^rfc_data_url].
[^rfc_data_url]: https://www.rfc-editor.org/rfc/rfc2397
Il est possible qu'un fichier de configuration Ignition contienne des
directives d'inclusion/fusion d'autres fichiers Ignition pouvant être récupérés
notamment par le réseau. Il faut dans ce cas s'assurer de l'intégrité de ces
fichiers distants, afin de prévenir la compromission totale du serveur.
Fedora CoreOS étant un système d'exploitation immuable, reposant sur des images
systèmes administrées par `rpm-ostree`, l'image de base est la même pour toutes
les machines d'une infrastructure virtualisée ; les machines se diversifient et
se spécialisent via leur configuration Ignition. Cette approche est donc opposée
à celles impliquant la pré-personnalisation des images avec des outils comme
Packer[^packer].
[^packer]: https://www.packer.io/
Le moment et la manière de générer la configuration Ignition peut varier en
fonction des préférences individuelles. Le plus gros de la configuration peut
être statique : tous les serveurs HTTP ont besoin des mêmes images de
conteneurs, des mêmes scripts de démarrage, des mêmes partitions et volumes pour
stocker certaines informations de manière persistante. Tout cela peut être
stocké dans un fichier Ignition commun à toutes les instances et ultérieurement
inclus, ou être mis dans le fichier Ignition de chaque instance. C'est au choix.
Avec une approche cloud-init, ces fichiers feraient parties de l'image générée
avec Packer.
En revanche, le contenu servi par ces serveurs HTTP ou l'adresse IP d'écoute
sont des informations spécifiques à chaque instance. Avec l'approche cloud-init,
ces informations seraient communiquées au système d'exploitation par le service
de métadonnées interrogé par l'utilitaire cloud-init.
Dans le cas d'espèce, puisque nous n'avons pas encore d'infrastructure
virtualisée, pas de serveur HTTP de confiance où stocker et distribuer la
configuration commune à toutes les machines virtuelles d'installation, et que le
fichier Ignition va être stocké dans un ISO personnalisé de Fedora CoreOS, nous
n'allons générer qu'un seul fichier Ignition, contenant les informations
communes à plusieurs instances éventuelles et les informations spécifiques à une
instance spécifique.
Concernant la conception du document JSON au format Ignition, nous pourrions
écrire cette configuration Ignition avec n'importe quel outil, y compris
"manuellement", mais ultérieurement, nous le ferons avec Opentofu, au moins pour
les informations spécifiques. Par cohérence, Opentofu a donc également été
utilisé pour cette machine.
Étant donné que les fichiers Ignition sont exprimés en JSON, il est possible de
structurer ses données en HCL (HashiCorp Configuration Language), puis de faire
appel à la fonction `jsonencode`. Pour autant, Hashicorp a initialement
développé un fournisseur Terraform pour Ignition[^hashicorp_ignition], qui a
ensuite été abandonné, puis repris par la communauté[^community_ignition]. Ce
fournisseur propose des sources de données (*data sources*) afin de structurer
la configuration Terraform et la typer.
[^hashicorp_ignition]: https://github.com/hashicorp/terraform-provider-ignition
[^community_ignition]: https://github.com/community-terraform-providers/terraform-provider-ignition
Ce fournisseur est assez basique puisqu'il se contente de codifier sous la forme
d'un schéma de source de données les champs des différentes structures définies
dans la spécification d'Ignition[^ignition_spec].
[^ignition_spec]: https://coreos.github.io/ignition/configuration-v3_4/
Avec le fournisseur, on écrit donc :
```hcl
data "ignition_file" "dnsmasq_container" {
path = "/etc/containers/systemd/dnsmasq.container"
mode = 420
content {
content = file("${path.module}/files/dnsmasq.container")
}
}
data "ignition_config" "example" {
files = [
data.ignition_file.dnsmasq_container.rendered,
]
}
locals {
serialized_config = data.ignition_config.example.rendered
}
```
En HCL "pur", pour comparaison, on écrit :
```hcl
locals {
dnsmasq_container_file = {
path = "/etc/containers/systemd/dnsmasq.container"
mode = 420
contents = {
source = format(
"data:text/plain;base64,%s",
base64encode(file("${path.module}/files/dnsmasq.container"))
)
}
}
serialized_config = jsonencode({
ignition = {
version = "3.4.0"
}
storage = {
files = [
local.dnsmasq_container_file,
]
}
})
}
```
La différence de verbosité entre les deux versions n'est pas flagrante ; mais
surtout, le fournisseur comporte plusieurs problèmes : le document JSON généré
n'est pas minimal, certaines générations sont incorrectes, et il manque des
options pourtant spécifiées dans la version 3.4.0 du format Ignition.
La génération non-minimaliste est dû à l'usage par le fournisseur des types
définis dans le code source de l'outil Ignition lui-même[^incorrecttypes]. Par
exemple la structure racine d'une configuration Ignition est définie ainsi :
[^incorrecttypes]: https://github.com/coreos/ignition/blob/v2.18.0/config/v3_4/types/schema.go
```
type Ignition struct {
Config IgnitionConfig `json:"config,omitempty"`
Proxy Proxy `json:"proxy,omitempty"`
Security Security `json:"security,omitempty"`
Timeouts Timeouts `json:"timeouts,omitempty"`
Version string `json:"version"`
}
```
La plupart des champs sont définis avec l'annotation de sérialisation JSON
`omitempty`. Quand cette annotation est utilisée de manière correcte, le champ
n'apparait pas dans le document JSON généré avec la fonction `json.Marshal` si
sa valeur est "fausse" ou "vide" selon le système de typage du langage Go. Or,
les sous-structures ne sont pas définies comme des pointeurs. Ainsi, elles ne
peuvent jamais être "vides", et les champs sont toujours ajoutés au document
JSON, même si elles ne contiennent aucune valeur.
Voici la configuration générée par le code ci-dessus utilisant le fournisseur :
```
{
"ignition": {
"config": {
"replace": {
"verification": {}
}
},
"proxy": {},
"security": {
"tls": {}
},
"timeouts": {},
"version": "3.4.0"
},
"kernelArguments": {},
"passwd": {},
"storage": {
"files": [
{
"group": {},
"overwrite": false,
"path": "/etc/containers/systemd/dnsmasq.container",
"user": {},
"contents": {
"source": "data:text/plain;charset=utf-8;base64,W1VuaXRdCkRlc2NyaXB0aW9uID0gREhDUCBDb250YWluZXIKCldhbnRzPWltYWdlX2Rvd25sb2FkZXIuc2VydmljZQpBZnRlcj1pbWFnZV9kb3dubG9hZGVyLnNlcnZpY2UKV2FudHM9bmV0d29yay1vbmxpbmUudGFyZ2V0CkFmdGVyPW5ldHdvcmstb25saW5lLnRhcmdldApXYW50cz1kaGNwX2NvbmZpZ19pbml0LnNlcnZpY2UKQWZ0ZXI9ZGhjcF9jb25maWdfaW5pdC5zZXJ2aWNlCgpbQ29udGFpbmVyXQpDb250YWluZXJOYW1lID0gZG5zbWFzcV9jb250YWluZXIKSW1hZ2UgPSBsb2NhbGhvc3QvZG5zbWFzcTpsYXRlc3QKVm9sdW1lID0gZGhjcF9jb25maWcudm9sdW1lOi9ldGMvZG5zbWFzcS5kOnoKVm9sdW1lID0gZGhjcF9kYXRhLnZvbHVtZTovZGF0YTpaClZvbHVtZSA9IC9kZXYvbG9nOi9kZXYvbG9nCk5ldHdvcmsgPSBob3N0CkFkZENhcGFiaWxpdHkgPSBDQVBfTkVUX0FETUlOLENBUF9ORVRfUkFXCgpbU2VydmljZV0KV29ya2luZ0RpcmVjdG9yeT0vdmFyL3Jvb3Rob21lL2RoY3AKRXhlY1N0YXJ0UHJlPS9iaW4vYmFzaCAvdmFyL3Jvb3Rob21lL2dlbmVyYXRlX2RoY3Bfb3B0aW9ucy5zaApFeGVjU3RhcnRQcmU9L3Vzci9iaW4vcG9kbWFuIGJ1aWxkIC10IGRuc21hc3E6bGF0ZXN0IC4KUmVzdGFydD1vbi1mYWlsdXJlCgpbSW5zdGFsbF0KV2FudGVkQnk9bXVsdGktdXNlci50YXJnZXQKCg==",
"verification": {}
},
"mode": 420
}
]
},
"systemd": {}
}
```
En comparaison, voici le document JSON généré en écrivant soi-même en HCL la configuration Ignition :
```
{
"ignition": {
"version": "3.4.0"
},
"storage": {
"files": [
{
"contents": {
"source": "data:text/plain;base64,W1VuaXRdCkRlc2NyaXB0aW9uID0gREhDUCBDb250YWluZXIKCldhbnRzPWltYWdlX2Rvd25sb2FkZXIuc2VydmljZQpBZnRlcj1pbWFnZV9kb3dubG9hZGVyLnNlcnZpY2UKV2FudHM9bmV0d29yay1vbmxpbmUudGFyZ2V0CkFmdGVyPW5ldHdvcmstb25saW5lLnRhcmdldApXYW50cz1kaGNwX2NvbmZpZ19pbml0LnNlcnZpY2UKQWZ0ZXI9ZGhjcF9jb25maWdfaW5pdC5zZXJ2aWNlCgpbQ29udGFpbmVyXQpDb250YWluZXJOYW1lID0gZG5zbWFzcV9jb250YWluZXIKSW1hZ2UgPSBsb2NhbGhvc3QvZG5zbWFzcTpsYXRlc3QKVm9sdW1lID0gZGhjcF9jb25maWcudm9sdW1lOi9ldGMvZG5zbWFzcS5kOnoKVm9sdW1lID0gZGhjcF9kYXRhLnZvbHVtZTovZGF0YTpaClZvbHVtZSA9IC9kZXYvbG9nOi9kZXYvbG9nCk5ldHdvcmsgPSBob3N0CkFkZENhcGFiaWxpdHkgPSBDQVBfTkVUX0FETUlOLENBUF9ORVRfUkFXCgpbU2VydmljZV0KV29ya2luZ0RpcmVjdG9yeT0vdmFyL3Jvb3Rob21lL2RoY3AKRXhlY1N0YXJ0UHJlPS9iaW4vYmFzaCAvdmFyL3Jvb3Rob21lL2dlbmVyYXRlX2RoY3Bfb3B0aW9ucy5zaApFeGVjU3RhcnRQcmU9L3Vzci9iaW4vcG9kbWFuIGJ1aWxkIC10IGRuc21hc3E6bGF0ZXN0IC4KUmVzdGFydD1vbi1mYWlsdXJlCgpbSW5zdGFsbF0KV2FudGVkQnk9bXVsdGktdXNlci50YXJnZXQKCg=="
},
"mode": 420,
"path": "/etc/containers/systemd/dnsmasq.container"
}
]
}
}
```
Cette définition incorrecte des types dans le code source d'Ignition n'est pas
trivial à corriger car ces types sont générés par un outil appelé
`schematyper`[^schematyper]. Cet outil prend en entrée une description de
données au format JSON Schema[^json_schema], et écrit en sortie des définitions de structures
en Go.
[^json_schema]: https://json-schema.org/
[^schematyper]: https://github.com/idubinskiy/schematyper
Dans le cas d'espèce, cette verbosité excessive n'est pas forcément gênante car
nous n'avons pas de contrainte de taille de fichiers. Certains services de
metadonnées cloud en ont cependant une (généralement autour de 16ko) ; dans
ces cas, la taille compte.
Également, bien que les champs soient bien définis par le code source
d'Ignition, le fournisseur Terraform est incomplet et plusieurs définitions
manquent, rendant impossible l'utilisation de certaines options. Ce n'est pas
que c'est difficile à ajouter... mais leur absence cumulée au fait qu'exprimer en
HCL une configuration Ignition fait qu'il devient immédiatement préférable de
s'en passer.
### Problèmes avec la prise en charge de SFTP par Opentofu
La solution proposée utilise SFTP afin de permettre l'extension de la
configuration du serveur DHCP et la publication de fichiers Ignition pour les
futures machines virtuelles qui composeront l'infrastructure.
SFTP est un service natif d'OpenSSH, un des démons les plus exposés et les plus
sensibles puisque sa présence est quasi universelle et qu'il transporte
notamment les flux d'administration. Son emploi est particulièrement intéressant
pour le type de transfert de fichiers utilisé dans cette preuve de concept,
grâce à son chiffrement de flux par défaut, son identification native par clé
publique, et ses capacités d'isolation à l'aide de chroot[^chroot].
[^chroot]: `chroot(2)` est un appel système qui permet de restreindre la
capacité d'un processus à changer de répertoires ; bien utilisé, il permet
de restreindre les accès à un sous-ensemble de la hiérarchie des fichiers
d'un système de fichiers sous Linux.
De surcroit, la configuration est triviale :
```
Subsystem sftp internal-sftp
Match User terraform_ignition
ForceCommand internal-sftp
ChrootDirectory /my/chroot/path
```
Compte tenu des politiques de sécurité SELinux en place par défaut sur Fedora
CoreOS, il n'est pas possible d'utiliser le processus OpenSSH Server du socle.
En effet, lors de la réception des fichiers, ces derniers sont marqués avec le
type SELinux `user_home_t` avec lequel les conteneurs ne peuvent interagir.
Pour cette raison, la preuve de concept dispose de deux serveurs SSH : un pour
se connecter au socle et un autre, accessible uniquement en SFTP, permettant le
téléversement de fichiers. Ainsi, les fichiers déposés en SFTP sont marqués avec
le type `container_file_t` qui peut être lu par d'autres conteneurs.
Hélas, ce superbe service SFTP ne peut être utilisé nativement par Opentofu...
En effet, l'espoir était que les configurations DHCP soient générées par
Opentofu, écrites sur disque à l'aide d'une ressource
`local_file`[^local_file_res] ou même une `null_resource`[^null_res], puis
téléversées grâce au mécanisme d'approvisionnement (*provisioner*)
"file"[^file_prov]. Le code aurait ressemblé à :
[^local_file_res]: https://registry.terraform.io/providers/hashicorp/local/latest/docs/resources/file
[^null_res]: https://registry.terraform.io/providers/hashicorp/null/latest/docs/resources/resource
[^file_prov]: https://developer.hashicorp.com/terraform/language/resources/provisioners/file
```hcl
resource "null_resource" "ignition_configuration" {
provisioner "file" {
content = local.encoded_config
destination = "writable/${vm_id}.ign"
connection {
type = "ssh"
host = var.netboot_server_ip
port = 2222
user = "terraform_ignition"
agent = true
bastion_host = var.pve_host
bastion_user = var.pve_pam_user
bastion_port = 22
}
}
}
```
Il s'avère néanmoins que cela n'est pas possible, et une tentative renvoie le
message d'erreur :
```
Upload failed: his service allows sftp connections only`
```
La documentation explique que :
> Provisioners which execute commands on a remote system via a protocol such as
> SSH typically achieve that by uploading a script file to the remote system and
> then asking the default shell to execute it.
Ce mécanisme d'approvisionnement ayant pour but la copie de fichiers notamment
par SSH n'est pas compatible avec le mécanisme standard de transfert de fichiers
de SSH[^scp]...
[^scp]: Le protocole SCP est devenu obsolète à la suite d'une vulnérabilité
protocolaire irréparable : https://lwn.net/Articles/835962/
La preuve de concept actuelle repose donc sur plusieurs mécanismes
d'approvisionnement, dont `local-exec`[^local_exec] afin d'exécuter la commande
`sftp` via le shell. Comme cette solution est un bricolage peu satisfaisant,
l'auteur de cet article envisage de développer, dans un futur plus ou moins
proche, un fournisseur Opentofu pour la gestion de ressources de type fichiers
au travers du protocole WebDav[^webdav], afin de palier cette situation.
[^local_exec]: https://developer.hashicorp.com/terraform/language/resources/provisioners/local-exec
[^webdav]: https://www.rfc-editor.org/rfc/rfc4918
### Problèmes avec le fournisseur Opentofu pour Proxmox
Le fournisseur Opentofu pour Proxmox `bpg/proxmox`[^bpg] est le fournisseur le
plus avancé disponible sur les registres de Terraform.
[^bpg]: https://registry.terraform.io/providers/bpg/proxmox/latest/docs
Hélas, ce dernier ne dispose pas d'une ressource pour le téléversement de
fichiers ISO. La ressource `proxmox_virtual_environement_file` permet certes le
téléversement de fichiers arbitraires, mais comme l'indique la documentation, un
transfert par SSH vers l'hyperviseur est impliqué, au lieu d'utiliser l'API de
Proxmox. La surface d'attaque de cette ressource est donc bien plus importante
que si l'API avait été utilisée. C'est d'autant plus regrettable que l'API
fournit bien un moyen de téléverser des fichiers ISO.
En conséquence, dans cette preuve de concept, le mécanisme d'approvisionnement
`local-exec` d'Opentofu a été utilisé afin de téléverser le fichier avec
l'utilitaire `curl` par l'API de Proxmox :
```
provisioner "local-exec" {
command = <<EOT
curl \
-F "content=iso" \
-F "filename=@customized-${random_pet.config_name.id}.iso;type=application/vnd.efi.iso;filename=fcos-netboot-server-${random_pet.config_name.id}.iso" \
-H "@${local_file.api_token.filename}" \
"${var.pve_api_base_url}nodes/${var.pve_node_name}/storage/${var.pve_storage_id}/upload"
EOT
}
```
Une ressource de type `local_file` a également été utilisée pour stocker le
jeton d'API, de manière à éviter d'exposer le jeton directement sur la ligne de
commandes[^cmdline_exposed].
[^cmdline_exposed]: https://cwe.mitre.org/data/definitions/214
### Problèmes avec systemd.path et la détection de changements de fichiers dans un répertoire
Le serveur DHCP utilisé par cette preuve de concept repose sur `dnsmasq`.
Celui-ci contient certainement moins de fonctionnalités que le serveur de l'ISC,
mais il convient parfaitement pour le démarrage par le réseau.
Une fonctionnalité manquante sur ces deux logiciels est la capacité de recharger
automatiquement la configuration lors de son changement ou de son extension. Or,
cette preuve de concept repose sur l'extensibilité de la configuration par
Opentofu : lorsqu'une nouvelle machine est ajoutée à l'infrastructure, des
options DHCP sont définies dynamiquement pour permettre son installation. En
particulier, une option DHCP indique où trouver le fichier de configuration
Ignition spécifique à cette machine à installer et une autre indique le chemin
du disque dur sur lequel effectuer l'installation.
```
dhcp-host=${mac_address},set:${hostname}tag,${host_ip},${hostname}
dhcp-option=tag:${hostname}tag,encap:128,2,"${vm_id}.ign"
dhcp-option=tag:${hostname}tag,encap:128,3,"/dev/disk/by-path/pci-0000:00:0a.0"
```
Le redémarrage du service pourrait être fait avec un mécanisme
d'approvisionnement de type remote-exec[^remote_exec], mais cette solution
nécessite la capacité d'exécuter des commandes sur le socle depuis Opentofu.
[^remote_exec]: https://developer.hashicorp.com/terraform/language/resources/provisioners/remote-exec
Opentofu dispose déjà de la capacité d'exécuter des commandes arbitraires sur
toutes les machines de l'infrastructure par injection de commandes et de
fichiers dans les configurations Ignition. Cela demande néanmoins le redémarrage
de la machine virtuelle pour appliquer la nouvelle configuration, ce qui n'est
pas forcément très discret pour un attaquant qui souhaiterait ainsi compromettre
des machines.
Une solution plus propre et ne nécessitant pas d'accès shell est de surveiller
les fichiers de configuration avec `inotify`[^inotify]. Inotify est une
fonctionnalité du noyau Linux qui permet d'émettre des événements lors
d'opérations sur le système de fichiers. Les programmes intéréssés peuvent
s'abonner à ces événements en vue d'y réagir.
[^inotify]: https://www.man7.org/linux/man-pages/man7/inotify.7.html
Systemd peut être configuré pour surveiller le système de fichiers avec `inotify`,
grâce aux unités de type `path`[^systemd_path]. La configuration ressemble à ceci :
[^systemd_path]: https://www.freedesktop.org/software/systemd/man/latest/systemd.path.html
```
[Unit]
Description = Path Monitor for DHCP Config
[Path]
PathChanged=/path/to/monitored/path
TriggerLimitIntervalSec=0
[Install]
WantedBy=multi-user.target
```
Cette unité d'exemple active un service du même nom que celui de l'unité de type
`path` lorsque le chemin indiqué subit une modification. Ce chemin peut être un
fichier ou un répertoire. Dans le cas d'un répertoire, les changements
n'interviennent que lors de la suppression ou de l'ajout d'un fichier ; les
opérations de modification des fichiers contenus dans un répertoire n'entrainent
pas de modification du répertoire lui-même.
Hélas, bien qu'il existe une directive `PathExistsGlob` permettant l'usage de
chaines de substitution (e.g. `*.conf`), il n'existe aucune directive de type
`PathChangedGlob` qui permettrait la surveillance de tous les fichiers au sein
d'un répertoire. Un ticket à ce sujet est ouvert depuis plusieurs
années[^pathchangedglob]. Un bricolage pour s'accomoder de la situation est de
supprimer les fichiers de configuration existants avant d'ajouter les nouveaux,
de façon à provoquer un ou deux redémarrages du service[^coalesce] et la prise
en compte de la nouvelle configuration.
[^pathchangedglob]: https://github.com/systemd/systemd/issues/14330
[^coalesce]: L'incertitude entre un ou deux redémarrage provient du fait
qu'inotify ne garantit pas un événement distinct par opération recherchée.
Si deux événements de même type se produisent avant qu'un observateur/abonné
ne soit informé du premier, un unique événement lui est rapporté.
Un autre défaut de cette solution est qu'elle exige que systemd "espionne" les
fichiers déposés par SFTP dans un volume géré par Podman. Cela implique de faire
une hypothèse sur les chemins utilisés par Podman ou de monter une deuxième fois
le système de fichiers associé à ce volume à un chemin connu du socle. Si le
montage peut sembler plus propre, il faut considérer que lorsqu'on monte un même
système de fichiers à de multiples endroits, il faut que les options de montage
soient identiques, y compris les informations relatives à SELinux (e.g.
l'option `rootcontext`). Il faut alors à nouveau faire des hypothèses : les
options utilisées par les Quadlets Podman lors du montage du système de fichiers
sur le volume.
Dans le ticket précédemment évoqué, un moyen de contournement est évoqué :
utiliser l'utilitaire `inotifywatch`[^inotifywatch]. Ce programme permet la
surveillance de plusieurs fichiers dans un répertoire, nommés suivant un motif
spécifié. Hélas, ce dernier n'est pas disponible sur le socle de Fedora CoreOS,
et comme expliqué précédemment, il n'est pas possible d'installer des logiciels
additionnels dans notre cas d'usage. Il est cependant possible de l'utiliser
dans un conteneur qui aurait également accès au volume des extensions de
configuration DHCP. Une pierre, deux coups : on contournerait la limitation de
systemd et on ne ferait plus d'hypothèse sur le chemin d'accès ! Hélas, il reste
ensuite à trouver un moyen de redémarrer le service dnsmasq depuis le conteneur
ayant détecté le changement...
[^inotifywatch]: https://www.man7.org/linux/man-pages/man1/inotifywatch.1.html
Donner un accès SSH sur le socle depuis le conteneur de surveillance semble la
voie royale. Hélas, cet accès est compliqué à donner, du fait des politiques
SELinux, encore une fois, ou du plan d'adressage dynamique de la couche réseau
de Podman.
L'hypothèse à faire sur le plan d'adressage de Podman est de connaitre l'adresse
IP associée au socle. Elle doit être faite si on tente de se connecter en SSH au
socle par le réseau. Une solution pour éviter de faire des hypothèses sur la
couche réseau est d'établir la connexion SSH à travers une socket Unix
bind-montée dans le conteneur de surveillance. Exposer un service SSH sur socket
Unix à d'autres conteneurs ou utilisateurs est une astuce assez élégante
notamment discutée par Timothée Ravier, développeur de CoreOS, sur son blog dans
le cadre d'un remplacement de `sudo` par une connexion SSH locale[^sshunix].
Hélas, dans le cas d'espèce, les politiques SELinux empêchent la connexion de
`socat`, utilisé par le client SSH du conteneur, à la socket Unix exposée par le
serveur SSHD du socle.
[^sshunix]: https://tim.siosm.fr/blog/2023/12/19/ssh-over-unix-socket/
En conséquence, sur cette problématique, aucune solution satisfaisante n'a été
trouvée. La solution retenue est, en attendant de trouver mieux, d'utiliser une
unité systemd de type `path` en faisant l'hypothèse sur le chemin du volume
Podman, et en supprimant puis en rajoutant le fichier de configuration, afin de
déclencher le redémarrage du service.
# Conclusion
Cet article a couvert une partie des problèmes rencontrés et des solutions
proposées pour la création d'une preuve de concept d'un serveur d'installation
pour une infrastructure basée sur Fedora CoreOS sur Proxmox. Le code est
ouvert[^sourcecode] et les commentaires sont les bienvenues, afin d'améliorer
cette dernière et permettre à la communauté d'en bénéficier.
Grâce à cette machine d'installation, il devient trivial de déployer de
nouvelles machines faisant tourner Fedora CoreOS. Il suffit pour cela de
téléverser le fichier Ignition de la machine à installer et un fichier
d'extension de la configuration de dnsmasq, puis de configurer la nouvelle
machine virtuelle pour démarrer par le réseau.
Ces téléversements peuvent s'effectuer lors de la déclaration de la nouvelle
machine virtuelle dans la configuration d'Opentofu.
Dans le prochain billet de cette série, nous verrons comment déployer, grâce à
cette machine d'installation, un serveur DNS[^knot_resolver], un serveur
ACME[^caddy], et un cluster etcd[^etcd], en vue d'installer une instance
d'Openbao[^openbao], et ainsi pouvoir enfin créer des instances de Fedora CoreOS
sans placer de secrets dans les fichiers Ignition.
[^knot_resolver]: https://www.knot-resolver.cz/
[^caddy]: https://caddyserver.com/docs/caddyfile/directives/acme_server
[^etcd]: https://etcd.io/
[^openbao]: https://openbao.org/