From 6dc2cc1e059d016456cff659e15673e61c3f6529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jarl=20Andr=C3=A9=20H=C3=BCbenthal?= Date: Wed, 22 Apr 2026 08:28:46 +0200 Subject: [PATCH] feat(connection): support TLS docker hosts via DOCKER_CERT_PATH Extend DockerUrl.construct/1 to handle https:// URLs and to promote tcp:// URLs to https:// when DOCKER_TLS_VERIFY is truthy, mirroring the Docker CLI. When the resolved URL is https, the Tesla/hackney adapter is configured with :ssl_options loaded from DOCKER_CERT_PATH (falling back to ~/.docker). Missing cert files are skipped with a debug log; verify mode follows DOCKER_TLS_VERIFY. Fixes #202 --- lib/connection/connection.ex | 65 ++++++++++++++++++ lib/connection/dockerurl.ex | 35 +++++++++- test/connection/docker_url_test.exs | 100 ++++++++++++++++++++++++++++ test/connection/tls_test.exs | 76 +++++++++++++++++++++ test/fixtures/docker_certs/ca.pem | 0 test/fixtures/docker_certs/cert.pem | 0 test/fixtures/docker_certs/key.pem | 0 7 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 test/connection/docker_url_test.exs create mode 100644 test/connection/tls_test.exs create mode 100644 test/fixtures/docker_certs/ca.pem create mode 100644 test/fixtures/docker_certs/cert.pem create mode 100644 test/fixtures/docker_certs/key.pem diff --git a/lib/connection/connection.ex b/lib/connection/connection.ex index 5f1ae6e7..1eed00ae 100644 --- a/lib/connection/connection.ex +++ b/lib/connection/connection.ex @@ -25,9 +25,74 @@ defmodule Testcontainers.Connection do user_agent: Constants.user_agent() ) + options = maybe_add_tls_options(options, docker_host_url) + {Connection.new(options), docker_host_url, docker_host} end + @doc """ + Builds the list of `:ssl_options` for a TLS-secured Docker daemon. + + Loads `ca.pem`, `cert.pem` and `key.pem` from `DOCKER_CERT_PATH` + (falling back to `~/.docker`). Missing files are skipped with a debug log. + When `DOCKER_TLS_VERIFY` is truthy, `verify: :verify_peer` is used; otherwise + `verify: :verify_none` with a warning log. + """ + def build_ssl_options do + cert_dir = cert_dir() + + ssl_options = [verify: verify_mode()] + + ssl_options + |> maybe_put_file(:cacertfile, Path.join(cert_dir, "ca.pem")) + |> maybe_put_file(:certfile, Path.join(cert_dir, "cert.pem")) + |> maybe_put_file(:keyfile, Path.join(cert_dir, "key.pem")) + end + + defp cert_dir do + case System.get_env("DOCKER_CERT_PATH") do + nil -> Path.expand("~/.docker") + "" -> Path.expand("~/.docker") + path -> path + end + end + + defp verify_mode do + if DockerUrl.tls_verify?() do + :verify_peer + else + Logger.warning( + "Docker TLS connection without DOCKER_TLS_VERIFY; peer certificate will NOT be verified" + ) + + :verify_none + end + end + + defp maybe_put_file(opts, key, path) do + if File.exists?(path) do + Keyword.put(opts, key, path) + else + Logger.debug("Docker TLS cert file #{path} not found; skipping #{key}") + opts + end + end + + defp maybe_add_tls_options(options, url) do + if DockerUrl.https?(url) do + ssl_options = build_ssl_options() + + adapter_opts = [ + recv_timeout: Keyword.get(options, :recv_timeout, @timeout), + ssl_options: ssl_options + ] + + Keyword.put(options, :adapter, {Tesla.Adapter.Hackney, adapter_opts}) + else + options + end + end + defp get_docker_host_url do case get_docker_host() do {:ok, docker_host} -> diff --git a/lib/connection/dockerurl.ex b/lib/connection/dockerurl.ex index 0300f968..1d517c0c 100644 --- a/lib/connection/dockerurl.ex +++ b/lib/connection/dockerurl.ex @@ -9,7 +9,17 @@ defmodule Testcontainers.DockerUrl do "http+unix://#{URI.encode_www_form(path)}" %URI{scheme: "tcp"} = uri -> - URI.to_string(%{uri | scheme: "http"}) + if tls_verify?() do + URI.to_string(%{uri | scheme: "https"}) + else + URI.to_string(%{uri | scheme: "http"}) + end + + %URI{scheme: "https"} = uri -> + URI.to_string(uri) + + %URI{scheme: "http"} = uri -> + URI.to_string(uri) %URI{scheme: _, authority: _} = uri -> uri @@ -27,4 +37,27 @@ defmodule Testcontainers.DockerUrl do {:error, reason} end end + + @doc """ + Returns true if `DOCKER_TLS_VERIFY` is set to a truthy value (`"1"` or `"true"`). + """ + def tls_verify? do + case System.get_env("DOCKER_TLS_VERIFY") do + "1" -> true + "true" -> true + _ -> false + end + end + + @doc """ + Returns true if the URL uses the `https` scheme. + """ + def https?(url) when is_binary(url) do + case URI.parse(url) do + %URI{scheme: "https"} -> true + _ -> false + end + end + + def https?(_), do: false end diff --git a/test/connection/docker_url_test.exs b/test/connection/docker_url_test.exs new file mode 100644 index 00000000..ac4993b6 --- /dev/null +++ b/test/connection/docker_url_test.exs @@ -0,0 +1,100 @@ +defmodule Testcontainers.DockerUrlTest do + # async: false because we mutate DOCKER_TLS_VERIFY + use ExUnit.Case, async: false + + alias Testcontainers.DockerUrl + + setup do + original = System.get_env("DOCKER_TLS_VERIFY") + + on_exit(fn -> + case original do + nil -> System.delete_env("DOCKER_TLS_VERIFY") + value -> System.put_env("DOCKER_TLS_VERIFY", value) + end + end) + + System.delete_env("DOCKER_TLS_VERIFY") + :ok + end + + describe "construct/1" do + test "unix sockets are encoded as http+unix" do + assert DockerUrl.construct("unix:///var/run/docker.sock") == + "http+unix://%2Fvar%2Frun%2Fdocker.sock" + end + + test "tcp:// without DOCKER_TLS_VERIFY becomes http://" do + assert DockerUrl.construct("tcp://127.0.0.1:2375") == "http://127.0.0.1:2375" + end + + test "tcp:// with DOCKER_TLS_VERIFY=1 becomes https://" do + System.put_env("DOCKER_TLS_VERIFY", "1") + assert DockerUrl.construct("tcp://127.0.0.1:2376") == "https://127.0.0.1:2376" + end + + test "tcp:// with DOCKER_TLS_VERIFY=true becomes https://" do + System.put_env("DOCKER_TLS_VERIFY", "true") + assert DockerUrl.construct("tcp://my.docker.host:2376") == "https://my.docker.host:2376" + end + + test "tcp:// with DOCKER_TLS_VERIFY=0 remains http://" do + System.put_env("DOCKER_TLS_VERIFY", "0") + assert DockerUrl.construct("tcp://127.0.0.1:2375") == "http://127.0.0.1:2375" + end + + test "https:// is passed through as string" do + assert DockerUrl.construct("https://my.docker.host:2376") == "https://my.docker.host:2376" + end + + test "http:// is passed through as string" do + assert DockerUrl.construct("http://127.0.0.1:2375") == "http://127.0.0.1:2375" + end + end + + describe "tls_verify?/0" do + test "returns true for \"1\"" do + System.put_env("DOCKER_TLS_VERIFY", "1") + assert DockerUrl.tls_verify?() + end + + test "returns true for \"true\"" do + System.put_env("DOCKER_TLS_VERIFY", "true") + assert DockerUrl.tls_verify?() + end + + test "returns false when unset" do + System.delete_env("DOCKER_TLS_VERIFY") + refute DockerUrl.tls_verify?() + end + + test "returns false for \"0\"" do + System.put_env("DOCKER_TLS_VERIFY", "0") + refute DockerUrl.tls_verify?() + end + + test "returns false for empty string" do + System.put_env("DOCKER_TLS_VERIFY", "") + refute DockerUrl.tls_verify?() + end + end + + describe "https?/1" do + test "true for https URL" do + assert DockerUrl.https?("https://host:2376") + end + + test "false for http URL" do + refute DockerUrl.https?("http://host:2375") + end + + test "false for http+unix URL" do + refute DockerUrl.https?("http+unix://%2Fvar%2Frun%2Fdocker.sock") + end + + test "false for non-string input" do + refute DockerUrl.https?(nil) + refute DockerUrl.https?(:foo) + end + end +end diff --git a/test/connection/tls_test.exs b/test/connection/tls_test.exs new file mode 100644 index 00000000..4a0ad76d --- /dev/null +++ b/test/connection/tls_test.exs @@ -0,0 +1,76 @@ +defmodule Testcontainers.Connection.TlsTest do + # async: false because we mutate DOCKER_CERT_PATH / DOCKER_TLS_VERIFY + use ExUnit.Case, async: false + + alias Testcontainers.Connection + + @fixture_dir Path.expand("../fixtures/docker_certs", __DIR__) + + setup do + original_cert_path = System.get_env("DOCKER_CERT_PATH") + original_tls_verify = System.get_env("DOCKER_TLS_VERIFY") + + on_exit(fn -> + restore_env("DOCKER_CERT_PATH", original_cert_path) + restore_env("DOCKER_TLS_VERIFY", original_tls_verify) + end) + + System.delete_env("DOCKER_CERT_PATH") + System.delete_env("DOCKER_TLS_VERIFY") + :ok + end + + describe "build_ssl_options/0" do + test "loads ca, cert and key files from DOCKER_CERT_PATH when they exist" do + System.put_env("DOCKER_CERT_PATH", @fixture_dir) + System.put_env("DOCKER_TLS_VERIFY", "1") + + opts = Connection.build_ssl_options() + + assert opts[:verify] == :verify_peer + assert opts[:cacertfile] == Path.join(@fixture_dir, "ca.pem") + assert opts[:certfile] == Path.join(@fixture_dir, "cert.pem") + assert opts[:keyfile] == Path.join(@fixture_dir, "key.pem") + end + + test "uses :verify_none when DOCKER_TLS_VERIFY is unset" do + System.put_env("DOCKER_CERT_PATH", @fixture_dir) + System.delete_env("DOCKER_TLS_VERIFY") + + opts = Connection.build_ssl_options() + + assert opts[:verify] == :verify_none + end + + test "skips missing cert files without crashing" do + empty_dir = Path.join(System.tmp_dir!(), "tc_empty_certs_#{:rand.uniform(1_000_000)}") + File.mkdir_p!(empty_dir) + System.put_env("DOCKER_CERT_PATH", empty_dir) + System.put_env("DOCKER_TLS_VERIFY", "1") + + try do + opts = Connection.build_ssl_options() + + assert opts[:verify] == :verify_peer + refute Keyword.has_key?(opts, :cacertfile) + refute Keyword.has_key?(opts, :certfile) + refute Keyword.has_key?(opts, :keyfile) + after + File.rm_rf!(empty_dir) + end + end + + test "falls back to ~/.docker when DOCKER_CERT_PATH is unset" do + System.delete_env("DOCKER_CERT_PATH") + System.put_env("DOCKER_TLS_VERIFY", "1") + + # Simply verify it does not crash and still returns a keyword list with :verify. + opts = Connection.build_ssl_options() + assert opts[:verify] == :verify_peer + assert is_list(opts) + end + end + + defp restore_env(key, nil), do: System.delete_env(key) + defp restore_env(key, value), do: System.put_env(key, value) +end diff --git a/test/fixtures/docker_certs/ca.pem b/test/fixtures/docker_certs/ca.pem new file mode 100644 index 00000000..e69de29b diff --git a/test/fixtures/docker_certs/cert.pem b/test/fixtures/docker_certs/cert.pem new file mode 100644 index 00000000..e69de29b diff --git a/test/fixtures/docker_certs/key.pem b/test/fixtures/docker_certs/key.pem new file mode 100644 index 00000000..e69de29b