Aller au contenu

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)

sudo iptables -L OUTPUT -v --line-numbers

Vous devez voir :

  • en tête (ligne 1) la règle ACCEPT udp -- anywhere anywhere udp dpt:domain et son équivalent TCP (DNS),
  • la règle ACCEPT all -- anywhere anywhere sur l'interface lo (loopback),
  • les ACCEPT vers la passerelle Docker et la plage intra-stack 172.30.0.0/24 (réseau Docker custom kirexo, défini dans compose.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)

sudo iptables -t nat -L OUTPUT -v

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

castor devcontainer:firewall-reload

É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 — un AVERTISSEMENT ici 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).

# Depuis l'hôte uniquement — castor doctor utilise le démon Docker
castor doctor

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é :

[<timestamp ISO 8601>] [<source>] <message>

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 invalide signalent des entrées invalides dans la baseline ou dans whitelist.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 — un N plus 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 :

  1. Le client utilise un resolver alternatif (cache OS local type nscd, entrée dans /etc/hosts, resolver applicatif…) qui shortcut dnsmasq. Forcez la résolution via dnsmasq : dig @127.0.0.2 <domaine>. L'IP retournée doit apparaître dans l'ipset immédiatement après.
  2. Le domaine n'est pas dans la directive ipset=… de dnsmasq — vérifiez cat /etc/dnsmasq.d/firewall-ipset.conf et regardez si une ligne ipset=/votre-domaine/.../allowed-domains couvre votre cas.
  3. Le client utilise IPv6 — le pare-feu filtre IPv4 via iptables + ipset ; côté IPv6, init-firewall.sh pose des policies ip6tables INPUT/OUTPUT/FORWARD DROP en ceinture de sécurité (les sysctls de compose.devcontainer.yaml désactivent déjà IPv6 — net.ipv6.conf.all.disable_ipv6=1 —, l'ip6tables DROP est un belt-and-suspenders). Si IPv6 a été réactivé manuellement et qu'aucune règle ACCEPT v6 n'existe, tout sortant v6 est rejeté ; à l'inverse, si on désactive aussi ip6tables, 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 → écrivez foo.com.
  • Wildcard : *.foo.com → le préfixe /domain/ de dnsmasq matche déjà tous les sous-domaines, écrivez foo.com.
  • Chemin / port : foo.com/api ou foo.com:8080 → écrivez foo.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 :

sudo /app/.devcontainer/firewall-healthcheck.sh && echo OK || echo KO
  • OK → la policy OUTPUT DROP est posée, le drift est ailleurs (revenir aux étapes 3 à 5).
  • KO → la chaîne OUTPUT n'a plus sa policy DROP. 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.txt permet 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.1 depuis l'hôte.

Voir aussi