TLS dans un conteneur statique

Publié le 09/06/2024

Cet article est motivé par un problème rencontré sur l’interface Guichet de Deuxfleurs. Au moment d’intéragir avec l’API S3, on a cette erreur suivante :

Impossible d'effectuer la modification.
Put "https://garage.deuxfleurs.fr/bottin-pictures/d1e3607f-4b9c-45fa-9e11-ddf8eef48676-thumb": tls: failed to verify certificate: x509: certificate signed by unknown authority

Pour comprendre le problème, partons de cet exemple basique en Go :

package main

import (
  "net/http"
  "log"
)

func main() {
	_, err := http.Get("https://deuxfleurs.fr")
	if err != nil {
		log.Fatal(err)
	}
	log.Println("Success")
}

Ce bloc de code fait une requête HTTPS vers deuxfleurs.fr et log l’erreur si il y en a une, sinon il affiche Success.

On va le compiler en statique pour ne pas dépendre de la lib C locale :

CGO_ENABLED=0 go build main.go

Ce qui nous donne bien un executable statique :

$ file main
main: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=ctUIsYsrR2BtpR58vqRU/TI93T6hZlDxMBNqsplsv/QYD-xJaEDyWB0QaX6tSS/cDyvoEdvE3kZpdq8yCs3, with debug_info, not stripped

Si on le fait tourner en local, tout se passe bien :

2024/06/09 15:54:59 Success

Maintenant, puisqu’on nous avons un binaire statique qui ne dépend de rien, on va vouloir créer un conteneur Docker qui ne contient que ce fichier (alors que souvent on embarque une distribution de base comme Debian ou Alpine). Pour se faire, on écrit un Dockerfile avec deux étapes : une qui a les outils pour le build, et une qui ne contient que notre binaire :

FROM golang as builder
COPY main.go .
RUN CGO_ENABLED=0 go build -o /main main.go

FROM scratch
COPY --from=builder /main /main
CMD [ "/main" ]

On construit & lance le conteneur et… on reproduit l’erreur !

$ docker build -t tls-static-go .
$ docker run --rm -it tls-static-go
2024/06/09 14:02:37 Get "https://deuxfleurs.fr": tls: failed to verify certificate: x509: certificate signed by unknown authority

Pour comprendre la différence de comportement entre l’intérieur du conteneur et l’extérieur, on peut faire appel à strace et voir les fichiers que notre binaire essaie d’ouvrir :

$ strace -fe openat ./main
...
[pid  9066] openat(AT_FDCWD, "/etc/ssl/certs/ca-certificates.crt", O_RDONLY|O_CLOEXEC) = 7
[pid  9066] openat(AT_FDCWD, "/etc/ssl/certs", O_RDONLY|O_CLOEXEC) = 7
[pid  9066] openat(AT_FDCWD, "/etc/ssl/certs/ca-bundle.crt", O_RDONLY|O_CLOEXEC) = 7
...

En général ce fichier est fourni par les distributions, qui à ma connaissance majoritairement se basent sur le travail de Mozilla. On peut en lire plus à propos de la gestion de ces racines de confiance sur la page dédiée du wiki de Mozilla : CA/FAQ

Une façon de faire est donc de copier le fichier de notre builder dans notre conteneur final :

--- Dockerfile.old	2024-06-09 16:13:18.457415988 +0200
+++ Dockerfile	2024-06-09 16:12:59.494182932 +0200
@@ -4,5 +4,6 @@

 FROM scratch
 COPY --from=builder /main /main
+COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
 CMD [ "/main" ]

On rebuild, on relance et… ça marche !

$ docker build -t tls-static-go-2 .
$ docker run --rm -it tls-static-go-2
2024/06/09 14:14:30 Success

Pour visualiser le contenu d’une image docker, on peut utiliser l’outil dive :

Dive

À gauche, puis à droite, les commandes sont :

dive tls-static-go
dive tls-static-go-2

On voit bien l’ajout du chemin /etc/ssl/certs/ca-certificates.crt à droite. Maintenant, le problème avec les Dockerfile, c’est que ce ne sont pas du tout des builds reproductibles ni précis : on ne sait pas quelles versions ou dépendances on embarque. Donc on veut plutôt construire nos conteneurs avec NixOS pour plus de contrôle.

Pour faciliter le packaging Nix, on va générer un fichier go.mod :

go mod init tls-static-go
go mod tidy

On peut ensuite créer un fichier flake.nix qui est notre équivalent – mais plus précis – de notre Dockerfile :

{
  description = "TLS Static Golang";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/master";
  };

  outputs = { self, nixpkgs }:
    let
      # On configure "le dépôt Nix"
      pkgs = import nixpkgs {
        system = "x86_64-linux";
      };

      # On utilise le builder Go intégré à Nix pour construire notre app
      tls-static = pkgs.buildGoModule {
        pname = "tls-static";
        version = "0.1.0";
        src = ./.;
	vendorHash = null;
        CGO_ENABLED = 0;
      };

      # On construit une image Docker qui ne contient que l'app qu'on a build.
      container = pkgs.dockerTools.buildImage {
        name = "superboum/tls-static-go";
        copyToRoot = tls-static;
        config = {
          Cmd = [ "/bin/tls-static" ];
        };
      };
    in
      {
        # Par défaut, sous Linux amd64, on construit le conteneur
        packages.x86_64-linux.default = container;
      };
}

Et ensuite on peut construire / charger / lancer le conteneur Docker :

$ nix build
$ docker load <result
Loaded image: superboum/tls-static-go:zvsb5pwz25irhr90x10kpfhgsph5is1s
$ docker run --rm -it superboum/tls-static-go:zvsb5pwz25irhr90x10kpfhgsph5is1s
2024/06/09 14:37:57 Get "https://deuxfleurs.fr": tls: failed to verify certificate: x509: certificate signed by unknown authority

Et de nouveau la même erreur, on jette un coup d’oeil avec dive :

Dive 2

On voit qu’on a quelques dépendances nix de tirées (à propos des fuseaux horaires par exemple) mais rien lié aux certificats. On va donc injecter là aussi ces certificats dans le conteneur, et pour ce faire on réalise la modification suivante :

--- flake.nix.old	2024-06-10 09:42:48.871184290 +0200
+++ flake.nix	2024-06-10 09:41:48.011959988 +0200
@@ -24,7 +24,10 @@
       # On construit une image Docker qui ne contient que l'app qu'on a build.
       container = pkgs.dockerTools.buildImage {
         name = "superboum/tls-static-go";
-        copyToRoot = tls-static;
+        copyToRoot = pkgs.buildEnv {
+          name = "tls-static-env";
+          paths = [ tls-static pkgs.cacert ];
+      	};
         config = {
           Cmd = [ "/bin/tls-static" ];
         };

Autrement dit, on a créé notre système de fichiers racine en fusionnant le contenu de notre build tls-static avec celui du paquet NixOS cacert.

On peut ensuite rebuild / load / run le conteneur avec… succès !

$ nix build
$ docker load <result
Loaded image: superboum/tls-static-go:02bmar6x0q1gi8b6j5ys6j1l84mn5vwh
$ docker run --rm -it superboum/tls-static-go:02bmar6x0q1gi8b6j5ys6j1l84mn5vwh
2024/06/09 14:45:06 Success

Quant à Dive, on voit bien que le bundle de certificat a été correctement injecté au bon endroit :

Dive 3

Et voilà, vous avez une image Docker fonctionnelle, minimaliste, reproductible, avec des dépendances correctement déclarées, et maintenable.