Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions lib/connection/connection.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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} ->
Expand Down
35 changes: 34 additions & 1 deletion lib/connection/dockerurl.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
100 changes: 100 additions & 0 deletions test/connection/docker_url_test.exs
Original file line number Diff line number Diff line change
@@ -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
76 changes: 76 additions & 0 deletions test/connection/tls_test.exs
Original file line number Diff line number Diff line change
@@ -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
Empty file.
Empty file.
Empty file.
Loading