dash 2.12.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/kamal/cli/app/boot.rb +47 -11
- data/lib/kamal/cli/app/rollout_boot.rb +59 -0
- data/lib/kamal/cli/app/ssl_certificates.rb +12 -3
- data/lib/kamal/cli/app.rb +74 -7
- data/lib/kamal/cli/base.rb +41 -32
- data/lib/kamal/cli/doctor/config_checks.rb +28 -0
- data/lib/kamal/cli/doctor/endpoint_checks.rb +114 -0
- data/lib/kamal/cli/doctor/host_checks.rb +178 -0
- data/lib/kamal/cli/doctor.rb +112 -0
- data/lib/kamal/cli/healthcheck/drift_error.rb +7 -0
- data/lib/kamal/cli/healthcheck/poller.rb +60 -10
- data/lib/kamal/cli/main.rb +63 -0
- data/lib/kamal/cli/proxy/drift.rb +39 -0
- data/lib/kamal/cli/proxy/loadbalancer_claim.rb +70 -0
- data/lib/kamal/cli/proxy/loadbalancer_reboot.rb +87 -0
- data/lib/kamal/cli/proxy/reboot.rb +173 -0
- data/lib/kamal/cli/proxy.rb +201 -34
- data/lib/kamal/cli/prune.rb +7 -4
- data/lib/kamal/cli/templates/sample_hooks/post-app-stop.sample +9 -0
- data/lib/kamal/cli/templates/sample_hooks/post-proxy-deploy.sample +3 -0
- data/lib/kamal/cli/templates/sample_hooks/pre-app-stop.sample +12 -0
- data/lib/kamal/cli/templates/sample_hooks/pre-proxy-deploy.sample +3 -0
- data/lib/kamal/cli.rb +1 -0
- data/lib/kamal/commands/app/proxy.rb +12 -0
- data/lib/kamal/commands/app.rb +8 -0
- data/lib/kamal/commands/base.rb +11 -1
- data/lib/kamal/commands/docker.rb +5 -0
- data/lib/kamal/commands/loadbalancer.rb +76 -34
- data/lib/kamal/commands/proxy.rb +111 -11
- data/lib/kamal/commands/prune.rb +16 -2
- data/lib/kamal/commands/server.rb +5 -0
- data/lib/kamal/configuration/accessory.rb +9 -8
- data/lib/kamal/configuration/boot.rb +38 -7
- data/lib/kamal/configuration/docs/accessory.yml +28 -1
- data/lib/kamal/configuration/docs/boot.yml +17 -2
- data/lib/kamal/configuration/docs/configuration.yml +2 -1
- data/lib/kamal/configuration/docs/proxy.yml +844 -24
- data/lib/kamal/configuration/docs/role.yml +86 -0
- data/lib/kamal/configuration/loadbalancer.rb +81 -6
- data/lib/kamal/configuration/proxy/acme.rb +69 -0
- data/lib/kamal/configuration/proxy/run.rb +194 -5
- data/lib/kamal/configuration/proxy.rb +495 -18
- data/lib/kamal/configuration/role/healthcheck.rb +95 -0
- data/lib/kamal/configuration/role.rb +104 -1
- data/lib/kamal/configuration/validator/proxy.rb +623 -10
- data/lib/kamal/configuration/validator/role.rb +26 -0
- data/lib/kamal/configuration/validator.rb +26 -3
- data/lib/kamal/configuration.rb +197 -0
- data/lib/kamal/sshkit_with_ext.rb +27 -2
- data/lib/kamal/utils.rb +22 -2
- data/lib/kamal/version.rb +1 -1
- data/lib/kamal.rb +11 -0
- metadata +18 -2
|
@@ -3,9 +3,14 @@ class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator
|
|
|
3
3
|
unless config.nil?
|
|
4
4
|
super
|
|
5
5
|
|
|
6
|
-
# Skip SSL host validation when a loadbalancer is present
|
|
7
|
-
#
|
|
8
|
-
|
|
6
|
+
# Skip SSL host validation when a loadbalancer is present (SSL is
|
|
7
|
+
# disabled when using a loadbalancer) or when a ssl_domains source
|
|
8
|
+
# provides the hostnames at runtime.
|
|
9
|
+
# On-demand TLS joins loadbalancer and ssl_domains as a source of hostnames
|
|
10
|
+
# that only exist at handshake time - demanding a static host here would
|
|
11
|
+
# reject the one shape kamal-proxy requires for it.
|
|
12
|
+
if config["host"].blank? && config["hosts"].blank? && config["ssl"] && config["loadbalancer"].blank? &&
|
|
13
|
+
config.dig("ssl_domains", "source").blank? && ssl_config["on_demand_url"].blank?
|
|
9
14
|
error "Must set a host to enable automatic SSL"
|
|
10
15
|
end
|
|
11
16
|
|
|
@@ -13,19 +18,41 @@ class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator
|
|
|
13
18
|
error "Specify one of 'host' or 'hosts', not both"
|
|
14
19
|
end
|
|
15
20
|
|
|
21
|
+
if config.key?("loadbalancer")
|
|
22
|
+
validate_loadbalancer! config["loadbalancer"]
|
|
23
|
+
end
|
|
24
|
+
|
|
16
25
|
if config["ssl"].is_a?(Hash)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
end
|
|
26
|
+
validate_ssl_hash! config["ssl"]
|
|
27
|
+
end
|
|
20
28
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
# Truthiness, not present? — an empty hash must still fail the
|
|
30
|
+
# "Missing source" check rather than silently disable the feature.
|
|
31
|
+
if config["ssl_domains"]
|
|
32
|
+
validate_ssl_domains! config["ssl_domains"]
|
|
24
33
|
end
|
|
25
34
|
|
|
35
|
+
if config["basic_auth"].is_a?(Hash)
|
|
36
|
+
validate_basic_auth! config["basic_auth"]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if config["cache"].is_a?(Hash)
|
|
40
|
+
validate_cache! config["cache"]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if config["compress"].is_a?(Hash)
|
|
44
|
+
validate_compress! config["compress"]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
validate_access_control!
|
|
48
|
+
validate_traffic_shaping!
|
|
49
|
+
validate_canonical_host!
|
|
50
|
+
validate_lifecycle!
|
|
51
|
+
validate_tuning!
|
|
52
|
+
|
|
26
53
|
if run_config = config["run"]
|
|
27
54
|
if run_config["bind_ips"].present?
|
|
28
|
-
ensure_valid_bind_ips(
|
|
55
|
+
ensure_valid_bind_ips(run_config["bind_ips"])
|
|
29
56
|
end
|
|
30
57
|
|
|
31
58
|
if run_config["publish"] == false
|
|
@@ -33,11 +60,597 @@ class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator
|
|
|
33
60
|
error "Cannot set http_port, https_port or bind_ips when publish is false"
|
|
34
61
|
end
|
|
35
62
|
end
|
|
63
|
+
|
|
64
|
+
if run_config["acme"].is_a?(Hash)
|
|
65
|
+
validate_acme! run_config["acme"]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if run_config["cache"].is_a?(Hash)
|
|
69
|
+
validate_cache_store! run_config["cache"]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
validate_run_server_options! run_config
|
|
36
73
|
end
|
|
37
74
|
end
|
|
38
75
|
end
|
|
39
76
|
|
|
40
77
|
private
|
|
78
|
+
# `true` picks the primary role's first host, `false` opts out of the
|
|
79
|
+
# auto-activation that kicks in for a multi-host primary role, and a string
|
|
80
|
+
# names the host to run the load balancer on — which may be a dedicated host
|
|
81
|
+
# outside `servers:`, so we only check its shape.
|
|
82
|
+
def validate_loadbalancer!(loadbalancer)
|
|
83
|
+
with_context("loadbalancer") do
|
|
84
|
+
return if loadbalancer == true || loadbalancer == false
|
|
85
|
+
|
|
86
|
+
unless loadbalancer.is_a?(String) && loadbalancer.present? && !loadbalancer.match?(/[\s,]/)
|
|
87
|
+
error "should be true, false, or a single host"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_ssl_domains!(ssl_domains)
|
|
93
|
+
with_context("ssl_domains") do
|
|
94
|
+
source = ssl_domains["source"]
|
|
95
|
+
|
|
96
|
+
if source.blank?
|
|
97
|
+
error "Missing source setting (required when ssl_domains is set)"
|
|
98
|
+
elsif !valid_ssl_domains_source?(source)
|
|
99
|
+
error "source must be a path starting with '/' or an http(s) URL"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if (interval = ssl_domains["interval"]) && (!interval.is_a?(Integer) || interval < 1)
|
|
103
|
+
error "interval must be a positive integer"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if (batch_size = ssl_domains["batch_size"]) && (!batch_size.is_a?(Integer) || !batch_size.between?(1, 25))
|
|
107
|
+
error "batch_size must be an integer between 1 and 25"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# kamal-proxy only logs a warning for a DNS provider it does not recognise
|
|
113
|
+
# and then carries on with no provider at all, so the symptom is a
|
|
114
|
+
# certificate that never issues rather than a failed boot. Catch it here,
|
|
115
|
+
# before any host is contacted.
|
|
116
|
+
def validate_acme!(acme)
|
|
117
|
+
with_context("run") do
|
|
118
|
+
with_context("acme") do
|
|
119
|
+
if acme["email"].blank?
|
|
120
|
+
error "Missing email setting (required when acme is set)"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
provider = acme["dns_provider"]
|
|
124
|
+
|
|
125
|
+
if provider.present? && !Kamal::Configuration::Proxy::Acme::SUPPORTED_DNS_PROVIDERS.include?(provider.to_s.downcase)
|
|
126
|
+
error "unsupported dns_provider '#{provider}'. " \
|
|
127
|
+
"Supported providers: #{Kamal::Configuration::Proxy::Acme::DNS_PROVIDERS.join(", ")}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The whole TLS surface lives in the one `ssl` hash: certificate material,
|
|
134
|
+
# on-demand issuance and the mTLS client CA. kamal-proxy rejects the
|
|
135
|
+
# on-demand combinations outright rather than picking a winner
|
|
136
|
+
# (ServiceOptions.Validate), so there is no precedence to document - only
|
|
137
|
+
# a deploy that would fail after the SSH round-trip. Fail here.
|
|
138
|
+
def validate_ssl_hash!(ssl)
|
|
139
|
+
with_context("ssl") do
|
|
140
|
+
if ssl["certificate_pem"].present? && ssl["private_key_pem"].blank?
|
|
141
|
+
error "Missing private_key_pem setting (required when certificate_pem is present)"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
if ssl["private_key_pem"].present? && ssl["certificate_pem"].blank?
|
|
145
|
+
error "Missing certificate_pem setting (required when private_key_pem is present)"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
if (on_demand_url = ssl["on_demand_url"]).present?
|
|
149
|
+
if config["host"].present? || config["hosts"].present?
|
|
150
|
+
error "cannot set on_demand_url together with host or hosts - " \
|
|
151
|
+
"on-demand TLS issues certificates for whatever hostnames the ask endpoint approves"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
if ssl["certificate_pem"].present?
|
|
155
|
+
error "cannot set on_demand_url together with a custom ssl certificate"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
if config.dig("ssl_domains", "source").present?
|
|
159
|
+
error "cannot set on_demand_url together with ssl_domains - " \
|
|
160
|
+
"both manage certificates for hostnames discovered at runtime, and only one can serve the handshake"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
unless valid_ssl_domains_source?(on_demand_url)
|
|
164
|
+
error "on_demand_url must be a path starting with '/' or an http(s) URL"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# `ssl` may be a boolean or the hash form - the hash-only settings read
|
|
171
|
+
# through this so a bare `ssl: true` never trips a Hash dig.
|
|
172
|
+
def ssl_config
|
|
173
|
+
config["ssl"].is_a?(Hash) ? config["ssl"] : {}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
REDIRECT_STATUSES = [ 301, 302, 303, 307, 308 ].freeze
|
|
177
|
+
|
|
178
|
+
# An RFC 9110 token, which is both a valid header field name and a valid
|
|
179
|
+
# cookie name. For a header, a space or a colon would also break the
|
|
180
|
+
# '<name>: <value>' encoding kamal-proxy cuts at the first colon.
|
|
181
|
+
TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
|
|
182
|
+
|
|
183
|
+
POOL_KEYS = %w[ max_conns max_idle_conns idle_conn_timeout dial_timeout try_duration try_interval ].freeze
|
|
184
|
+
|
|
185
|
+
# A zero is meaningful for most of these, so only negatives are refused.
|
|
186
|
+
# kamal-proxy clamps them rather than failing when it reads saved state, and
|
|
187
|
+
# rejects them from a client — this is the client.
|
|
188
|
+
def validate_tuning!
|
|
189
|
+
target = config["target"] || {}
|
|
190
|
+
|
|
191
|
+
with_context("target") do
|
|
192
|
+
POOL_KEYS.each do |key|
|
|
193
|
+
error "#{key} cannot be negative" if target[key].to_f.negative?
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# A retry interval paces attempts within a duration; without one there is
|
|
197
|
+
# only ever a single attempt for it to pace.
|
|
198
|
+
if target["try_interval"].present? && target["try_duration"].to_i.zero?
|
|
199
|
+
error "try_interval has no effect without try_duration"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
if config["request_timeout"].to_f.negative?
|
|
204
|
+
with_context("request_timeout") { error "cannot be negative" }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Sibling consistency: kamal-proxy clamps a negative --target-timeout as
|
|
208
|
+
# silently as a negative --request-timeout.
|
|
209
|
+
if config["response_timeout"].to_f.negative?
|
|
210
|
+
with_context("response_timeout") { error "cannot be negative" }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Both maps reach kamal-proxy's one parsePathTimeouts, which refuses a
|
|
214
|
+
# negative duration.
|
|
215
|
+
%w[ path_response_timeouts path_request_timeouts ].each do |key|
|
|
216
|
+
with_context(key) do
|
|
217
|
+
(config[key] || {}).each do |prefix, duration|
|
|
218
|
+
error "'#{prefix}' cannot be negative" if duration.is_a?(Numeric) && duration.negative?
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Session affinity and scale-to-zero. The one rule NOT here is that sleep
|
|
225
|
+
# needs proxy/run/docker_socket: a role can carry `sleep` while the root
|
|
226
|
+
# carries the socket, and this validator runs against the role's own proxy
|
|
227
|
+
# block before it is merged. Kamal::Configuration checks that pairing on the
|
|
228
|
+
# merged config instead.
|
|
229
|
+
def validate_lifecycle!
|
|
230
|
+
affinity = config["session_affinity"] || {}
|
|
231
|
+
sleep_config = config["sleep"] || {}
|
|
232
|
+
|
|
233
|
+
with_context("session_affinity") do
|
|
234
|
+
if affinity["cookie"].present?
|
|
235
|
+
if !affinity["enabled"]
|
|
236
|
+
error "cookie has no effect without enabled: true"
|
|
237
|
+
elsif !affinity["cookie"].to_s.match?(TOKEN)
|
|
238
|
+
# net/http drops a cookie with an invalid name when it writes the
|
|
239
|
+
# response, which looks exactly like affinity not working.
|
|
240
|
+
error "cookie '#{affinity["cookie"]}' is not a valid cookie name"
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
with_context("sleep") do
|
|
246
|
+
if sleep_config["after"].present?
|
|
247
|
+
error "after cannot be negative" if sleep_config["after"].to_i.negative?
|
|
248
|
+
error "wake_timeout cannot be negative" if sleep_config["wake_timeout"].to_i.negative?
|
|
249
|
+
|
|
250
|
+
if ssl_config["on_demand_url"].present?
|
|
251
|
+
error "after cannot be combined with ssl/on_demand_url - " \
|
|
252
|
+
"a sleeping target cannot answer the ask endpoint, and waking one would let any hostname start a container"
|
|
253
|
+
end
|
|
254
|
+
else
|
|
255
|
+
error "containers has no effect without after" if sleep_config["containers"].present?
|
|
256
|
+
error "wake_timeout has no effect without after" if sleep_config["wake_timeout"].present?
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# kamal-proxy rejects canonical_host + on-demand TLS after the SSH round
|
|
262
|
+
# trip (canonical redirection needs a fixed host, on-demand TLS has none),
|
|
263
|
+
# and a canonical host outside the host list redirects every request to a
|
|
264
|
+
# hostname this service does not serve.
|
|
265
|
+
def validate_canonical_host!
|
|
266
|
+
canonical_host = config["canonical_host"]
|
|
267
|
+
return if canonical_host.blank?
|
|
268
|
+
|
|
269
|
+
with_context("canonical_host") do
|
|
270
|
+
if ssl_config["on_demand_url"].present?
|
|
271
|
+
error "cannot be combined with ssl/on_demand_url - " \
|
|
272
|
+
"canonical redirection needs a fixed host, and on-demand TLS has none"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
hosts = Array(config["hosts"].presence || config["host"]&.split(","))
|
|
276
|
+
|
|
277
|
+
if hosts.any? && !hosts.include?(canonical_host)
|
|
278
|
+
error "'#{canonical_host}' is not in hosts - " \
|
|
279
|
+
"every request would redirect to a hostname this service does not serve"
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def validate_traffic_shaping!
|
|
285
|
+
validate_header_rules!
|
|
286
|
+
validate_path_rules! "redirects", redirect: true
|
|
287
|
+
validate_path_rules! "rewrites", redirect: false
|
|
288
|
+
|
|
289
|
+
with_context("intercept_errors") do
|
|
290
|
+
Array(config["intercept_errors"]).each do |status|
|
|
291
|
+
unless status.is_a?(Integer) && status.between?(400, 599)
|
|
292
|
+
error "#{status} must be a 4xx or 5xx status code"
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def validate_header_shape!(headers)
|
|
299
|
+
validate_type! headers, Hash
|
|
300
|
+
|
|
301
|
+
with_context("headers") do
|
|
302
|
+
unknown_keys_error(headers.keys - %w[ request response ]) if (headers.keys - %w[ request response ]).any?
|
|
303
|
+
|
|
304
|
+
headers.each do |direction, rules|
|
|
305
|
+
with_context(direction) do
|
|
306
|
+
validate_type! rules, Hash
|
|
307
|
+
unknown_keys_error(rules.keys - %w[ set add remove ]) if (rules.keys - %w[ set add remove ]).any?
|
|
308
|
+
|
|
309
|
+
%w[ set add ].each { |verb| with_context(verb) { validate_hash_of! rules[verb], String } if rules.key?(verb) }
|
|
310
|
+
with_context("remove") { validate_array_of! rules["remove"], String } if rules.key?("remove")
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
true
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def validate_header_rules!
|
|
319
|
+
headers = config["headers"] || {}
|
|
320
|
+
|
|
321
|
+
with_context("headers") do
|
|
322
|
+
%w[ request response ].each do |direction|
|
|
323
|
+
rules = headers[direction] || {}
|
|
324
|
+
|
|
325
|
+
with_context(direction) do
|
|
326
|
+
%w[ set add ].each do |verb|
|
|
327
|
+
with_context(verb) { (rules[verb] || {}).each { |name, value| validate_header_rule! direction, name, value } }
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
with_context("remove") { Array(rules["remove"]).each { |name| validate_header_rule! direction, name, nil } }
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def validate_header_rule!(direction, name, value)
|
|
337
|
+
unless name.to_s.match?(TOKEN)
|
|
338
|
+
return error "'#{name}' is not a valid header name"
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Go carries the request host in Request.Host rather than the header map,
|
|
342
|
+
# so kamal-proxy refuses the rule instead of silently doing nothing.
|
|
343
|
+
if direction == "request" && name.to_s.downcase == "host"
|
|
344
|
+
return error "cannot rewrite the Host header"
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Escaping would turn a newline into a literal backslash-n, so the operator
|
|
348
|
+
# would get a value they did not write, with no error. And CR/LF in a
|
|
349
|
+
# header is response splitting.
|
|
350
|
+
if value.to_s.match?(/[\r\n]/)
|
|
351
|
+
error "'#{name}' has a value containing a newline or carriage return"
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# The pattern is deliberately not compiled: Go's RE2 and Ruby's Onigmo differ
|
|
356
|
+
# at the edges (Ruby rejects Go's `(?P<name>...)`), and refusing a pattern
|
|
357
|
+
# kamal-proxy would have accepted is worse than finding out at deploy time.
|
|
358
|
+
def validate_path_rules!(key, redirect:)
|
|
359
|
+
Array(config[key]).each_with_index do |rule, index|
|
|
360
|
+
with_context(key) do
|
|
361
|
+
with_context(index) do
|
|
362
|
+
rule = {} unless rule.is_a?(Hash)
|
|
363
|
+
|
|
364
|
+
if rule["from"].blank? || rule["to"].blank?
|
|
365
|
+
next error "needs both from and to"
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# The wire format is '<from>=<to>' and kamal-proxy cuts at the
|
|
369
|
+
# FIRST '=', so an '=' inside the pattern silently builds a rule
|
|
370
|
+
# for a different path.
|
|
371
|
+
if rule["from"].to_s.include?("=")
|
|
372
|
+
next error "from cannot contain '=' - " \
|
|
373
|
+
"the <from>=<to> wire format cuts at the first '=' and would silently build a different rule"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
unless rule["to"].to_s.start_with?("/") || (redirect && rule["to"].to_s.match?(%r{\Ahttps?://}))
|
|
377
|
+
next error "to must be an absolute path starting with '/'#{" or a full http(s) URL" if redirect}"
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
if rule["status"].present?
|
|
381
|
+
if !redirect
|
|
382
|
+
error "status is only meaningful for a redirect"
|
|
383
|
+
elsif !REDIRECT_STATUSES.include?(rule["status"])
|
|
384
|
+
error "status must be one of #{REDIRECT_STATUSES.join(", ")}"
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# kamal-proxy's --rate-limit is a Float64, so half a request per second is a
|
|
393
|
+
# legal rate. The docs example can only demonstrate one numeric type, and an
|
|
394
|
+
# Integer there would reject 0.5 — so the shape is checked by hand instead.
|
|
395
|
+
def validate_key_override!(key, value)
|
|
396
|
+
# Header names are the operator's to choose, so the docs example's names
|
|
397
|
+
# are illustrations rather than the permitted set — the example-driven
|
|
398
|
+
# unknown-key check would reject every real config. validate_header_rules!
|
|
399
|
+
# does the semantic checking.
|
|
400
|
+
return validate_header_shape!(value) if key.to_s == "headers"
|
|
401
|
+
|
|
402
|
+
# The whole point of proxy/run/flags is that the gem does not know the flag
|
|
403
|
+
# names, so the example's key is an illustration and the unknown-key check
|
|
404
|
+
# would reject every real use. Proxy::Run rejects one that collides with a
|
|
405
|
+
# named key; nothing else about the name is the gem's business.
|
|
406
|
+
if key.to_s == "flags"
|
|
407
|
+
validate_type! value, Hash
|
|
408
|
+
with_context("flags") { validate_hash_of! value, String }
|
|
409
|
+
return true
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
return false unless key.to_s == "rate_limit"
|
|
413
|
+
|
|
414
|
+
validate_type! value, Hash
|
|
415
|
+
|
|
416
|
+
with_context("rate_limit") do
|
|
417
|
+
unknown_keys_error(value.keys - %w[ requests burst exempt ]) if (value.keys - %w[ requests burst exempt ]).any?
|
|
418
|
+
|
|
419
|
+
with_context("requests") { validate_type! value["requests"], Numeric } if value.key?("requests")
|
|
420
|
+
with_context("burst") { validate_type! value["burst"], Integer } if value.key?("burst")
|
|
421
|
+
with_context("exempt") { validate_array_of! value["exempt"], String } if value.key?("exempt")
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
true
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Rate limiting and the IP allow list are only as correct as the address they
|
|
428
|
+
# key on, so kamal-proxy ties the three settings together with rules that
|
|
429
|
+
# otherwise surface only once the deploy has reached a host.
|
|
430
|
+
def validate_access_control!
|
|
431
|
+
allow_ips = Array(config["allow_ips"])
|
|
432
|
+
client_ip = config["client_ip"] || {}
|
|
433
|
+
rate_limit = config["rate_limit"] || {}
|
|
434
|
+
trusted_proxies = Array(client_ip["trusted_proxies"])
|
|
435
|
+
rate_limited = rate_limit["requests"].present?
|
|
436
|
+
|
|
437
|
+
with_context("allow_ips") { allow_ips.each { |entry| validate_ip_entry! entry } }
|
|
438
|
+
with_context("rate_limit") { with_context("exempt") { Array(rate_limit["exempt"]).each { |entry| validate_ip_entry! entry } } }
|
|
439
|
+
|
|
440
|
+
with_context("client_ip") do
|
|
441
|
+
with_context("trusted_proxies") do
|
|
442
|
+
trusted_proxies.each do |entry|
|
|
443
|
+
validate_ip_entry! entry
|
|
444
|
+
|
|
445
|
+
# Trusting everything means trusting every client to speak for
|
|
446
|
+
# someone else, which is the bypass this setting exists to prevent.
|
|
447
|
+
if default_route?(entry)
|
|
448
|
+
error "'#{entry}' is a default route - " \
|
|
449
|
+
"trusting every address means trusting every client to speak for someone else"
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
if trusted_proxies.any? && allow_ips.empty? && !rate_limited
|
|
455
|
+
error "trusted_proxies has no effect without allow_ips or rate_limit"
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Unconditional: even without allow_ips/rate_limit, kamal-proxy rewrites
|
|
459
|
+
# the client address (and X-Forwarded-For) from this header, so honoring
|
|
460
|
+
# it from untrusted peers is a client-spoofable identity.
|
|
461
|
+
if client_ip["header"].present? && trusted_proxies.empty?
|
|
462
|
+
error "header requires trusted_proxies, or the header would be ignored while appearing to be honored"
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
with_context("rate_limit") do
|
|
467
|
+
if rate_limited
|
|
468
|
+
error "requests cannot be negative" if rate_limit["requests"].to_f.negative?
|
|
469
|
+
error "burst cannot be negative" if rate_limit["burst"].to_i.negative?
|
|
470
|
+
else
|
|
471
|
+
error "burst has no effect without requests" if rate_limit["burst"].present?
|
|
472
|
+
error "exempt has no effect without requests" if rate_limit["exempt"].present?
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# kamal-proxy serves the health check path without an address check and
|
|
477
|
+
# without a rate limit so deploys keep working, which makes '/' a hole
|
|
478
|
+
# straight through both features.
|
|
479
|
+
if (allow_ips.any? || rate_limited) && config.dig("healthcheck", "path") == "/"
|
|
480
|
+
with_context("healthcheck") do
|
|
481
|
+
error "path cannot be '/' when allow_ips or rate_limit is set, " \
|
|
482
|
+
"as that path is served without an address check or a rate limit"
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# IPAddr is looser than the proxy's netip parsing in two ways that matter: it
|
|
488
|
+
# drops an IPv6 zone rather than rejecting it, and it accepts IPv4-mapped
|
|
489
|
+
# forms. Both would match nothing at runtime while looking configured.
|
|
490
|
+
def validate_ip_entry!(entry)
|
|
491
|
+
if entry.to_s.include?("%")
|
|
492
|
+
return error "'#{entry}' carries an IPv6 zone; write the address without it"
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
address = IPAddr.new(entry.to_s)
|
|
496
|
+
|
|
497
|
+
if address.ipv4_mapped?
|
|
498
|
+
error "'#{entry}' is IPv4-mapped; write it as plain IPv4"
|
|
499
|
+
end
|
|
500
|
+
rescue IPAddr::Error
|
|
501
|
+
error "'#{entry}' is not a valid address or CIDR range"
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def default_route?(entry)
|
|
505
|
+
IPAddr.new(entry.to_s).prefix.zero?
|
|
506
|
+
rescue IPAddr::Error
|
|
507
|
+
false
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Mirrors CompressionOptions.Validate, which rejects each of these after the
|
|
511
|
+
# deploy has already reached the host.
|
|
512
|
+
def validate_compress!(compress)
|
|
513
|
+
with_context("compress") do
|
|
514
|
+
# An explicit false is a deliberate off switch - the tuned settings may
|
|
515
|
+
# stay for when it is flipped back on. (Distinct from an absent enabled
|
|
516
|
+
# with orphaned tuning, which is a block that never did anything.)
|
|
517
|
+
return if compress["enabled"] == false
|
|
518
|
+
|
|
519
|
+
encodings = Array(compress["encodings"])
|
|
520
|
+
enabled = compress["enabled"] || encodings.any?
|
|
521
|
+
|
|
522
|
+
unless enabled
|
|
523
|
+
if (orphaned = compress.keys - [ "enabled" ]).any?
|
|
524
|
+
error "#{orphaned.first} has no effect without compression - set enabled: true or name the encodings"
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
return
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
encodings.each do |encoding|
|
|
531
|
+
canonical = Kamal::Configuration::Proxy::COMPRESSION_ENCODING_ALIASES.fetch(encoding.to_s, encoding.to_s)
|
|
532
|
+
|
|
533
|
+
unless Kamal::Configuration::Proxy::SUPPORTED_COMPRESSION_ENCODINGS.include?(canonical)
|
|
534
|
+
error "unsupported encoding '#{encoding}'. " \
|
|
535
|
+
"Supported encodings: #{Kamal::Configuration::Proxy::SUPPORTED_COMPRESSION_ENCODINGS.join(", ")}"
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
if compress["min_length"].to_i.negative?
|
|
540
|
+
error "min_length cannot be negative"
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
Array(compress["content_types"]).each do |content_type|
|
|
544
|
+
# A main-type wildcard would match everything the proxy cannot see
|
|
545
|
+
# inside, so kamal-proxy allows a wildcard only in the subtype.
|
|
546
|
+
unless content_type.to_s.match?(%r{\A[^/*\s]+/[^/\s]+\z})
|
|
547
|
+
error "content_types entry '#{content_type}' must be a media type such as text/html or text/*"
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
LOG_FORMATS = %w[ json text logfmt ].freeze
|
|
554
|
+
TRACE_CONTEXT_MODES = %w[ off propagate generate ].freeze
|
|
555
|
+
MIN_TLS_VERSIONS = %w[ 1.2 1.3 ].freeze
|
|
556
|
+
REFUSED_TLS_VERSIONS = %w[ 1.0 1.1 ].freeze
|
|
557
|
+
RUN_TIMEOUTS = %w[ read_header_timeout read_timeout write_timeout idle_timeout shutdown_timeout ].freeze
|
|
558
|
+
|
|
559
|
+
# kamal-proxy validates these three in preRun, so a typo does not fail the
|
|
560
|
+
# deploy - it stops the proxy container from booting at all.
|
|
561
|
+
def validate_run_server_options!(run_config)
|
|
562
|
+
with_context("run") do
|
|
563
|
+
if (format = run_config["log_format"]).present? && !LOG_FORMATS.include?(format.to_s.downcase.strip)
|
|
564
|
+
error "log_format '#{format}' is not a log format - use json or text"
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
if (mode = run_config["trace_context"]).present? && !TRACE_CONTEXT_MODES.include?(mode.to_s.downcase.strip)
|
|
568
|
+
error "trace_context '#{mode}' is not a trace context mode - use off, propagate or generate"
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
validate_min_tls! run_config["min_tls"]
|
|
572
|
+
|
|
573
|
+
RUN_TIMEOUTS.each do |key|
|
|
574
|
+
error "#{key} cannot be negative" if run_config[key].to_f.negative?
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
%w[ metrics_allow_ips proxy_protocol_allow_ips ].each do |key|
|
|
578
|
+
with_context(key) { Array(run_config[key]).each { |entry| validate_ip_entry! entry } }
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# Accepts the spellings kamal-proxy's normalizeTLSVersion reduces - a `tls`
|
|
584
|
+
# or `tlsv` prefix, and `_` for `.` - so a config written for upstream is not
|
|
585
|
+
# rejected here and accepted there.
|
|
586
|
+
def validate_min_tls!(value)
|
|
587
|
+
return if value.blank?
|
|
588
|
+
|
|
589
|
+
normalized = value.to_s.downcase.strip.sub(/\Atlsv?/, "").tr("_", ".")
|
|
590
|
+
|
|
591
|
+
if REFUSED_TLS_VERSIONS.include?(normalized)
|
|
592
|
+
# The flag narrows what the listener negotiates; it was never a way to
|
|
593
|
+
# widen it, and Go refuses these regardless.
|
|
594
|
+
error "min_tls #{normalized} cannot be enabled - the lowest accepted minimum is #{MIN_TLS_VERSIONS.first}"
|
|
595
|
+
elsif !MIN_TLS_VERSIONS.include?(normalized)
|
|
596
|
+
error "min_tls '#{value}' is not a TLS version - use #{MIN_TLS_VERSIONS.join(" or ")}"
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# kamal-proxy reads the cache policy only when --cache is set and ignores it
|
|
601
|
+
# otherwise, so a tuned block with no `enabled` is a cache that silently
|
|
602
|
+
# never caches - this issue's own predicted first support question.
|
|
603
|
+
def validate_cache!(cache)
|
|
604
|
+
return if cache["enabled"]
|
|
605
|
+
|
|
606
|
+
with_context("cache") do
|
|
607
|
+
if (orphaned = cache.keys - [ "enabled" ]).any?
|
|
608
|
+
error "#{orphaned.first} has no effect without enabled: true - " \
|
|
609
|
+
"kamal-proxy ignores the cache policy entirely when --cache is absent"
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# An unsupported store is rejected in kamal-proxy's preRun, which means the
|
|
615
|
+
# proxy container exits at boot rather than a deploy failing. Catch it before
|
|
616
|
+
# the fleet loses its proxy.
|
|
617
|
+
def validate_cache_store!(cache)
|
|
618
|
+
store = cache["store"]
|
|
619
|
+
return if store.blank?
|
|
620
|
+
|
|
621
|
+
with_context("run") do
|
|
622
|
+
with_context("cache") do
|
|
623
|
+
unless store == "memory" || store.to_s.match?(%r{\Arediss?://\S+\z})
|
|
624
|
+
error "store must be 'memory' or a redis:// or rediss:// URL"
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def validate_basic_auth!(basic_auth)
|
|
631
|
+
with_context("basic_auth") do
|
|
632
|
+
if basic_auth["username"].blank?
|
|
633
|
+
error "Missing username setting (required when basic_auth is set)"
|
|
634
|
+
elsif basic_auth["username"].to_s.include?(":")
|
|
635
|
+
# kamal-proxy cuts <username>:<password> at the first colon, so a
|
|
636
|
+
# colon in the username would silently truncate it.
|
|
637
|
+
error "Invalid username: cannot contain a colon"
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
if basic_auth["password"].present? && basic_auth["password_secret"].present?
|
|
641
|
+
error "Specify one of 'password' or 'password_secret', not both"
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
if basic_auth["password"].blank? && basic_auth["password_secret"].blank?
|
|
645
|
+
error "Missing password or password_secret setting (required when basic_auth is set)"
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def valid_ssl_domains_source?(source)
|
|
651
|
+
source.is_a?(String) && (source.start_with?("/") || source.match?(%r{\Ahttps?://\S+\z}i))
|
|
652
|
+
end
|
|
653
|
+
|
|
41
654
|
def ensure_valid_bind_ips(bind_ips)
|
|
42
655
|
bind_ips.present? && bind_ips.each do |ip|
|
|
43
656
|
next if ip =~ Resolv::IPv4::Regex || ip =~ Resolv::IPv6::Regex
|