Debugger le pare-feu du Dev Container¶
Une commande dans le devcontainer renvoie un timeout, un Connection refused, un Network unreachable ou un comportement inattendu — et vous suspectez le pare-feu interne (cf. .devcontainer/init-firewall.sh). Cette recette enchaîne les vérifications de l'intérieur vers la sortie : isoler la cause, inspecter les règles iptables, l'ipset, dnsmasq, puis remettre les choses d'aplomb.
Prérequis¶
- Vous êtes dans le Dev Container (cf. le tutoriel PHPStorm).
- Vous savez quelle commande échoue (URL, message exact).
1. Vérifier la révision du script qui tourne¶
init-firewall.sh affiche en première ligne de sa sortie un header type :
==> init-firewall.sh — révision git abc1234 — 2026-01-15T12:34:56+00:00
Whitelist effective : 17 entrée(s) baseline + 2 entrée(s) locale(s).
Ces deux lignes apparaissent dans la sortie du postStartCommand et — quand vous appelez explicitement castor devcontainer:firewall-reload — dans le terminal qui a lancé la commande. Premier réflexe en debug : confronter le abc1234 affiché à git rev-parse --short HEAD côté hôte. Si vous travaillez sur une modif locale non commitée, le hash sera celui du dernier commit et pas votre version courante du script — l'écart explique souvent un comportement « inexplicable » après une édition.
Le second compteur (X entrée(s) baseline + Y entrée(s) locale(s)) permet de vérifier d'un coup d'œil que votre override local a bien été pris en compte. Si vous avez ajouté une ligne dans whitelist.local.txt et que Y = 0, le fichier n'est pas lu (typo dans le nom, mauvais bind-mount) ou toutes vos lignes ont été rejetées comme invalides — voir la section validation ci-dessous.
2. Confirmer que c'est bien le pare-feu¶
Le pare-feu rejette les connexions sortantes hors whitelist avec icmp-admin-prohibited. Selon l'outil client, ça se traduit par un timeout, un Connection refused ou un Network is unreachable. Premier test : comparer un domaine whitelisté connu et un domaine bloqué connu.
# Doit répondre (200/3xx)
curl -v --connect-timeout 5 https://symfony.com 2>&1 | head -20
# Doit échouer rapidement (le pare-feu rejette)
curl -v --connect-timeout 5 https://example.com 2>&1 | head -20
Si symfony.com répond et example.com est rejeté, le pare-feu fonctionne — le problème vient d'un domaine spécifique qui n'est pas dans la whitelist. Voir Whitelister un domaine.
Si les deux échouent, le problème est plus profond : dnsmasq est tombé, le DNAT vers 127.0.0.2:53 est cassé, ou la policy OUTPUT DROP n'a pas d'exceptions associées. Continuez les diagnostics ci-dessous.
3. Inspecter les règles iptables¶
Chaîne OUTPUT (trafic sortant)¶
Vous devez voir :
- en tête (ligne 1) la règle
ACCEPT udp -- anywhere anywhere udp dpt:domainet son équivalent TCP (DNS), - la règle
ACCEPT all -- anywhere anywheresur l'interfacelo(loopback), - les
ACCEPTvers la passerelle Docker et la plage intra-stack172.30.0.0/24(réseau Docker customkirexo, défini danscompose.override.yaml), - la règle clé :
ACCEPT all -- anywhere anywhere match-set allowed-domains dst— c'est elle qui laisse passer le trafic vers les domaines whitelistés, - en queue de chaîne,
REJECT all -- anywhere anywhere reject-with icmp-admin-prohibited— la borne par défaut.
Les colonnes pkts et bytes montent dès qu'une règle match. Si la règle match-set allowed-domains reste à 0 0 alors que vous lancez curl, c'est que la résolution DNS n'a pas poussé l'IP dans l'ipset — sautez à la section dnsmasq.
Table NAT (redirection DNS vers dnsmasq)¶
Vous devez voir deux règles DNAT (UDP et TCP) sur le port 53 vers 127.0.0.2:53. C'est ce qui force toutes les requêtes DNS du conteneur à passer par dnsmasq (qui alimente l'ipset). Si elles manquent, init-firewall.sh n'a pas terminé son exécution — relancez-le.
4. Inspecter l'ipset allowed-domains¶
L'ipset contient les IPs résolues pour les domaines whitelistés, ajoutées à la volée par dnsmasq au fil des résolutions DNS.
# Premières entrées (volumineuses : un domaine = plusieurs IPs souvent)
sudo ipset list allowed-domains | head -50
# Chercher une IP précise
sudo ipset list allowed-domains | grep <ip-recherchée>
# Compter les entrées
sudo ipset list allowed-domains | awk '/^Number of entries/ {print}'
Si l'ipset est vide : dnsmasq ne tourne pas, ou ne reçoit pas les requêtes. Si l'ipset est rempli mais ne contient pas l'IP du domaine que vous testez : le domaine n'est pas couvert par la directive ipset=/…/allowed-domains (ni dans .devcontainer/baseline-domains.txt, ni dans .devcontainer/whitelist.local.txt).
5. Inspecter dnsmasq¶
# dnsmasq tourne-t-il ?
pgrep -a dnsmasq
# Quels domaines sont déclarés dans sa config ?
cat /etc/dnsmasq.d/firewall-ipset.conf
Le fichier firewall-ipset.conf est généré par init-firewall.sh : il contient la liste ipset=/dom1/dom2/.../allowed-domains reconstruite à partir de .devcontainer/baseline-domains.txt + .devcontainer/whitelist.local.txt. Vérifiez que votre domaine y figure (avec le bon préfixe — dom.com matche aussi sub.dom.com).
Test direct d'une résolution via dnsmasq :
# Force la résolution via dnsmasq (127.0.0.2) — doit retourner une IP
dig @127.0.0.2 symfony.com +short
# Juste après, l'IP retournée doit apparaître dans l'ipset
sudo ipset list allowed-domains | grep <ip-retournée>
Si dig @127.0.0.2 … retourne connection timed out, dnsmasq est mort ou n'écoute pas sur 127.0.0.2. Relancez castor devcontainer:firewall-reload (qui relance dnsmasq).
6. Relancer proprement le pare-feu¶
Équivalent à sudo /app/.devcontainer/init-firewall.sh mais via la cible Castor (plus traçable). Le script flushe les règles, repose les policies DROP, recrée l'ipset, relance dnsmasq et rejoue les smoke-tests. À la fin, vous devez voir :
OK : example.com correctement bloqué(contrôle négatif bloquant),OK : https://gitlab.com/ accessible+ 3 autres lignes équivalentes (contrôles positifs non bloquants — unAVERTISSEMENTici signale un drift ou une panne externe, mais le pare-feu reste actif),OK : résolution intra-stack 'database'+ 5 autres (contrôles bloquants sur les services Compose).
7. Diagnostic global au niveau de la stack¶
Si vous voulez vérifier que tout l'environnement est cohérent (pas seulement le pare-feu), castor doctor enchaîne les vérifications (binaires hôte, plugin Compose, état running/healthy des services, migrations à jour).
8. Lire les logs var/log/devcontainer/frankenphp.log¶
Les scripts .devcontainer/*.sh (init-firewall, post-start, frankenphp-supervisor, php-healthcheck) loguent désormais via le helper commun firewall_log (défini dans .devcontainer/init-firewall.lib.sh). Format normalisé :
Le champ <source> correspond à la variable LOG_SOURCE posée par le script appelant (init-firewall, post-start, supervisor, php-healthcheck). Conséquence pratique : un seul tail -f sur var/log/devcontainer/frankenphp.log couvre toute la chaîne de démarrage et de runtime du pare-feu et de FrankenPHP, et les filtres grep deviennent simples :
# Toutes les lignes du supervisor FrankenPHP
grep '\[supervisor\]' var/log/devcontainer/frankenphp.log
# Tout ce qu'a écrit init-firewall.sh à son dernier reload
grep '\[init-firewall\]' var/log/devcontainer/frankenphp.log | tail -50
# Les auto-relances FrankenPHP déclenchées par le healthcheck
grep '\[php-healthcheck\]' var/log/devcontainer/frankenphp.log
Le timestamp ISO 8601 permet de corréler avec les logs Docker (docker compose logs php) sans avoir à reconstituer un fuseau ou un format. Si vous voyez une ligne sans préfixe (cas rare : sortie d'un sous-process invoqué par post-start.sh, par exemple le binaire frankenphp lui-même qui écrit sur stdout), c'est attendu — firewall_log ne s'applique qu'aux messages écrits par les scripts shell eux-mêmes, pas aux process fils qu'ils lancent.
Cas typiques¶
« Ça marchait, et plus depuis que j'ai modifié la whitelist »¶
La baseline vit dans .devcontainer/baseline-domains.txt — un format texte simple (un FQDN par ligne, # pour commenter). Les fautes de frappe classiques (URL avec schéma, wildcard *.foo, IP littérale) sont skipées avec un avertissement par init-firewall.sh plutôt que d'avorter le script. Relancez castor devcontainer:firewall-reload et lisez attentivement la sortie :
- des
AVERTISSEMENT : ligne rejetée — format invalidesignalent des entrées invalides dans la baseline ou danswhitelist.local.txt; - le récap
Baseline chargée depuis /app/.devcontainer/baseline-domains.txt (N domaine(s)).confirme combien d'entrées ont été retenues — unNplus petit que prévu pointe vers une ligne mal formée.
Si le pare-feu démarre mais les smoke-tests non bloquants émettent des avertissements pour gitlab.com ou symfony.com, vous avez peut-être supprimé une entrée nécessaire. Confirmez avec git diff .devcontainer/baseline-domains.txt pour voir ce qui a bougé.
« Un domaine résolu n'est jamais dans l'ipset »¶
Plusieurs explications possibles :
- Le client utilise un resolver alternatif (cache OS local type
nscd, entrée dans/etc/hosts, resolver applicatif…) qui shortcutdnsmasq. Forcez la résolution viadnsmasq:dig @127.0.0.2 <domaine>. L'IP retournée doit apparaître dans l'ipset immédiatement après. - Le domaine n'est pas dans la directive
ipset=…dednsmasq— vérifiezcat /etc/dnsmasq.d/firewall-ipset.confet regardez si une ligneipset=/votre-domaine/.../allowed-domainscouvre votre cas. - Le client utilise IPv6 — le pare-feu filtre IPv4 via
iptables+ipset; côté IPv6,init-firewall.shpose des policiesip6tables INPUT/OUTPUT/FORWARD DROPen ceinture de sécurité (les sysctls decompose.devcontainer.yamldésactivent déjà IPv6 —net.ipv6.conf.all.disable_ipv6=1—, l'ip6tables DROPest un belt-and-suspenders). Si IPv6 a été réactivé manuellement et qu'aucune règleACCEPTv6 n'existe, tout sortant v6 est rejeté ; à l'inverse, si on désactive aussiip6tables, IPv6 redevient ouvert. Restez sur la configuration par défaut.
« Toutes les sorties sont bloquées »¶
La policy OUTPUT DROP a été posée mais aucune règle ACCEPT n'est en place — init-firewall.sh n'a probablement pas fini son exécution (interrompu, erreur en cours de route). Vérifiez la sortie de post-start.sh :
tail -50 /tmp/post-start.log 2>/dev/null || echo "Pas de log post-start — relancez le script."
sudo /app/.devcontainer/post-start.sh
Ce script n'a pas de journal systemd : journalctl ne contient rien d'utile. Tout est dans /tmp/post-start.log (s'il existe) ou dans la sortie du relancement manuel.
« Mon domaine est dans whitelist.local.txt mais reste bloqué »¶
Depuis l'ajout de la validation côté shell, chaque ligne de .devcontainer/whitelist.local.txt est confrontée à la même regex FQDN que celle utilisée côté PHP (FirewallWhitelist::DOMAIN_REGEX). Une ligne invalide est skipée avec un avertissement plutôt que de casser le pare-feu. Dans la sortie de init-firewall.sh (visible via castor devcontainer:firewall-reload), cherchez :
AVERTISSEMENT : N ligne(s) rejetée(s) de /app/.devcontainer/whitelist.local.txt :
- « <ligne> » — format invalide (attendu : FQDN sans schéma, sans chemin, sans wildcard).
Causes habituelles :
- Schéma collé devant :
https://foo.com→ écrivezfoo.com. - Wildcard :
*.foo.com→ le préfixe/domain/de dnsmasq matche déjà tous les sous-domaines, écrivezfoo.com. - Chemin / port :
foo.com/apioufoo.com:8080→ écrivezfoo.com(le pare-feu filtre au niveau IP/port, le path n'est pas vu). - IP littérale :
1.2.3.4→ pas géré par dnsmasq, ouvrez une issue ou ajoutez une règle iptables dédiée.
Le compteur Whitelist effective : … + Y entrée(s) locale(s) du header confirme combien de lignes ont été retenues. Si Y ne correspond pas à votre compte, vous avez probablement une ligne rejetée. Pour valider en amont la prochaine fois, utilisez castor devcontainer:whitelist-add <domain> qui refuse l'entrée invalide avant écriture.
« Le pare-feu est tombé silencieusement (healthcheck rouge sans message clair) »¶
Le healthcheck du service php valide en quatrième condition que la policy OUTPUT DROP est toujours en place, via le wrapper sudo read-only firewall-healthcheck.sh (cf. Référence du Dev Container). Quand le conteneur bascule en unhealthy mais que dnsmasq tourne, que la config existe et que FrankenPHP répond, c'est probablement ce check qui a échoué — typique d'un iptables -F manuel pour debug et oublié, ou d'un script de tiers qui a flushé les règles.
Reproduisez le check à la main pour confirmer :
OK→ la policyOUTPUT DROPest posée, le drift est ailleurs (revenir aux étapes 3 à 5).KO→ la chaîneOUTPUTn'a plus sa policyDROP. Listez la chaîne pour vérifier (sudo iptables -S OUTPUT | head -5— la première ligne doit être-P OUTPUT DROP). Relancez le pare-feu pour reposer toutes les règles d'un coup :castor devcontainer:firewall-reload.
Le wrapper est volontairement minimal (une seule commande iptables -S OUTPUT | grep -q '^-P OUTPUT DROP') — il vérifie la policy par défaut, pas l'intégralité des règles d'exception. Un drift portant uniquement sur une règle ACCEPT spécifique (par exemple celle qui consulte l'ipset allowed-domains) passerait inaperçu de ce check, mais ferait échouer les requêtes sortantes et donc les contrôles positifs habituels au reload (voir le cas précédent sur les AVERTISSEMENT).
« J'ai des AVERTISSEMENT dans les smoke-tests positifs »¶
Depuis le passage en non-bloquant, les smoke-tests positifs (gitlab.com, symfony.com, packagist.org, registry.npmjs.org) émettent un AVERTISSEMENT au lieu d'échouer. Le pare-feu est actif et fonctionnel — mais une partie de la whitelist externe est temporairement injoignable. Causes habituelles :
- Panne externe : check Twitter/X de l'opérateur, attendez.
- Drift de whitelist : un domaine a peut-être disparu de la baseline —
git log -p .devcontainer/baseline-domains.txtpermet de retrouver une suppression récente. - Démarrage offline : le devcontainer s'est ouvert sans connexion internet. Vérifiez
curl -v https://1.1.1.1depuis l'hôte.
Voir aussi¶
- Whitelister un domaine — ajouter un domaine à la baseline ou en override personnel.
- Référence du Dev Container — liste complète des domaines whitelistés, fichiers, cycle de vie.
- Choix d'isolation du Dev Container — pourquoi un pare-feu interne plutôt que se reposer sur le sandbox Claude Code.