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

5 min de lectureRead 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=6
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 sur le canal 6 est un choix 2,4 GHz délibérément conservateur ; le 5 GHz est plus rapide mais porte bien moins à travers les murs, ce qui est le mauvais compromis pour un réseau que des téléphones et des objets IoT vont utiliser.

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.

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.
  • 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