dash 2.12.0 → 3.0.0

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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/lib/kamal/cli/app/boot.rb +47 -11
  3. data/lib/kamal/cli/app/rollout_boot.rb +59 -0
  4. data/lib/kamal/cli/app/ssl_certificates.rb +12 -3
  5. data/lib/kamal/cli/app.rb +74 -7
  6. data/lib/kamal/cli/base.rb +41 -32
  7. data/lib/kamal/cli/doctor/config_checks.rb +28 -0
  8. data/lib/kamal/cli/doctor/endpoint_checks.rb +114 -0
  9. data/lib/kamal/cli/doctor/host_checks.rb +178 -0
  10. data/lib/kamal/cli/doctor.rb +112 -0
  11. data/lib/kamal/cli/healthcheck/drift_error.rb +7 -0
  12. data/lib/kamal/cli/healthcheck/poller.rb +60 -10
  13. data/lib/kamal/cli/main.rb +63 -0
  14. data/lib/kamal/cli/proxy/drift.rb +39 -0
  15. data/lib/kamal/cli/proxy/loadbalancer_claim.rb +70 -0
  16. data/lib/kamal/cli/proxy/loadbalancer_reboot.rb +41 -0
  17. data/lib/kamal/cli/proxy/reboot.rb +172 -0
  18. data/lib/kamal/cli/proxy.rb +200 -26
  19. data/lib/kamal/cli/prune.rb +7 -4
  20. data/lib/kamal/cli/templates/sample_hooks/post-app-stop.sample +9 -0
  21. data/lib/kamal/cli/templates/sample_hooks/post-proxy-deploy.sample +3 -0
  22. data/lib/kamal/cli/templates/sample_hooks/pre-app-stop.sample +12 -0
  23. data/lib/kamal/cli/templates/sample_hooks/pre-proxy-deploy.sample +3 -0
  24. data/lib/kamal/cli.rb +1 -0
  25. data/lib/kamal/commands/app/proxy.rb +12 -0
  26. data/lib/kamal/commands/app.rb +8 -0
  27. data/lib/kamal/commands/base.rb +11 -1
  28. data/lib/kamal/commands/docker.rb +5 -0
  29. data/lib/kamal/commands/loadbalancer.rb +76 -34
  30. data/lib/kamal/commands/proxy.rb +105 -11
  31. data/lib/kamal/commands/prune.rb +16 -2
  32. data/lib/kamal/commands/server.rb +5 -0
  33. data/lib/kamal/configuration/accessory.rb +9 -8
  34. data/lib/kamal/configuration/boot.rb +38 -7
  35. data/lib/kamal/configuration/docs/accessory.yml +28 -1
  36. data/lib/kamal/configuration/docs/boot.yml +17 -2
  37. data/lib/kamal/configuration/docs/configuration.yml +2 -1
  38. data/lib/kamal/configuration/docs/proxy.yml +844 -24
  39. data/lib/kamal/configuration/docs/role.yml +86 -0
  40. data/lib/kamal/configuration/loadbalancer.rb +70 -7
  41. data/lib/kamal/configuration/proxy/acme.rb +69 -0
  42. data/lib/kamal/configuration/proxy/run.rb +194 -5
  43. data/lib/kamal/configuration/proxy.rb +495 -18
  44. data/lib/kamal/configuration/role/healthcheck.rb +95 -0
  45. data/lib/kamal/configuration/role.rb +104 -1
  46. data/lib/kamal/configuration/validator/proxy.rb +623 -10
  47. data/lib/kamal/configuration/validator/role.rb +26 -0
  48. data/lib/kamal/configuration/validator.rb +26 -3
  49. data/lib/kamal/configuration.rb +197 -0
  50. data/lib/kamal/sshkit_with_ext.rb +27 -2
  51. data/lib/kamal/utils.rb +22 -2
  52. data/lib/kamal/version.rb +1 -1
  53. data/lib/kamal.rb +11 -0
  54. 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
- # since SSL is disabled when using a loadbalancer
8
- if config["host"].blank? && config["hosts"].blank? && config["ssl"] && config["loadbalancer"].blank?
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
- if config["ssl"]["certificate_pem"].present? && config["ssl"]["private_key_pem"].blank?
18
- error "Missing private_key_pem setting (required when certificate_pem is present)"
19
- end
26
+ validate_ssl_hash! config["ssl"]
27
+ end
20
28
 
21
- if config["ssl"]["private_key_pem"].present? && config["ssl"]["certificate_pem"].blank?
22
- error "Missing certificate_pem setting (required when private_key_pem is present)"
23
- end
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(config["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