Table des matières

Migration proxy HTTP : Raspi → NUC

Cette page décrit le reverse proxy de la maison. Il tournait auparavant sur un Raspberry Pi (Arch Linux, Apache et Certbot). Il tourne maintenant sur un NUC sous Debian 13, avec le logiciel Caddy dans un conteneur Docker.

Le changement visait une machine plus simple à entretenir, plus fiable, entièrement en logiciels libres et plus facile à surveiller.

1. Système

Distribution

Le NUC fonctionne sous Debian 13 « Trixie », version stable depuis le 9 août 2025 et maintenue jusqu'en 2030. L'installation est minimale : un serveur SSH et les outils habituels, sans interface graphique.

Le noyau est installé par le paquet linux-image-amd64, qui suit automatiquement les correctifs de sécurité. Le bootloader est GRUB, le choix par défaut de Debian, qui s'entend bien avec LVM.

Partitionnement

Le disque est un NVMe de 250 Go, non chiffré. Il est découpé en trois partitions :

Partition Taille FS Montage
nvme0n1p1 1 Go FAT32 /boot/efi
nvme0n1p2 1 Go ext4 /boot
nvme0n1p3 ~248 Go LVM (PV) —

La troisième partition est un volume physique LVM. Elle porte le groupe de volumes volumes, découpé ainsi :

LV Taille FS Montage
lv-swap 8 Go swap [SWAP]
lv-root 25 Go ext4 /
lv-var 35 Go ext4 /var
lv-opt 60 Go ext4 /opt
(libre) ~93 Go — réserve

Environ 93 Go restent non alloués dans le VG, pour étendre un LV le jour où il sera plein.

Un LV s'étend à chaud. Par exemple, pour ajouter 20 Go à /var :
lvextend -L +20G /dev/volumes/lv-var
resize2fs /dev/volumes/lv-var

2. Réseau

Adressage

Le routeur est un OpenWrt à l'adresse 192.168.1.90. Il sert aussi de DNS local et résout les noms des machines internes.

Le NUC a toujours l'adresse 192.168.1.25. Il l'obtient par une réservation DHCP sur le routeur, liée à son adresse MAC cc:28:aa:47:ea:ab.

Cette adresse était déjà celle de l'ancien proxy. Le remplacement n'a donc demandé aucun changement ailleurs : les redirections de ports du routeur et les noms internes sont restés les mêmes.

Gestion réseau

L'interface enp1s0 est gérée par ifupdown. Elle est déclarée en DHCP dans /etc/network/interfaces par la ligne iface enp1s0 inet dhcp.

Pour renouveler le bail DHCP :

sudo sh -c 'ifdown enp1s0; ifup enp1s0'
Piège rencontré. Une réservation DHCP ne prend pas effet tant qu'une autre machine occupe encore l'adresse. Il faut éteindre l'ancienne machine, purger les baux sur OpenWrt, puis renouveler le bail sur le NUC :
# Sur OpenWrt
sed -i '/cc:28:aa:47:ea:ab/d; /192.168.1.25/d' /tmp/dhcp.leases
/etc/init.d/dnsmasq restart

3. Sécurité

Pare-feu (UFW)

La politique par défaut refuse le trafic entrant et autorise le trafic sortant. Les ouvertures sont les suivantes :

ufw default deny incoming
ufw default allow outgoing
ufw allow from 192.168.1.0/24   to any port 22 proto tcp   # SSH depuis LAN1
ufw allow from 192.168.115.0/24 to any port 22 proto tcp   # SSH depuis le WiFi
ufw allow from 10.1.100.0/24    to any port 22 proto tcp   # SSH depuis le VPN
ufw allow 80/tcp     # HTTP (challenge ACME)
ufw allow 443/tcp    # HTTPS
ufw allow 443/udp    # HTTP/3
ufw enable

SSH n'est joignable que depuis les réseaux internes (LAN1, WiFi, VPN). Le port est fermé côté Internet, en IPv4 comme en IPv6.

Les règles sur les ports 80 et 443 sont là pour la lisibilité. En pratique, Docker publie lui-même ces ports par ses propres règles iptables, en amont d'UFW.

Cloisonnement sortant de Caddy

Le proxy est la seule machine de la maison directement exposée à Internet. Si Caddy était compromis, l'attaquant aurait un pied sur le LAN. Pour limiter les dégâts, le conteneur ne peut joindre, sur les réseaux privés, que les backends qu'il dessert réellement.

Ce filtrage ne peut pas s'écrire avec les règles UFW habituelles. Caddy est sur un réseau bridge Docker : son trafic vers le LAN est routé par l'hôte et traverse la chaîne FORWARD, pas la chaîne OUTPUT. Les règles ufw allow out et ufw deny out ne s'y appliquent donc pas. Docker réserve à cet usage la chaîne DOCKER-USER, évaluée avant ses propres règles.

Les règles sont écrites à la fin du fichier /etc/ufw/after.rules. UFW les charge à chaque démarrage et à chaque rechargement. Une copie du fichier d'origine est conservée sous le nom after.rules.bak.

# BEGIN CADDY EGRESS
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
-A DOCKER-USER -s 172.18.0.0/16 -d 192.168.1.9 -p tcp -m multiport --dports 443,3000,3001,3002,8888,17170 -j RETURN
-A DOCKER-USER -s 172.18.0.0/16 -d 192.168.1.67 -p tcp --dport 8123 -j RETURN
-A DOCKER-USER -s 172.18.0.0/16 -d 192.168.1.90 -p udp --dport 53 -j RETURN
-A DOCKER-USER -s 172.18.0.0/16 -d 192.168.1.90 -p tcp --dport 53 -j RETURN
-A DOCKER-USER -s 172.18.0.0/16 -d 192.168.0.0/16 -j DROP
-A DOCKER-USER -s 172.18.0.0/16 -d 10.0.0.0/8 -j DROP
COMMIT
# END CADDY EGRESS

Les règles se lisent de haut en bas, et la première qui correspond s'applique. La première laisse passer les connexions déjà établies, c'est-à-dire les réponses de Caddy aux clients du LAN. Les quatre suivantes autorisent les destinations du tableau ci-dessous. Les deux dernières font un DROP vers toutes les autres adresses privées, ce qui couvre LAN1, le WiFi et le VPN.

Destination Port Service
cahute (.9) 443 Apache : blog, dav, musique, nuage, wiki
cahute (.9) 3000 Gitea
cahute (.9) 3001 Uptime-Kuma
cahute (.9) 3002 Homepage
cahute (.9) 8888 Dozzle
cahute (.9) 17170 LLDAP (interface web)
homeassistant (.67) 8123 Home Assistant
routeur (.90) 53 DNS local

Internet reste accessible au conteneur. Il en a besoin pour ACME et pour joindre le DNS de secours 1.1.1.1.

Trois conséquences sont à connaître :

# Recharger après une modification du bloc
sudo ufw reload
 
# Afficher la chaîne telle qu'elle est chargée
sudo iptables -S DOCKER-USER
 
# Vérifier que le blocage fonctionne : Cockpit (port 9090) n'est pas autorisé,
# la commande doit échouer au bout de 3 secondes
docker exec proxy-caddy-1 wget -T 3 -qO- http://192.168.1.9:9090 ; echo "code=$?"

Fail2ban

Fail2ban surveille les tentatives de connexion SSH. Une adresse qui échoue cinq fois en dix minutes est bannie pendant une heure. Le réglage se trouve dans /etc/fail2ban/jail.local :

[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5

[sshd]
enabled = true

SSH durci

La connexion SSH se fait uniquement par clé (de type ed25519). Les mots de passe sont refusés (PasswordAuthentication no) et le compte root ne peut pas se connecter directement (PermitRootLogin no).

La clé a été copiée sur le NUC avec ssh-copy-id avant de désactiver les mots de passe. Dans l'ordre inverse, on se serait enfermé dehors.

Mises à jour automatiques

Le paquet unattended-upgrades installe tout seul les correctifs de sécurité de Debian.

4. Stack proxy

Docker

Le NUC utilise Docker 29 et Compose v5, installés avec le script officiel get.docker.com. Tout ce qui concerne le proxy est rangé dans /opt/proxy/.

Caddy

Caddy fait office de reverse proxy et gère le TLS automatiquement : il obtient et renouvelle seul les certificats Let's Encrypt. Il remplace à lui seul Apache et Certbot.

Le conteneur est décrit dans /opt/proxy/docker-compose.yml :

services:
  caddy:
    image: caddy:alpine
    restart: unless-stopped
    dns:
      - 192.168.1.90    # DNS local OpenWrt : résout les noms internes (cahute, etc.)
      - 1.1.1.1         # secours public
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config

volumes:
  caddy_data:
  caddy_config:

networks:
  default:
    ipam:
      config:
        - subnet: 172.18.0.0/16   # figé : référencé par les règles DOCKER-USER (§3)
Pourquoi figer le sous-réseau. Sans le bloc networks, Docker attribue le sous-réseau au moment où il crée le réseau. Un docker compose down suivi d'un up peut lui en donner un autre. Les règles DOCKER-USER du §3 ne correspondraient alors plus à rien, et Caddy retrouverait un accès libre à tout le LAN sans aucun symptôme.
Deux pièges rencontrés avec le DNS.

Au début, le résolveur interne de Docker (127.0.0.11) n'avait aucun serveur amont. Les requêtes vers les serveurs ACME renvoyaient SERVFAIL et Caddy n'obtenait pas ses certificats. Le problème a été réglé en forçant dns: dans le compose.

Ensuite, avec un DNS public seul, les backends internes comme cahute.beafrancois.fr ne résolvaient plus, et les vhosts renvoyaient un 502. C'est pourquoi le DNS OpenWrt est placé en premier.

Caddyfile

La configuration des vhosts se trouve dans /opt/proxy/caddy/Caddyfile. Voici le modèle d'un vhost, dans le cas le plus courant d'un proxy HTTPS vers HTTPS en direction de cahute :

blog.beafrancois.fr {
    redir / /blog/ 301
    reverse_proxy https://cahute.beafrancois.fr {
        header_up Host blog.beafrancois.fr
        transport http {
            tls_insecure_skip_verify
        }
    }
    header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload"
}
La directive header_up Host transmet au backend l'en-tête Host demandé par le client. Sans elle, Caddy envoie le Host de l'upstream (cahute), et le backend génère des URL et des redirections vers ce nom, qui ne résout pas depuis l'extérieur. C'est l'équivalent du ProxyPreserveHost On d'Apache.

La directive tls_insecure_skip_verify désactive la vérification du certificat du backend, parce que celui de cahute est auto-signé. C'est l'équivalent du SSLProxyVerify none d'Apache.

Vhosts migrés

Domaine Backend Particularité
blog cahute/blog redirection de / vers /blog/
dav cahute/dav/html/ redirection de / vers /dav/html/
git cahute:3000 —
musique cahute/musique/ redirection de / vers /musique/
nuage cahute/nextcloud redirections .well-known (carddav, caldav, webfinger, nodeinfo)
wiki cahute/dokuwiki/ redirection de / vers /dokuwiki/
service homeassistant:8123, en HTTP WebSocket géré nativement par Caddy

Le vhost shell.beafrancois.fr n'a pas été repris, car il n'a plus d'enregistrement DNS (NXDOMAIN).

Un nouveau vhost qui pointe vers une adresse ou un port absent du bloc CADDY EGRESS (§3) ne fonctionnera pas tant que la ligne correspondante n'aura pas été ajoutée dans /etc/ufw/after.rules.

Limite de taille des logs

Les logs des conteneurs sont limités à cinq fichiers de 50 Mo chacun, pour ne pas remplir le disque. Le réglage est dans /etc/docker/daemon.json :

{
  "log-driver": "json-file",
  "log-opts": { "max-size": "50m", "max-file": "5" }
}

Le journal systemd est limité à 2 Go par la ligne SystemMaxUse=2G dans /etc/systemd/journald.conf.

5. Mise à jour de l'image Caddy

La mise à jour de Caddy se fait à la main, sur le proxy. Aucun outil ne met à jour le conteneur automatiquement : si une nouvelle version casse quelque chose, il faut que ce soit au moment où on l'a décidé, pas par surprise.

cd /opt/proxy
 
# 1. Sauvegarder la configuration avant de toucher à quoi que ce soit
sudo cp -a /opt/proxy /root/proxy-$(date +%F)
 
# 2. Noter la version en cours, pour pouvoir y revenir
docker compose exec caddy caddy version
 
# 3. Télécharger la nouvelle image
docker compose pull
 
# 4. Recréer le conteneur avec cette image
docker compose up -d
 
# 5. Contrôler les logs, puis chaque vhost
docker compose logs --tail 40 caddy | grep -Ei "obtained|error|panic"
for h in blog dav git musique nuage wiki service; do
  echo -n "$h : "; curl -sI -o /dev/null -w '%{http_code}\n' https://$h.beafrancois.fr
done

Rollback. Dans le compose, on remplace caddy:alpine par le tag exact de l'ancienne version (par exemple image: caddy:2.x.y-alpine), puis on relance docker compose up -d. Les certificats sont conservés dans le volume caddy_data, donc le rollback est immédiat.

Avant une mise à jour, il vaut mieux lire le changelog de Caddy, car des directives du Caddyfile changent parfois. En cas de doute, on peut vérifier la configuration avant de recréer le conteneur :
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
Le tag alpine suit toujours la dernière version publiée. Un docker compose pull peut donc installer une version majeure sans prévenir. Le rollback exige de connaître la version qui tournait avant : c'est la raison de l'étape 2.

6. Procédure de bascule

Cette procédure est celle qui a servi à passer du Raspberry Pi au NUC. À chaque étape, il restait possible de revenir à l'ancien proxy.

  1. La veille, abaisser le TTL DNS à 60 secondes, pour qu'un changement se propage rapidement.
  2. Démarrer Caddy sur le NUC. Les challenges ACME échouent tant qu'il ne reçoit pas le trafic, c'est normal.
  3. Sur OpenWrt, basculer la réservation DHCP vers le NUC, ou pointer vers lui la redirection de ports.
  4. Vérifier que les certificats sont obtenus :
    docker compose logs -f caddy | grep -E "obtained|error"
  5. Tester chaque vhost avec curl -sI.
  6. Pour un rollback, remettre l'ancienne réservation ou l'ancienne redirection sur le routeur : le Raspberry Pi reprend aussitôt la main.
  7. Après 48 heures sans incident, éteindre le Raspberry Pi.

7. Commandes utiles

# Prendre en compte une modification du Caddyfile, sans couper les connexions en cours
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
 
# Même chose en redémarrant le conteneur (coupe les connexions en cours)
docker compose restart caddy
 
# Vérifier la syntaxe du Caddyfile sans l'appliquer
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
 
# Suivre les logs en direct
docker compose logs -f caddy
 
# État du conteneur
docker compose ps
 
# Chaîne DOCKER-USER telle qu'elle est chargée
sudo iptables -S DOCKER-USER
 
# Place disponible sur le disque et dans les volumes
df -h && vgs && lvs

Ce qui reste à faire