Chez Sysnove, nous utilisons shorewall pour gérer la configuration du firewall de nos serveurs. Avec l'arrivée de Docker dans nos infrastructures, nous avons dû travailler sur une solution pour faire cohabiter les deux, Docker ajoutant ses propres règles via iptables directement.

Nous avions réussi à faire cohabiter les deux au prix d'une configuration compliquée de shorewall, impliquant de devoir ajouter des règles pour chaque conteneur. Trop compliqué. Et avec Swarm, c'était devenu impossible à gérer facilement puisque les conteneurs sont dispatchés entre les hosts. Nous avons donc, pendant un temps, abandonné la gestion d'un firewall devant les hosts Docker.

Mais nous sommes tombé·es sur cet article qui nous a permis de trouver la solution adéquate pour finalement sécuriser correctement nos hosts tout en gardant la flexibilité de Docker.

Docker et les règles iptables

C'est un fait, la puissance de Docker provient grandement de la gestion qu'il fait du parefeu, permettant l'isolation des conteneurs, la création de réseaux isolés entre les nœuds et le réseau swarm permettant de contacter un conteneur, peu importe l'hôte qui le fait tourner. Et pour cela, il s'appuie sur des règles iptables.

La solution naïve pour faire cohabiter Docker avec des outils de gestion de firewall tels que shorewall ou ufw est de ne pas laisser Docker gérer ces règles via l'option iptables à false dans daemon.json et de le faire soi-même. Dans le contexte de Docker seul, c'est envisageable (et encore, il vaut mieux que le cas d'utilisation soit simple). Mais le cas de swarm est tellement complexe qu'il est impensable de vouloir remplacer la gestion du firewall par Docker via des configurations shorewall.

Il faudrait donc voir s'il est possible d'ajouter nos propres règles sans toucher à celles de Docker et vice-versa. Et une option de iptables-restore permet cela.

L'option --noflush de iptables-restore

Dans cet article, l'auteur nous propose l'utilisation de l'option --noflush de iptables-restore. Dans les faits, Docker utilise les tables nat et filter et dans cette dernière, il n'utilise que les chaines FORWARD, DOCKER-INGRESS, DOCKER-ISOLATION-STAGE-1 et DOCKER-ISOLATION-STAGE-2. Il nous laisse entièrement libres de jouer avec les chaînes INPUT et OUTPUT.

Il créée également la chaîne DOCKER-USER, sans l'utiliser lui-même, dont nous détaillerons l'utilisation plus tard.

La solution pour ne pas écraser les modifications de firewall de Docker est donc de ne pas écraser les chaînes qu'il utilise. L'option --noflush est parfaite dans ce cas car nous pouvons donc flusher les chaînes que nous voulons et laisser les autres intactes lors d'un iptables-restore. Et tout ce que nous voulons, en premier lieu, c'est de sécuriser la chaîne INPUT.

Le début de notre configuration iptables sera donc de cette forme :

*filter
:INPUT ACCEPT [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
:DOCKER-USER - [0:0]

-F INPUT
-F DOCKER-USER

COMMIT

Nous fixons les mêmes policies que Docker fixe par défaut. L'idée étant toujours de pouvoir relancer indépendament iptables-retore et docker sans qu'ils ne se perturbent mutuellement. Nous flushons INPUT, vu que nous allons l'utiliser pour sécuriser notre serveur, mais également DOCKER-USER.

Sécuriser INPUT

Nous voulons interdire par défaut l'accès au serveur. Ne pouvant pas mettre la policy à DROP pour ne pas être perturbé·es par le fonctionnement de Docker, nous allons simplement nous servir d'une règle par défaut, après avoir ajouté une règle nous permettant de nous connecter par SSH, évitons de nous couper la main :

-A INPUT -i lo -j ACCEPT
-A INPUT -p icmp --icmp-type any -j ACCEPT
-A INPUT -i eth0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A INPUT -i eth0 -m conntrack --ctstate NEW -m tcp -p tcp --dport 22 -j ACCEPT
-A INPUT -i eth0 -j DROP

On en profite également pour autoriser ping et le trafic sur la boucle locale. L'information importante, c'est la précision de l'interface eth0. En effet, le seul filtre qui nous intéresse, c'est de l'extérieur vers l'hôte. Pour bien faire, nous allons créer une chaîne FILTERS supplémentaire et renvoyer tout le trafic qui entre par eth0 dans cette chaîne. Ce qui donne une configuration plus claire :

-A INPUT -i lo -j ACCEPT
-A INPUT -p icmp --icmp-type any -j ACCEPT
-A INPUT -i eth0 -j FILTERS

-A FILTERS -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A FILTERS -m conntrack --ctstate NEW -m tcp -p tcp --dport 22 -j ACCEPT
-A FILTERS -j DROP

Nous avons un hôte pour lequel nous n'autorisons que ping et ssh depuis l'extérieur. Il reste néanmoins à autoriser le trafic provenant des autres hôtes dans le cadre d'un cluster swarm, si le trafic passe par la patte publique du serveur. Pour faire propre, on rajoute une chaine WHITELIST.

-A INPUT -i lo -j ACCEPT
-A INPUT -p icmp --icmp-type any -j ACCEPT
-A INPUT -i eth0 -j WHITELIST
-A INPUT -i eth0 -j FILTERS

-A WHITELIST -s ip-server-1 -j ACCEPT
-A WHITELIST -s ip-server-2 -j ACCEPT
-A WHITELIST -s ip-server-3 -j ACCEPT
-A WHITELIST -j RETURN

-A FILTERS -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A FILTERS -m conntrack --ctstate NEW -m tcp -p tcp --dport 22 -j ACCEPT
-A FILTERS -j DROP

Ce qui donne finalement comme configuration dans /etc/iptables.conf :

*filter
:INPUT ACCEPT [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
:WHITELIST - [0:0]
:FILTERS - [0:0]
:DOCKER-USER - [0:0]

-F INPUT
-F DOCKER-USER
-F WHITELIST
-F FILTERS

-A INPUT -i lo -j ACCEPT
-A INPUT -p icmp --icmp-type any -j ACCEPT
-A INPUT -i eth0 -j WHITELIST
-A INPUT -i eth0 -j FILTERS

-A WHITELIST -s ip-server-1 -j ACCEPT
-A WHITELIST -s ip-server-2 -j ACCEPT
-A WHITELIST -s ip-server-3 -j ACCEPT
-A WHITELIST -j RETURN

-A DOCKER-USER -j RETURN

-A FILTERS -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A FILTERS -m conntrack --ctstate NEW -m tcp -p tcp --dport 22 -j ACCEPT
-A FILTERS -j DROP

COMMIT

Et les accès aux conteneurs ?

C'est là que la magie de Docker opère. Le trafic vers les conteneurs ne passe pas par la chaîne INPUT. Tout ce trafic est d'abord traité par la table NAT et tout passe ensuite par la chaîne FORWARD, gérée par Docker. Donc de notre côté nous n'avons rien à faire pour autoriser ce trafic.

Et la chaîne DOCKER-USER ?

Nous n'avons pas parlé de la chaîne DOCKER-USER. Docker fait passer le trafic de FORWARD vers DOCKER-USER en tout début de chaîne. Elle sert donc à rajouter des règles que nous mettrions dans FORWARD et que Docker ne toucherait pas. On peut donc interagir avec le trafic en direction des conteneurs.

Il est tout à fait possible de restreindre l'accès à certains ports ou certains conteneurs pour une liste précise d'IPs sources par exemple. Nous vous laissons imaginer les possibilités d'une telle mécanique :).

Un petit service systemd pour la route ?

Pour appliquer ces règles on appelle la commande suivante iptables-restore -n /etc/iptables.conf.

On peut placer cet appel dans un unit oneshot systemd qu'il faudra activer au démarrage du serveur :

[Unit]
Description=Restore iptables firewall rules
Before=network-pre.target

[Service]
Type=oneshot
ExecStart=/sbin/iptables-restore -n /etc/iptables.conf

[Install]
WantedBy=multi-user.target

Conclusion

Ce qui était un point sensible de nos infrastructures est maintenant bien sécurisé. Finalement, comme le dit l'auteur du billet qui nous a inspiré·es, nous avons enfin une réponse à la question du firewalling devant Docker Swarm.

À propos de l'auteur



Laetitia a rejoint l'équipe de Sysnove début 2017. Administratrice système depuis 2013, elle a fait ses armes à la production et à la R&D dans plusieurs grands comptes en traversant diverses fusions et acquisitions.

Chez Sysnove, son rôle consiste à mettre en place et à administrer les infrastructures nécessaires au bon fonctionnement des services fournis aux clients. Elle est aussi responsable de l'amélioration et de l'innovation.

Elle nous a quitté depuis, pour s'orienter vers d'autres projets, mais ses articles sont toujours là :)