• Docker
  • Bonnes pratiques pour un hôte Docker en "prod"

Dans les sources du script de bench sécurité du CIS, j'ai trouvé ceci :

Alternatively, you can follow the Docker documentation and create a custom network and only
join containers that need to communicate to that custom network. The --icc parameter only applies
to the default docker bridge, if custom networks are used then the approach of segmenting networks
should be adopted instead.

Donc à priori effectivement ça ne s'applique qu'au default.

J'ai trouvé ceci aussi pour vérifier si l'ICC est actif ou non :

Get ICC setting for a specific network
docker inspect -f '{{index .Options "com.docker.network.bridge.enable_icc"}}' [network]

Et ceci pour créer le réseau en désactivant l'ICC (ne peut être fait qu'à la création du réseau) :

Create a network and explicitly enable ICC
docker network create -o com.docker.network.bridge.enable_icc=true [network]

Il faut que je teste si la désactivation de l'ICC dans le daemon.json avant la création de nouveaux réseaux applique automatiquement la désactivation de l'ICC

Je vais revoir ce point, passer le backend en internal (le paramètre est aussi valable pour un bridge ? Je n'ai trouvé des utilisations qu'avec overlay jusqu'à présent mais je n'ai qu'un hôte Docker).

Visiblement oui. Dans mon docker-compose quand je spécifie un réseau qui doit être isolé de l'extérieur, j'utilise internal: true.

Les réseaux configurés au sein d'un même docker-compose n'ont pas de visibilité entre eux ?

Tu peux avoir plein de réseaux dans un même compose, ça ne pose pas de soucis. La visibilité c'est dès lors que 2 ou plus de conteneurs sont dans un même réseau. (Ou si tu utilises l'option host, mais déconseillé sauf cas particulier.)

Pour iptables, je suis loin d'être un expert Linux mais tu utilises quoi du coup ?

J'ai un iptables.conf basique comme ça :

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

-F INPUT
-F DOCKER-USER
-F FILTERS

-A INPUT -i lo -j ACCEPT
-A INPUT -j FILTERS

-A DOCKER-USER -i eno1 -j FILTERS

# Allow existing connections
-A FILTERS -m state --state ESTABLISHED,RELATED -j ACCEPT

# Allow ping
-A FILTERS -p icmp --icmp-type echo-request -j ACCEPT

# Allow HTTP
-A FILTERS -p tcp --dport 8080 -m conntrack --ctstate NEW --ctorigdstport 80 -j ACCEPT
-A FILTERS -p tcp --dport 4430 -m conntrack --ctstate NEW --ctorigdstport 443 -j ACCEPT

# Allow containers to interact with full addresses
-A FILTERS -m state --state NEW -p tcp --dport 80 -j ACCEPT
-A FILTERS -m state --state NEW -p tcp --dport 443 -j ACCEPT

# Allow SSH
-A FILTERS -m state --state NEW -p tcp --dport 22 -j ACCEPT

# Reject everything else
-A FILTERS -j REJECT --reject-with icmp-host-prohibited

COMMIT

C'est à titre d'exemple, j'ai viré des besoins perso, mais tu vois l'idée. (Et mes règles HTTP/HTTPS sont bizarres car mon conteneur Traefik est non-root.)

Il faut que je teste si la désactivation de l'ICC dans le daemon.json avant la création de nouveaux réseaux applique automatiquement la désactivation de l'ICC

De ce que j'avais compris l'ICC en tant qu'option n'a plus de sens dès lors que tu gères tes user-defined bridges. C'était surtout valable à l'époque quand il n'y avait qu'un seul bridge, etc.

J'attendrais xataz pour t'en dire plus dessus. 🙂

Merci pour les réponses @wonderyan, on va attendre le passage de @xataz pour avoir plus d'infos 🙂

Pour l'ICC s'il est actif sur tous les réseaux et si le --link est déprécié je ne vois pas comment tu peux faire communiquer les containers entre eux. Vu que chez toi l'option est active dans le daemon.json tu n'as pas de problème de communication inter container (si tu utilises un traefik par exemple) ?

Comme dit, ce paramètre ne s'applique a priori qu'au bridge par défaut. C'était une option plus ou moins indispensable avant que Docker ne se développe, car par défaut, tous les conteneurs étaient sur le même réseau. Pas terrible niveau isolation, donc c'était mieux de tout restreindre et d'utiliser des --link pour décider qui communique avec qui.

Docker a évolué depuis et comme dit, tu peux créer tes propres réseaux bridge (user-defined bridge). C'est la façon "moderne" de faire communiquer des conteneurs entre eux. C'est aussi simple que ça :

docker network create mon_reseau
docker run -d --network mon_reseau --name conteneur_1 image_1
docker run -d --network mon_reseau --name conteneur_2 image_2

Et l'équivalent docker-compose est possible aussi.
Et pouf les 2 conteneurs vont pouvoir communiquer ensemble. Les user-defined bridge ont la particularité de permettre une résolution DNS via Docker (contrairement au bridge par défaut qui utilisait /etc/hosts). Par exemple dans conteneur_1, tu peux tout à fait ping/curl conteneur_2 par exemple. A noter qu'avec des runtimes isolés, cette résolution DNS ne fonctionnera pas, donc il faut passer par des IP statiques, c'est pour ça je ne conseille pas pour l'instant de changer de runtime.

Par exemple pour Traefik, tu peux créer un réseau app_frontend et ajouter Traefik et ton app à ce réseau, que tu précises à Traefik via le label sur l'app. Et imaginons que tu veuilles un réseau interne à ton app pour qu'elle communique avec un conteneur SQL, tu ajouteras les deux à un réseau commun (avec le flag --internal pour l'isoler de l'extérieur).

En résumé, te casse pas la tête avec l'ICC, les --link et tout, fais des réseaux par groupe de conteneurs qui doivent communiquer entre eux !

Merci encore @Wonderfall
Je vais regarder tout ça et avancer sur le sujet, une dernière question sur le daemon.json. Est-ce que tu as testé les paramètres suivants, recommandés par le CIS d'après ce que j'ai pu lire :

{
"icc": false, # point déjà abordé, à gérer par la création de réseaux
"userns-remap": "default", # à mettre en place (si non utilisation de runtime)
"userland-proxy": false, # ? option assez floue pour moi
"iptables" : true, # à activer pour que docker puisse manipuler iptables ?!
"no-new-privileges": true, # ? un peu abstrait pour moi en utilisation réelle
"log-driver" : "syslog", # pas d'importance sur le fonctionnement de docker
"live-restore": true # à activer pour que les containers puissent continuer à fonctionner si problème de daemon
}

Merci !

Pas de soucis !

Comme tu peux le voir dans mon daemon.json, j'ai activé :

  • live-restore : cette fonction permet effectivement aux conteneurs de continuer à fonctionner sans que le daemon Docker soit actif. En effet Docker est une "coquille" qui orchestre le tout à l'aide des OCI runtimes (runc par défaut donc), donc c'est pertinent de pouvoir le mettre à jour sans tout devoir redémarrer.
  • userland-proxy : historiquement Docker utilisait une application dans l'espace utilisateur pour gérer les connexions des conteneurs, mais c'est une surface d'attaque supplémentaire. Docker moderne prend en charge iptables à la place (pas activé par défaut car iptables dépend des kernel, et quand c'est pas à jour...).
  • no-new-privileges : c'est une protection supplémentaire (utile avec runc) pour éviter des escalations de privilèges dans le conteneur. Par exemple, pour éviter qu'un utilisateur devienne root. Je conseille d'activer cette option aussi, je ne l'ai pas dans mon daemon.json car je le précise dans mes docker-compose (security-opt, je te laisse Google ça).

Le reste on l'a déjà abordé. 🙂

Et encore merci @Wonderfall il ne me reste plus qu'à mettre tout ça en application 😉
Donc le userland-proxy est à mettre à false et iptables à true, les 2 vont de paire si je comprends bien.

Je vais m'attaquer à ça cette semaine, si tu as d'autres astuces ou d'autres options (sécurité ou autre) à activer dans les docker-compose (comme no-new-privileges) je suis preneur aussi 🙂 merci !

    julienth37 Chacun fait ce qu'il veut je passe pas m'a nuit a regarder mes alertes. Si le serveur s'emballe sur l'espace disque c'est une simple solution pour débloquer rapidement l'espace disque et travailler correctement. Je ne dis en rien qu'un système d'alerte n'est pas utile.

    NicCo Notre discussion m'a inspiré à faire un article pour détailler mon aventure avec les runtimes si ça t'intéresse : https://wonderfall.space/gvisor-kata-containers/

    Après m'être renseigné longuement et avoir discuté avec quelques chercheurs sur des chans Matrix obscurs, je conseille vivement de s'intéresser à gVisor, c'est vraiment le futur ce truc. 🙂

      Génial cet article @Wonderfall
      Est-ce que tu penses que c'est jouable pour un débutant comme moi de se lancer sur gVisor ? Dans ce cas tu n'utilises pas les userns dans le daemon.json ?

        NicCo Je pense que c'est jouable, c'est pas dur à installer, ça demande en soit pas de configuration par défaut. Le but de gVisor c'est vraiment d'apporter des conteneurs sandboxés à tout le monde sans les défauts des VM.

        Alors le problème, c'est que peut-être tes applications ne tourneront pas bien avec gVisor. Faut tester au cas par cas, si tout va bien pour toi, alors franchement je ne vois pas le défaut, t'as une sécurité digne de production.

        Du coup non, pas besoin de cette option comme expliqué ici.

        Merci @Wonderfall
        Du coup tu peux choisir d'utiliser le runtime avec certaines applis et de rester avec le runtime par défaut avec d'autres ?

          NicCo C'est expliqué dans l'article, mais en gros, avec Docker en CLI tu précises avec --runtime, et dans un docker-compose c'est tout simplement runtime:. Si tu précises rien, ça sera runc, celui de base.

            Wonderfall Ok merci, je pensais qu'une fois ajouté au daemon.json par défaut ça désactivait le runtime de base. Mais c'est vrai qu'en y réflechissant tu dis qu'on peut déclarer plusieurs runtimes

            4 jours plus tard

            Banip Je suis en train de tester l'UDP et j'ai quelques questions :

            • Si tu es sur un dédié la question ne se pose pas normalement mais à la maison, tu es quand même obligé de mettre en place un NAT du ou des ports que tu veux utiliser en UDP ?
            • Tu attaques sur l'adresse ts.domain.tld ou ts2.domain.tld mais tu ne déclares pas cette adresse dans ton teamspeak.yml ? Tu la déclares ailleurs ?
            • Tu n'exposes pas les ports 9987 et 9988 dans le docker-compose de ton traefik ?
            • Dans l'interface traefik, je n'ai rien dans UDP, alors que j'ai bien mes containers dans HTTP, c'est normal ?
            • Tu aurais un exemple de docker-compose pour ton serveur TS ?
              Merci !

              Wonderfall Merci pour l'article et notamment l'explication VS Kata. Je faisais tourner certains Dockers dans des VM (Whonix ou Qubes notamment) mais effectivement c'était plus du bricolage qu'autre chose pour la plupart (ne nécessitant pas d'anonymat spécifique surtout).

              NicCo Je suis en train de tester l'UDP et j'ai quelques questions :

              Pas de problème je vais essayer de répondre à tout, bonne lecture 🙂

              Si tu es sur un dédié la question ne se pose pas normalement mais à la maison, tu es quand même obligé de mettre en place un NAT du ou des ports que tu veux utiliser en UDP ?

              Je suis sur un dédié effectivement, si mon traefik était @home je ferai quand même tout passer par traefik.
              J'irais même plus loin et je mettrais tout ce qui est exposé au web en DMZ au cas ou quelqu'un réussisse à pirater ton serveur qu'il ne puisse pas redescendre dans le réseau domestique et soit arrêté dans la DMZ.

              Tu attaques sur l'adresse ts.domain.tld ou ts2.domain.tld mais tu ne déclares pas cette adresse dans ton teamspeak.yml ? Tu la déclares ailleurs ?

              Exacte, mon serveur fait aussi DNS j'ai donc deux entrées SRV dans ma déclaration DNS comme l'indique la documentation de teamspeak :

              ; A record
              chronos IN A XXX.XXX.XXX.XXX
              ; SRV record
              _ts3._udp.ts.domain.tld. 86400 IN SRV 0 5 9987 chronos
              _ts3._udp.ts2.domain.tld. 86400 IN SRV 0 5 9988 chronos

              Tu n'exposes pas les ports 9987 et 9988 dans le docker-compose de ton traefik ?

              Si tu es obligé si tu veux que traefik gère ton entrypoints après :

              version: "3.2"
              
              networks:
                traefik:
                  external:
                    name: traefik
              
              services:
                traefik:
                  image: traefik:v2.3.6
                  container_name: traefik
                  volumes:
                    - /path/file/static/traefik/:/etc/traefik/
                    - /var/run/docker.sock:/var/run/docker.sock:ro
                  ports:
                    - 80:80
                    - 443:443
                    - 21:21
                    - 9900-9999:9900-9999/udp # j'ai pour projet de gérer 100 serveurs teamspeak
                  networks:
                    - traefik
                  restart: unless-stopped

              Mais grâce aux entrées SRV quand la requête ts.domain.tld arrive au serveur on sait qu'en réalité on demande le port 9987 comme si tu tapais IP:9987 dans la requête. De l'autre coté dans la configuration de traefik j'ai deux entrypoints udp :

              entryPoints:
                web:
                  address: ":80"
                websecure:
                  address: ":443"
                ftp:
                  address: ":21"
                ts9987:
                  address: ":9987/udp"
                ts9988:
                  address: ":9988/udp"

              Et ensuite dans mon dossier conf que traefik watch j'ai le fichier teamspeak.yml :

              udp:
                services:
                  ts9987:
                    loadBalancer:
                      servers:
                      - address: "teamspeak:9987"
                  ts9988:
                    loadBalancer:
                      servers:
                      - address: "teamspeak:9988"
                routers:
                  ts9987:
                    entryPoints:
                      - "ts9987"
                    service: "ts9987"
                  ts9988:
                    entryPoints:
                      - "ts9988"
                    service: "ts9988"

              De cette manière les ports 9987 et 9988 ne sont pas en listening malgré qu'ils soient déclarés dans le docker-compose de traefik - 9900-9999:9900-9999/udp :

              root@chronos:~# netstat -ntpl |grep 9987
              root@chronos:~# netstat -ntpl |grep 9988

              Dans l'interface traefik, je n'ai rien dans UDP, alors que j'ai bien mes containers dans HTTP, c'est normal ?

              Dès que j'ai mon fichier teamspeak.yml dans mon dossier conf je vois bien mes routers UDP :
              Traefik
              Il faut fouiller dans les logs de traefik voir si tu n'as pas un problème dans ton fichier .yml, tu peux même prendre le mien et regarder si ça fonctionne.

              Tu aurais un exemple de docker-compose pour ton serveur TS ?

              Voici, j'ai mis quelques #annotations sur certains points :

              version: '3'
              
              services:
              
              #############
              # teamspeak #
              #############
                teamspeak:
                  image: teamspeak
                  container_name: teamspeak
                  ports:
                    - 9987 # Je laisse les ports que mes applications utilisent, cela n'expose en rien les ports du host vers le docker, c'est simplement pour pouvoir me souvenir si j'ai la tête dans les fichiers qui utilise quoi sans devoir me reconnecter au dashboard traefik.
                    - 30033
                    - 10011
                  environment:
                    - TS3SERVER_LICENSE=accept
                  volumes:
                    - /path/to/static/files/ts3:/var/ts3server
                  restart: always
                  networks:
                    - traefik # Bien penser à déclarer qu'on utilise le network traefik (et bien le créer avant) sinon le traefik ne verra pas le teamspeak.
              
              networks: # Obligé de redéclarer le netork traefik car ce fichier docker-compose est séparé du docker-compose de traefik
                traefik:
                  external:
                    name: traefik

              Merci !

              Content si j'ai pu t'aider 😄

              J'attire ton attention sur la doc des routeurs UDP de traefik, tu ne pourra pas faire fonctionner une application derrière sub.domain.tld si cette application ne fournit pas d'entrée SRV car traefik sur les routeur UDP ne gère pas de règle host SNI.

                Merci beaucoup Banip je regarde ça ce soir avec attention en rentrant 😉

                @Banip Je viens de tester, ça ne fonctionne pas avec Wireguard mais je viens de relire ta dernière phrase et Wireguard ne semble pas supporter les entrées SRV 🙁 du coup je ne peux pas le faire passer derrière Traefik ?

                @Banip Bon avec tes explications et en cherchant un peu j'ai réussi à le faire fonctionner mais sans utiliser une entrée SRV mais avec une entrée A classique. Je me connecte en UDP à travers Traefik, le client Wireguard demande une URL et un port. Quelle est la différence entre l'utilisation d'un enregistrement SRV et un A ? J'avoue que j'ai une utilisation basique du DNS, j'ai toujours plus ou moins utilisé seulement du A, CNAME et MX.
                J'ai bien vérifié, le port 51820 utilisé par Wireguard n'est pas en listening. J'ai également mis en place un OpenVPN en UDP aussi, les 2 semblent tourner sans problème en parallèle. Voici mes fichiers pour le moment (avec les 2 VPN en parallèle) :

                ports:
                  - 80:80
                  - 443:443
                  - 1194:1194/udp
                  - 51820:51820/udp
                entryPoints:
                  web:
                    address: ":80"
                    http:
                      redirections:
                        entryPoint:
                          to: websecure
                          scheme: https
                          permanent: true
                  websecure:
                    address: ":443"
                  openvpn:
                    address: ":1194/udp"
                  wireguard:
                    address: ":51820/udp"
                udp:
                  services:
                    wireguard:
                      loadBalancer:
                        servers:
                          - address: "wireguard:51820"
                
                  routers:
                    wireguard:
                      entryPoints:
                        - "wireguard"
                      services: "wireguard@file"
                tcp:
                  services:
                    openvpn_tcp:
                      loadBalancer:
                        servers:
                          - address: "openvpn_tcp:1194"
                          
                  routers:
                    openvpn_tcp:
                      rule: "HostSNI(`*`)"
                        entryPoints:
                          - "websecure"
                      service: "openvpn_tcp@file"
                                    
                udp:
                  services:
                    openvpn_udp:
                      loadBalancer:
                        servers:
                          - address: "openvpn_tcp:1194"
                      
                  routers:
                    openvpn_udp:
                      entryPoints:
                        - "openvpn"
                      service: "openvpn_udp@file"