tuxvador@blog:~/blog/fr/wifi-access-point-lxc-container$

~/blog/fr/wifi-access-point-lxc-container

Un point d'accès WiFi dans un conteneur LXC

8 min de lectureMis à jour le Read in English →

#homelab#linux#networking#lxc#vpn

Tous les guides pour un point d’accès WiFi maison supposent un Raspberry Pi ou une machine de rechange. Le mien tourne dans un conteneur Proxmox, à une petite étape de configuration près d’être impossible — et cette étape mérite d’être notée, parce que l’erreur que vous obtenez quand vous la sautez ne vous dit rien.

L’objectif : un réseau sans fil isolé, séparé du LAN filaire, pour les invités et tout ce en quoi je n’ai pas entièrement confiance, avec du filtrage DNS et un VPN en dessous.

Le problème : un conteneur ne voit pas la radio

Un conteneur partage le noyau de l’hôte, donc une carte sans fil présente sur l’hôte est invisible à l’intérieur par défaut. iw dev ne renvoie rien. hostapd échoue avec une erreur de pilote qui se lit comme un paquet manquant.

Il y a deux solutions plausibles et une seule bonne :

  • macvlan — plausible, et cela ne fonctionne pas pour un AP : hostapd doit passer l’interface en mode AP, et les interfaces macvlan ne le peuvent pas.
  • Un passthrough de netdev physique — confier la carte réelle au conteneur. Le conteneur possède alors la radio et fait tout lui-même : mode AP, bridge, le tout.

La seconde est une option de conteneur de deux lignes :

# sur l'hôte Proxmox : /etc/pve/lxc/107.conf
lxc.net.1.type: phys
lxc.net.1.link: wlan0
lxc.net.1.name: wlan0
lxc.net.1.flags: up

C’est toute l’astuce. net0 reste sur le bridge LAN normal pour que le conteneur soit joignable et puisse atteindre l’internet ; net1 est la carte physique, qui apparaît à l’intérieur sous le nom wlan0.

Deux autres réglages de conteneur, tous deux nécessaires et pour des raisons différentes :

features: nesting=1
lxc.cgroup2.devices.allow: c 10:200 rwm

nesting=1 est ce qui permet au conteneur de configurer sa propre pile réseau comme hostapd l’attend. La ligne c 10:200 est /dev/net/tun, le périphérique caractère c 10:200 — le conteneur en a besoin pour monter un tunnel VPN, ce qui est précisément la raison de mettre ce réseau dans un conteneur. Sans cela, le client VPN échoue au démarrage avec une erreur de permissions et vous partez chercher au mauvais endroit.

Donnez au conteneur plus de RAM que ce que vous imagineriez pour ce travail : un point d’accès plus du DNS plus un tunnel, ce n’est pas lourd, mais 256 Mo, c’est inconfortablement juste.

hostapd

interface=wlan0
bridge=br0
driver=nl80211

ssid=lab-wifi
hw_mode=g
channel=1
wmm_enabled=1

# WPA2-PSK
auth_algs=1
wpa=2
wpa_key_mgmt=WPA-PSK
rsn_pairwise=CCMP
# wpa_passphrase=<your passphrase>
ieee80211n=1
ht_capab=[HT40+][SHORT-GI-20][SHORT-GI-40]

ctrl_interface=/var/run/hostapd

bridge=br0 fait un vrai travail : hostapd ajoute lui-même l’interface sans fil au bridge, donc le br0 du conteneur est l’uplink du point d’accès — tout ce qui a une adresse sur ce bridge route normalement, et vous n’avez jamais à scripter de la jonglerie d’interfaces.

driver=nl80211 est l’interface moderne vers la pile sans fil du noyau. hw_mode=g, c’est le 2,4 GHz, et avec cette carte ce n’est pas un choix : les cartes WiFi Intel n’autorisent le mode AP qu’en 2,4 GHz, et la demande de 40 MHz dans ht_capab n’est pas accordée non plus, donc le réseau tourne en 20 MHz. Cela fait 144 Mbit/s sur le papier et 50 à 70 Mbit/s partagés en pratique. Le 5 GHz demanderait un autre matériel : un adaptateur USB dont le chipset accepte le mode AP en 5 GHz, ou un point d’accès séparé relié au bridge.

ht_capab doit correspondre au canal. [HT40+] n’est valide que sur les canaux 1 à 9 et [HT40-] sur 5 à 13. Si vous vous trompez, hostapd refuse de démarrer (« Hardware does not support configured channel »), mais seulement au prochain démarrage : un changement de canal à chaud ne le vérifie pas. Je l’ai découvert quand le réseau est resté coupé après une modification de configuration.

ctrl_interface ne sert pas qu’à hostapd_cli ; c’est ce qui rend possibles les notifications client plus bas.

DHCP et DNS : dnsmasq

interface=br0
bind-interfaces
dhcp-range=10.42.0.100,10.42.0.200,12h
dhcp-option=option:router,10.42.0.1
dhcp-option=option:dns-server,10.42.0.1

no-resolv
server=1.1.1.1
server=1.0.0.1

log-queries
log-facility=/var/log/dnsmasq-queries.log
log-async

bind-interfaces compte ici : sans lui, dnsmasq écoute partout, y compris sur l’interface LAN, et le conteneur devient discrètement un serveur DNS pour tout votre réseau.

no-resolv plus des lignes server= explicites signifie que les résolveurs amont sont exactement ceux que vous avez écrits — sinon il lit aussi /etc/resolv.conf et vous avez un chemin de requête imprévisible.

Le journal des requêtes est la partie intéressante. log-queries avec un log-facility dédié vous donne un enregistrement par appareil de chaque requête sur le réseau : quel appareil a demandé quoi, et quand. Ce journal est le capteur de tout ce qui suit.

Bloquer au niveau DNS

Le blocage de domaines a sa place dans le résolveur, pas dans une extension de navigateur qui ne couvre qu’un seul appareil :

conf-file=/etc/dnsmasq-ads/adblock.conf

La liste elle-même est l’ensemble OISD, récupéré par un timer et réécrit sous la forme address=/domain/ de dnsmasq. Sans adresse, les noms bloqués reçoivent une réponse NXDOMAIN plutôt qu’une fausse IP :

#!/bin/bash
set -euo pipefail
URL="https://big.oisd.nl/"
TMP=$(mktemp)
timeout 180 wget -q -T 90 -O "$TMP" "$URL"
# ... rewrite into address=/…/ lines, then:
dnsmasq --test -C /etc/dnsmasq-ads/adblock.conf && systemctl reload dnsmasq

Deux choses rendent cela sûr à exécuter sans surveillance. Écrivez dans un fichier temporaire et ne remplacez le fichier actif que si le téléchargement a réussi — une liste tronquée à cause d’un timeout ne doit pas anéantir votre blocage. Et faites toujours dnsmasq --test avant de recharger : une config générée malformée fait échouer le rechargement, et un rechargement raté signifie plus de DNS pour tous les appareils de ce réseau.

Savoir quand un appareil se connecte

hostapd_cli -a exécute un script à chaque événement du point d’accès :

ExecStart=/usr/sbin/hostapd_cli -i wlan0 -a /usr/local/sbin/ap-sta-event.sh

Le script se déclenche à la connexion et à la déconnexion, et le chemin de connexion résout le nouveau client en quelque chose de lisible :

AP-STA-CONNECTED)
  mac="$2"
  sleep 2                                  # laisser dnsmasq enregistrer le bail d'abord
  line=$(grep -i "$mac" /var/lib/misc/dnsmasq.leases | tail -1)
  ip=$(awk '{print $3}' <<<"$line")
  hostname=$(awk '{print $4}' <<<"$line")
  ;;

Le sleep 2 n’est pas de la superstition. L’événement d’association se déclenche avant que DHCP ne se termine, donc lire le fichier des baux immédiatement ne donne rien — vous obtenez une adresse MAC et pas de nom, à chaque fois. Deux secondes suffisent pour que le bail apparaisse et transforment une MAC opaque en « le téléphone de quelqu’un vient de se connecter ».

Surveiller les requêtes malveillantes

Le journal des requêtes fait un capteur bon marché : taillez-le, comparez les domaines à un flux de menaces, et rapportez. Le mien est délibérément alert-only — il journalise, il ne bloque pas :

syslog.openlog("dns-threat", 0, syslog.LOG_DAEMON)
syslog.syslog(syslog.LOG_INFO, f"started: {len(threats)} threat domains loaded, alert-only mode")

et la sortie est en logfmt — client=… device=… domain=… — pour que le pipeline de journaux puisse extraire les champs sans une regex par ligne. La liste de menaces se rafraîchit selon son propre timer.

L’alerte seule est un choix qui mérite d’être défendu. Une blocklist qui bloque silencieusement finira par bloquer quelque chose dont vous avez besoin, au pire moment, et vous n’aurez aucune idée de pourquoi. L’alerte vous donne la même information sans le mode de défaillance, et si un appareil de votre réseau émet vraiment des balises vers un domaine connu comme malveillant, la notification est de toute façon la partie utile.

« Le WiFi est lent »

Quand quelqu’un dit ça, il y a trois suspects : la radio, le VPN et le DNS. Mesurez-les séparément avant de changer quoi que ce soit.

Le tunnel, depuis l’intérieur du conteneur, sans la radio dans le chemin :

curl --interface tun0 -o /dev/null -w '%{speed_download}\n' \
  'https://speed.cloudflare.com/__down?bytes=25000000'

Le mien mesurait 220 à 420 Mbit/s à travers le VPN, avec 13 ms de latence et aucune perte, et le DNS répondait en 14 ms sans cache. La radio était donc le goulot d’étranglement, et le problème de la radio, c’était le voisinage : le canal 6 portait quatorze autres réseaux, dont neuf puissants.

Une carte Intel en mode AP ne peut pas scanner les autres canaux (iw dev wlan0 scan renvoie « Operation not supported »), mais elle peut se déplacer. hostapd annonce un changement de canal aux clients, qui suivent sans se déconnecter :

hostapd_cli -i wlan0 chan_switch 5 2412 ht     # 2412 MHz = channel 1, 2437 = 6, 2462 = 11
sleep 20
iw dev wlan0 scan dump                          # networks seen on the new channel

Le canal 1 avait sept voisins dont deux puissants ; le canal 11 avait un réseau caché à −24 dBm et le WiFi Direct d’une télévision. Ce fut donc le canal 1, inscrit dans hostapd.conf avec le ht_capab correspondant.

Une mesure qui n’aide pas : pinguer les téléphones par la radio. L’économie d’énergie d’iOS produit des pics de 150 à 300 ms sur une liaison parfaitement saine. Les compteurs de retransmissions par station (iw dev wlan0 station dump) et un test de débit sur le téléphone sont de meilleurs signaux.

Quand le tunnel meurt

Le client VPN fait tourner OpenVPN avec --persist-tun, qui garde l’interface du tunnel et ses routes actives entre les reconnexions. C’est normalement ce qu’on veut. Après un redémarrage de la box, pourtant, cela a produit un interblocage : la session était morte, mais la route par défaut pointait toujours dans le tunnel. La résolution DNS de son propre serveur par OpenVPN partait elle aussi dans le tunnel mort, donc il ne pouvait jamais se reconnecter. Tous les clients WiFi étaient hors ligne, et aucune alerte ne s’est déclenchée, parce que l’interface était « up ».

Deux changements :

  • « Up » veut désormais dire que le trafic passe. La métrique qu’utilisent le tableau de bord et l’alerte est un ping à travers tun0 vers deux résolveurs publics, pas l’état de l’interface.
  • Un timer de surveillance. Chaque minute, si rien n’est passé dans le tunnel depuis trois minutes, il redémarre le service VPN, au plus une fois toutes les cinq minutes. Il journalise chaque décision dans syslog, donc son historique est dans le pipeline de journaux.
if ping -c1 -W3 -I tun0 1.1.1.1 >/dev/null || ping -c1 -W3 -I tun0 9.9.9.9 >/dev/null; then
    rm -f "$STATE/down-since"; exit 0
fi
# ...record when it went down; after 180 s, and not within 300 s of the last attempt:
systemctl restart vpn-client.service

Pièges

  • macvlan ne fonctionnera pas pour un point d’accès. Passez le périphérique physique en passthrough.
  • La carte est mobilisée. Dès que le conteneur la possède, l’hôte ne peut plus l’utiliser comme client — prévoyez le matériel en conséquence.
  • WPA2 uniquement, tel quel. wpa=2 est du WPA2-PSK ; si vous voulez du WPA3, il vous faut wpa_key_mgmt=SAE et ieee80211w, et les appareils plus anciens risquent de ne plus se connecter.
  • bind-interfaces. Oubliez-le et vous faites tourner du DNS pour tout le LAN.
  • Recharger une blocklist cassée met le réseau par terre. Validez avant de recharger.
  • Pas de /dev/net/tun signifie pas de VPN. Ajoutez l’autorisation du périphérique, et n’attendez pas d’erreur claire si vous l’oubliez.
  • Le conteneur a besoin que le pilote soit chargé avant de démarrer. Après un redémarrage brutal de l’hôte, iwlwifi ne s’est pas chargé automatiquement, il n’y avait pas de wlan0 à transmettre, et le conteneur n’a pas démarré. /etc/modules-load.d/iwlwifi.conf sur l’hôte le charge désormais à chaque démarrage.
  • Cartes Intel : 2,4 GHz seulement en mode AP. Choisissez le canal par relevé, et gardez ht_capab cohérent avec lui.
  • Une interface active n’est pas un tunnel qui marche. Testez avec du trafic, et redémarrez en cas d’échec.
  • Les logs du noyau sont indisponibles dans un conteneur. imklog ne peut pas lire /proc/kmsg sans CAP_SYSLOG, donc les avertissements du pilote sans fil sur l’hôte ne sont pas consultables depuis l’intérieur — déboguez-les depuis l’hôte Proxmox.

cd ~/blog