Skip to content

Commit cd08154

Browse files
authored
Add mTLS runtime configuration for Docker API client (#14)
* Add working mTLS support to Ruby transport * Sync Sorbet signatures for mTLS transport changes * Document TLS client certificate configuration * Fix OpenSSL digest deprecation in mTLS test
1 parent e0ff1e2 commit cd08154

11 files changed

Lines changed: 246 additions & 15 deletions

File tree

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,25 @@ On timeout, `DockerEngineRuby::Errors::APITimeoutError` is raised.
107107

108108
Note that requests that time out are retried by default.
109109

110+
### TLS / mTLS
111+
112+
For Docker Engine over TLS (`https://...:2376`), you can provide CA, client certificate, and client key paths:
113+
114+
```ruby
115+
docker = DockerEngineRuby::Client.new(
116+
base_url: "https://localhost:2376",
117+
tls_ca_cert_path: "/path/to/ca.pem",
118+
tls_client_cert_path: "/path/to/cert.pem",
119+
tls_client_key_path: "/path/to/key.pem"
120+
)
121+
```
122+
123+
You can also configure these through environment variables:
124+
125+
- `DOCKER_TLS_CA_CERT_PATH`
126+
- `DOCKER_TLS_CLIENT_CERT_PATH`
127+
- `DOCKER_TLS_CLIENT_KEY_PATH`
128+
110129
## Advanced concepts
111130

112131
### BaseModel

lib/docker_engine_ruby/client.rb

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@ class Client < DockerEngineRuby::Internal::Transport::BaseClient
2020
ENVIRONMENTS = {production: "http://localhost:2375", production_tls: "https://localhost:2376"}
2121
# rubocop:enable Style/MutableConstant
2222

23+
# Path to the trusted CA certificate file (PEM) used to verify the Docker daemon
24+
# certificate.
25+
# @return [String, nil]
26+
attr_reader :tls_ca_cert_path
27+
28+
# Path to the client TLS certificate file (PEM).
29+
# @return [String, nil]
30+
attr_reader :tls_client_cert_path
31+
32+
# Path to the client TLS private key file (PEM).
33+
# @return [String, nil]
34+
attr_reader :tls_client_key_path
35+
2336
# @return [DockerEngineRuby::Resources::Auth]
2437
attr_reader :auth
2538

@@ -67,6 +80,15 @@ class Client < DockerEngineRuby::Internal::Transport::BaseClient
6780

6881
# Creates and returns a new client for interacting with the API.
6982
#
83+
# @param tls_ca_cert_path [String, nil] Path to the trusted CA certificate file (PEM) used to verify the Docker daemon
84+
# certificate. Defaults to `ENV["DOCKER_TLS_CA_CERT_PATH"]`
85+
#
86+
# @param tls_client_cert_path [String, nil] Path to the client TLS certificate file (PEM). Defaults to
87+
# `ENV["DOCKER_TLS_CLIENT_CERT_PATH"]`
88+
#
89+
# @param tls_client_key_path [String, nil] Path to the client TLS private key file (PEM). Defaults to
90+
# `ENV["DOCKER_TLS_CLIENT_KEY_PATH"]`
91+
#
7092
# @param environment [:production, :production_tls, nil] Specifies the environment to use for the API.
7193
#
7294
# Each environment maps to a different base URL:
@@ -85,6 +107,9 @@ class Client < DockerEngineRuby::Internal::Transport::BaseClient
85107
#
86108
# @param max_retry_delay [Float]
87109
def initialize(
110+
tls_ca_cert_path: ENV["DOCKER_TLS_CA_CERT_PATH"],
111+
tls_client_cert_path: ENV["DOCKER_TLS_CLIENT_CERT_PATH"],
112+
tls_client_key_path: ENV["DOCKER_TLS_CLIENT_KEY_PATH"],
88113
environment: nil,
89114
base_url: ENV["DOCKER_BASE_URL"],
90115
max_retries: self.class::DEFAULT_MAX_RETRIES,
@@ -97,12 +122,19 @@ def initialize(
97122
raise ArgumentError.new(message)
98123
end
99124

125+
@tls_ca_cert_path = tls_ca_cert_path&.to_s
126+
@tls_client_cert_path = tls_client_cert_path&.to_s
127+
@tls_client_key_path = tls_client_key_path&.to_s
128+
100129
super(
101130
base_url: base_url,
102131
timeout: timeout,
103132
max_retries: max_retries,
104133
initial_retry_delay: initial_retry_delay,
105-
max_retry_delay: max_retry_delay
134+
max_retry_delay: max_retry_delay,
135+
tls_ca_cert_path: @tls_ca_cert_path,
136+
tls_client_cert_path: @tls_client_cert_path,
137+
tls_client_key_path: @tls_client_key_path
106138
)
107139

108140
@auth = DockerEngineRuby::Resources::Auth.new(client: self)

lib/docker_engine_ruby/internal/transport/base_client.rb

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,16 +197,26 @@ def reap_connection!(status, stream:)
197197
# @param max_retry_delay [Float]
198198
# @param headers [Hash{String=>String, Integer, Array<String, Integer, nil>, nil}]
199199
# @param idempotency_header [String, nil]
200+
# @param tls_ca_cert_path [String, nil]
201+
# @param tls_client_cert_path [String, nil]
202+
# @param tls_client_key_path [String, nil]
200203
def initialize(
201204
base_url:,
202205
timeout: 0.0,
203206
max_retries: 0,
204207
initial_retry_delay: 0.0,
205208
max_retry_delay: 0.0,
206209
headers: {},
207-
idempotency_header: nil
210+
idempotency_header: nil,
211+
tls_ca_cert_path: nil,
212+
tls_client_cert_path: nil,
213+
tls_client_key_path: nil
208214
)
209-
@requester = DockerEngineRuby::Internal::Transport::PooledNetRequester.new
215+
@requester = DockerEngineRuby::Internal::Transport::PooledNetRequester.new(
216+
tls_ca_cert_path: tls_ca_cert_path,
217+
tls_client_cert_path: tls_client_cert_path,
218+
tls_client_key_path: tls_client_key_path
219+
)
210220
@headers = DockerEngineRuby::Internal::Util.normalized_headers(
211221
self.class::PLATFORM_HEADERS,
212222
{

lib/docker_engine_ruby/internal/transport/pooled_net_requester.rb

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ class << self
1717
# @api private
1818
#
1919
# @param cert_store [OpenSSL::X509::Store]
20+
# @param tls_cert [OpenSSL::X509::Certificate, nil]
21+
# @param tls_key [OpenSSL::PKey::PKey, nil]
2022
# @param url [URI::Generic]
2123
#
2224
# @return [Net::HTTP]
23-
def connect(cert_store:, url:)
25+
def connect(cert_store:, tls_cert:, tls_key:, url:)
2426
port =
2527
case [url.port, url.scheme]
2628
in [Integer, _]
@@ -35,7 +37,11 @@ def connect(cert_store:, url:)
3537
_1.use_ssl = %w[https wss].include?(url.scheme)
3638
_1.max_retries = 0
3739

38-
(_1.cert_store = cert_store) if _1.use_ssl?
40+
if _1.use_ssl?
41+
_1.cert_store = cert_store
42+
_1.cert = tls_cert if tls_cert
43+
_1.key = tls_key if tls_key
44+
end
3945
end
4046
end
4147

@@ -105,7 +111,7 @@ def build_request(request, &blk)
105111
pool =
106112
@mutex.synchronize do
107113
@pools[origin] ||= ConnectionPool.new(size: @size) do
108-
self.class.connect(cert_store: @cert_store, url: url)
114+
self.class.connect(cert_store: @cert_store, tls_cert: @tls_cert, tls_key: @tls_key, url: url)
109115
end
110116
end
111117

@@ -194,10 +200,32 @@ def execute(request)
194200
# @api private
195201
#
196202
# @param size [Integer]
197-
def initialize(size: self.class::DEFAULT_MAX_CONNECTIONS)
203+
# @param tls_ca_cert_path [String, nil]
204+
# @param tls_client_cert_path [String, nil]
205+
# @param tls_client_key_path [String, nil]
206+
def initialize(
207+
size: self.class::DEFAULT_MAX_CONNECTIONS,
208+
tls_ca_cert_path: nil,
209+
tls_client_cert_path: nil,
210+
tls_client_key_path: nil
211+
)
198212
@mutex = Mutex.new
199213
@size = size
200214
@cert_store = OpenSSL::X509::Store.new.tap(&:set_default_paths)
215+
@cert_store.add_file(tls_ca_cert_path) if tls_ca_cert_path
216+
217+
if tls_client_cert_path || tls_client_key_path
218+
if tls_client_cert_path.nil? || tls_client_key_path.nil?
219+
raise ArgumentError.new("Both tls_client_cert_path and tls_client_key_path must be provided together.")
220+
end
221+
222+
@tls_cert = OpenSSL::X509::Certificate.new(File.read(tls_client_cert_path))
223+
@tls_key = OpenSSL::PKey.read(File.read(tls_client_key_path))
224+
else
225+
@tls_cert = nil
226+
@tls_key = nil
227+
end
228+
201229
@pools = {}
202230
end
203231

rbi/docker_engine_ruby/client.rbi

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,19 @@ module DockerEngineRuby
1919
T::Hash[Symbol, String]
2020
)
2121

22+
# Path to the trusted CA certificate file (PEM) used to verify the Docker daemon
23+
# certificate.
24+
sig { returns(T.nilable(String)) }
25+
attr_reader :tls_ca_cert_path
26+
27+
# Path to the client TLS certificate file (PEM).
28+
sig { returns(T.nilable(String)) }
29+
attr_reader :tls_client_cert_path
30+
31+
# Path to the client TLS private key file (PEM).
32+
sig { returns(T.nilable(String)) }
33+
attr_reader :tls_client_key_path
34+
2235
sig { returns(DockerEngineRuby::Resources::Auth) }
2336
attr_reader :auth
2437

@@ -67,6 +80,9 @@ module DockerEngineRuby
6780
# Creates and returns a new client for interacting with the API.
6881
sig do
6982
params(
83+
tls_ca_cert_path: T.nilable(String),
84+
tls_client_cert_path: T.nilable(String),
85+
tls_client_key_path: T.nilable(String),
7086
environment: T.nilable(T.any(Symbol, String)),
7187
base_url: T.nilable(String),
7288
max_retries: Integer,
@@ -76,6 +92,15 @@ module DockerEngineRuby
7692
).returns(T.attached_class)
7793
end
7894
def self.new(
95+
# Path to the trusted CA certificate file (PEM) used to verify the Docker daemon
96+
# certificate. Defaults to `ENV["DOCKER_TLS_CA_CERT_PATH"]`
97+
tls_ca_cert_path: ENV["DOCKER_TLS_CA_CERT_PATH"],
98+
# Path to the client TLS certificate file (PEM). Defaults to
99+
# `ENV["DOCKER_TLS_CLIENT_CERT_PATH"]`
100+
tls_client_cert_path: ENV["DOCKER_TLS_CLIENT_CERT_PATH"],
101+
# Path to the client TLS private key file (PEM). Defaults to
102+
# `ENV["DOCKER_TLS_CLIENT_KEY_PATH"]`
103+
tls_client_key_path: ENV["DOCKER_TLS_CLIENT_KEY_PATH"],
79104
# Specifies the environment to use for the API.
80105
#
81106
# Each environment maps to a different base URL:

rbi/docker_engine_ruby/internal/transport/base_client.rbi

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,10 @@ module DockerEngineRuby
165165
)
166166
)
167167
],
168-
idempotency_header: T.nilable(String)
168+
idempotency_header: T.nilable(String),
169+
tls_ca_cert_path: T.nilable(String),
170+
tls_client_cert_path: T.nilable(String),
171+
tls_client_key_path: T.nilable(String)
169172
).returns(T.attached_class)
170173
end
171174
def self.new(
@@ -175,7 +178,10 @@ module DockerEngineRuby
175178
initial_retry_delay: 0.0,
176179
max_retry_delay: 0.0,
177180
headers: {},
178-
idempotency_header: nil
181+
idempotency_header: nil,
182+
tls_ca_cert_path: nil,
183+
tls_client_cert_path: nil,
184+
tls_client_key_path: nil
179185
)
180186
end
181187

rbi/docker_engine_ruby/internal/transport/pooled_net_requester.rbi

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,16 @@ module DockerEngineRuby
2727
class << self
2828
# @api private
2929
sig do
30-
params(cert_store: OpenSSL::X509::Store, url: URI::Generic).returns(
30+
params(
31+
cert_store: OpenSSL::X509::Store,
32+
tls_cert: T.nilable(OpenSSL::X509::Certificate),
33+
tls_key: T.nilable(OpenSSL::PKey::PKey),
34+
url: URI::Generic
35+
).returns(
3136
Net::HTTP
3237
)
3338
end
34-
def connect(cert_store:, url:)
39+
def connect(cert_store:, tls_cert:, tls_key:, url:)
3540
end
3641

3742
# @api private
@@ -73,9 +78,19 @@ module DockerEngineRuby
7378
end
7479

7580
# @api private
76-
sig { params(size: Integer).returns(T.attached_class) }
81+
sig do
82+
params(
83+
size: Integer,
84+
tls_ca_cert_path: T.nilable(String),
85+
tls_client_cert_path: T.nilable(String),
86+
tls_client_key_path: T.nilable(String)
87+
).returns(T.attached_class)
88+
end
7789
def self.new(
78-
size: DockerEngineRuby::Internal::Transport::PooledNetRequester::DEFAULT_MAX_CONNECTIONS
90+
size: DockerEngineRuby::Internal::Transport::PooledNetRequester::DEFAULT_MAX_CONNECTIONS,
91+
tls_ca_cert_path: nil,
92+
tls_client_cert_path: nil,
93+
tls_client_key_path: nil
7994
)
8095
end
8196
end

sig/docker_engine_ruby/client.rbs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ module DockerEngineRuby
1313
production_tls: "https://localhost:2376"
1414
}
1515

16+
attr_reader tls_ca_cert_path: String?
17+
18+
attr_reader tls_client_cert_path: String?
19+
20+
attr_reader tls_client_key_path: String?
21+
1622
attr_reader auth: DockerEngineRuby::Resources::Auth
1723

1824
attr_reader system_: DockerEngineRuby::Resources::System
@@ -44,6 +50,9 @@ module DockerEngineRuby
4450
attr_reader distribution: DockerEngineRuby::Resources::Distribution
4551

4652
def initialize: (
53+
?tls_ca_cert_path: String?,
54+
?tls_client_cert_path: String?,
55+
?tls_client_key_path: String?,
4756
?environment: :production | :production_tls | nil,
4857
?base_url: String?,
4958
?max_retries: Integer,

sig/docker_engine_ruby/internal/transport/base_client.rbs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@ module DockerEngineRuby
8282
?headers: ::Hash[String, (String
8383
| Integer
8484
| ::Array[(String | Integer)?])?],
85-
?idempotency_header: String?
85+
?idempotency_header: String?,
86+
?tls_ca_cert_path: String?,
87+
?tls_client_cert_path: String?,
88+
?tls_client_key_path: String?
8689
) -> void
8790

8891
private def user_agent: -> String

sig/docker_engine_ruby/internal/transport/pooled_net_requester.rbs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ module DockerEngineRuby
1919

2020
def self.connect: (
2121
cert_store: OpenSSL::X509::Store,
22+
tls_cert: OpenSSL::X509::Certificate?,
23+
tls_key: OpenSSL::PKey::PKey?,
2224
url: URI::Generic
2325
) -> top
2426

@@ -41,7 +43,12 @@ module DockerEngineRuby
4143
DockerEngineRuby::Internal::Transport::PooledNetRequester::request request
4244
) -> [Integer, top, Enumerable[String]]
4345

44-
def initialize: (?size: Integer) -> void
46+
def initialize: (
47+
?size: Integer,
48+
?tls_ca_cert_path: String?,
49+
?tls_client_cert_path: String?,
50+
?tls_client_key_path: String?
51+
) -> void
4552
end
4653
end
4754
end

0 commit comments

Comments
 (0)