dash 3.0.0 → 3.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6645870f4a0452741a5974d1d822e73fae0d4a04aec353196af3c02b3fb149d2
4
- data.tar.gz: 23d1738ee101bf3eda8c03c363845dacc396403767271f7fbda7cd6656b9f0ff
3
+ metadata.gz: 76de5dcf1c023a36baae2b55567695e691d2a02ac65df587469742d95f043d55
4
+ data.tar.gz: d1b681c9d34d5313840bb588d24235c4f8d30058f7b01d1778390dd7056f56fd
5
5
  SHA512:
6
- metadata.gz: 972ac9e339a4b56463c2f6caaf764a272cc01002101345e2696474fb01817d3a36dcb2c825b6f188521da82c764764323ff1b92b34ced280a330eb5a1d1bde0d
7
- data.tar.gz: 7e698f1dbb093d79787f7c48aec3053742c90bfe08261b942d66a208b8051f53b8154969f796c82e29dac893b531dd9bd945a11015f598f4ac76577b271ce1fe
6
+ metadata.gz: 3418967bd8855f630c65a2a27090248ebda77b2f585f97039ca0b27b7d173936b905823854e9c2f156c1986003eedc18cb9373da56661d7267df425ab6dc83a7
7
+ data.tar.gz: '0439bbced6c8a6d496283970df7a21e5e679aa99052fc90d2e8ef6ba5072c0a954b2206e03c5c01200622b169d5d1bbec73d96c48c0fbaf9cc36ed879f25a752'
@@ -6,6 +6,8 @@
6
6
  # loadbalancer keeps its service state in the config volume, which the
7
7
  # replacement container re-mounts, so every app's routes survive the gap.
8
8
  class Kamal::Cli::Proxy::LoadbalancerReboot
9
+ READY_TIMEOUT = 30
10
+
9
11
  attr_reader :host, :sshkit
10
12
  delegate :execute, :capture_with_info, :info, :upload!, to: :sshkit
11
13
 
@@ -33,9 +35,53 @@ class Kamal::Cli::Proxy::LoadbalancerReboot
33
35
  Kamal::Cli::Proxy::LoadbalancerClaim.new(host, sshkit).claim_run_config(replace: true)
34
36
  execute *KAMAL.loadbalancer.run
35
37
 
38
+ wait_until_ready
39
+ verify_service if re_register_service
40
+
36
41
  # kamal-proxy keeps its service state in the config volume, which the
37
42
  # replacement container re-mounts - every app's routes survive.
38
43
  services = capture_with_info(*KAMAL.loadbalancer.list).strip
39
44
  info "Services registered on the load balancer at #{host} after reboot:\n#{services}"
40
45
  end
46
+
47
+ private
48
+ def wait_until_ready
49
+ deadline = Time.now + READY_TIMEOUT
50
+
51
+ begin
52
+ capture_with_info(*KAMAL.loadbalancer.list, verbosity: :debug)
53
+ rescue SSHKit::Command::Failed
54
+ raise Kamal::Cli::BootError, "the load balancer on #{host} did not become ready within #{READY_TIMEOUT} seconds" if Time.now >= deadline
55
+ sleep 0.5
56
+ retry
57
+ end
58
+ end
59
+
60
+ # A deploy failure after this reboot must not strand a fresh LB with no
61
+ # services: re-register this app's routes now rather than trusting the
62
+ # deploy step that hasn't run yet, mirroring the per-host proxy reboot.
63
+ #
64
+ # Only this app's registration can be rebuilt from this deploy.yml - the
65
+ # owner files of other apps sharing the LB record tokens, not deploy
66
+ # commands - so anything else rides on the state-volume restore (which
67
+ # --recheck-targets-on-restore re-verifies). No owner record, or a foreign
68
+ # one, means nothing of ours to restore.
69
+ def re_register_service
70
+ owner = capture_with_info(*KAMAL.loadbalancer.read_service_owner, raise_on_non_zero_exit: false).strip
71
+ return false unless owner == KAMAL.loadbalancer_config.owner_token
72
+
73
+ info "Re-registering #{KAMAL.config.service} with the load balancer on #{host}..."
74
+ execute *KAMAL.loadbalancer.deploy(targets: KAMAL.loadbalancer_config.target_hosts)
75
+ true
76
+ end
77
+
78
+ # `list --json` returns {"services": {"<name>": ...}} - exact key
79
+ # membership, same as the per-host proxy reboot's verification.
80
+ def verify_service
81
+ listed = JSON.parse(capture_with_info(*KAMAL.loadbalancer.list(json: true))).fetch("services", {}).keys
82
+
83
+ unless listed.include?(KAMAL.config.service)
84
+ raise Kamal::Cli::BootError, "the load balancer on #{host} is missing service #{KAMAL.config.service} after reboot"
85
+ end
86
+ end
41
87
  end
@@ -83,6 +83,7 @@ class Kamal::Cli::Proxy::Reboot
83
83
  execute *proxy.run(digest: drift.expected_digest, name: proxy.next_container_name)
84
84
  wait_until_ready(name: proxy.next_container_name)
85
85
 
86
+ execute *proxy.disable_restart
86
87
  execute *proxy.drain(timeout: KAMAL.config.drain_timeout)
87
88
  execute *proxy.wait_for_exit
88
89
  execute *proxy.remove_stopped_container
@@ -426,14 +426,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
426
426
  end
427
427
  when "deploy"
428
428
  if KAMAL.config.proxy.load_balancing?
429
- targets = []
430
- KAMAL.config.roles.each do |role|
431
- next unless role.running_proxy?
432
-
433
- role.hosts.each do |host|
434
- targets << host
435
- end
436
- end
429
+ targets = KAMAL.loadbalancer_config.target_hosts
437
430
 
438
431
  on(KAMAL.config.proxy.effective_loadbalancer) do |host|
439
432
  Kamal::Cli::Proxy::LoadbalancerClaim.new(host, self).claim_service
@@ -511,6 +504,83 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
511
504
  end
512
505
  end
513
506
 
507
+ desc "export_certs LOCAL_PATH", "Export the TLS certificate store to a local archive (contains private keys)"
508
+ def export_certs(local_path)
509
+ load_balancing = KAMAL.config.proxy.load_balancing?
510
+
511
+ # Under the deploy lock: a concurrent deploy could boot or reboot the
512
+ # proxy mid-export, and the offline read below would archive a torn store.
513
+ modify(lock: true) do
514
+ on(cert_store_host) do |host|
515
+ execute *KAMAL.auditor.record("Exported the proxy certificate store"), verbosity: :debug
516
+
517
+ commands = load_balancing ? KAMAL.loadbalancer : KAMAL.proxy(host)
518
+ execute *commands.ensure_apps_config_directory
519
+
520
+ # Running: export through the container's RPC socket, under the proxy's
521
+ # certificate write lock. Stopped: read the data directory offline with a
522
+ # one-off container - which may first need the image.
523
+ if capture_with_info(*commands.container_id(only_running: true), raise_on_non_zero_exit: false).strip.present?
524
+ puts capture_with_info(*commands.export_certs)
525
+ else
526
+ execute *KAMAL.registry.login
527
+ puts capture_with_info(*commands.export_certs_offline)
528
+ end
529
+
530
+ # The archive holds private keys; it must not outlive a failed download.
531
+ begin
532
+ download! commands.certs_archive_host_path, local_path
533
+ ensure
534
+ execute *commands.remove_certs_archive, raise_on_non_zero_exit: false
535
+ end
536
+ end
537
+ end
538
+ end
539
+
540
+ desc "import_certs", "Import certificates into the TLS certificate store from a Traefik acme.json or an exported archive"
541
+ option :traefik_acme, type: :string, default: nil, desc: "Local path of a Traefik acme.json to import from"
542
+ option :archive, type: :string, default: nil, desc: "Local path of an archive written by kamal proxy export_certs"
543
+ option :resolver, type: :string, default: nil, desc: "Import only this Traefik resolver's certificates (default: all, last writer wins per domain)"
544
+ option :force, type: :boolean, default: false, desc: "Overwrite a non-empty certificate store when restoring an archive"
545
+ option :verify, type: :boolean, default: false, desc: "Only verify the archive: report domains and expiries without touching the store"
546
+ def import_certs
547
+ validate_import_certs_options!
548
+ source = options[:traefik_acme] || options[:archive]
549
+ traefik_acme, resolver = options[:traefik_acme].present?, options[:resolver]
550
+ force, verify = options[:force], options[:verify]
551
+ load_balancing = KAMAL.config.proxy.load_balancing?
552
+
553
+ modify(lock: true) do
554
+ on(cert_store_host) do |host|
555
+ commands = load_balancing ? KAMAL.loadbalancer : KAMAL.proxy(host)
556
+
557
+ # kamal-proxy import runs offline against the data directory - importing
558
+ # under a live proxy risks a torn store. --verify only reads the archive.
559
+ unless verify
560
+ if capture_with_info(*commands.container_id(only_running: true), raise_on_non_zero_exit: false).strip.present?
561
+ raise "Cannot import certificates while the #{load_balancing ? "loadbalancer" : "proxy"} " \
562
+ "is running on #{host} - stop it first " \
563
+ "(kamal proxy #{load_balancing ? "loadbalancer stop" : "stop"}), import, then start it again"
564
+ end
565
+ end
566
+
567
+ execute *KAMAL.auditor.record("Imported certificates into the proxy certificate store"), verbosity: :debug
568
+ execute *KAMAL.registry.login
569
+ execute *commands.ensure_proxy_directory
570
+
571
+ # upload! inside the ensure's reach: a failed or partial upload must
572
+ # not leave certificate material behind on the host either.
573
+ begin
574
+ upload! source, commands.certs_import_host_path, mode: "0600"
575
+ puts capture_with_info(*commands.import_certs(
576
+ traefik_acme: traefik_acme, resolver: resolver, force: force, verify: verify))
577
+ ensure
578
+ execute *commands.remove_certs_import, raise_on_non_zero_exit: false
579
+ end
580
+ end
581
+ end
582
+ end
583
+
514
584
  desc "remove_container", "Remove proxy container from servers", hide: true
515
585
  def remove_container
516
586
  modify(lock: true) do
@@ -563,6 +633,33 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
563
633
  end
564
634
 
565
635
  private
636
+ # The host that owns TLS, and so the certificate store: the loadbalancer
637
+ # host when load balancing (TLS terminates at the edge), else the primary
638
+ # host - the same host `loadbalancer: true` would resolve to.
639
+ def cert_store_host
640
+ KAMAL.config.proxy.load_balancing? ? KAMAL.config.proxy.effective_loadbalancer : KAMAL.primary_host
641
+ end
642
+
643
+ # Mirrors kamal-proxy's own flag groups (import.go), so a contradictory
644
+ # invocation fails before anything is uploaded.
645
+ def validate_import_certs_options!
646
+ if options[:traefik_acme].present? == options[:archive].present?
647
+ raise ArgumentError, "Specify exactly one of --traefik-acme or --archive"
648
+ end
649
+
650
+ if options[:resolver].present? && options[:archive].present?
651
+ raise ArgumentError, "--resolver only applies to a Traefik import"
652
+ end
653
+
654
+ if options[:traefik_acme].present? && (options[:force] || options[:verify])
655
+ raise ArgumentError, "--force and --verify only apply to an archive"
656
+ end
657
+
658
+ if options[:force] && options[:verify]
659
+ raise ArgumentError, "--verify does not touch the store, so it cannot be combined with --force"
660
+ end
661
+ end
662
+
566
663
  # A shared load balancer tier is the exact case this guard exists for, so it
567
664
  # has to cover the load balancer host too - `remove_container` and
568
665
  # `remove_image` both act on it.
@@ -1,4 +1,6 @@
1
1
  class Kamal::Commands::Loadbalancer < Kamal::Commands::Base
2
+ include Kamal::Commands::Proxy::CertTransfer
3
+
2
4
  delegate :argumentize, :optionize, to: Kamal::Utils
3
5
 
4
6
  attr_reader :loadbalancer_config
@@ -44,8 +46,8 @@ class Kamal::Commands::Loadbalancer < Kamal::Commands::Base
44
46
  docker :exec, container_name, "kamal-proxy", "domains", subcommand
45
47
  end
46
48
 
47
- def list
48
- docker :exec, container_name, "kamal-proxy", :list
49
+ def list(json: false)
50
+ docker :exec, container_name, "kamal-proxy", :list, *("--json" if json)
49
51
  end
50
52
 
51
53
  # Cache policy is edge-only under load balancing (see the layering contract),
@@ -63,8 +65,8 @@ class Kamal::Commands::Loadbalancer < Kamal::Commands::Base
63
65
  docker :inspect, container_name, "--format", "'{{ index .Config.Labels \"#{Kamal::Commands::Proxy::CONFIG_DIGEST_LABEL}\" }}'"
64
66
  end
65
67
 
66
- def container_id
67
- container_id_for(container_name: container_name)
68
+ def container_id(only_running: false)
69
+ container_id_for(container_name: container_name, only_running: only_running)
68
70
  end
69
71
 
70
72
  def info
@@ -169,4 +171,14 @@ class Kamal::Commands::Loadbalancer < Kamal::Commands::Base
169
171
  [ "--volume", "kamal-loadbalancer-config:/home/kamal-proxy/.config/kamal-proxy" ]
170
172
  end
171
173
  end
174
+
175
+ # The certificate store lives in whichever config volume this loadbalancer
176
+ # actually mounts — the shared kamal-proxy one on a proxy host.
177
+ def cert_store_volume_args
178
+ config_volume
179
+ end
180
+
181
+ def one_off_image
182
+ [ loadbalancer_config.run.image ]
183
+ end
172
184
  end
@@ -0,0 +1,74 @@
1
+ # Certificate store transfer, shared by the proxy and loadbalancer command
2
+ # builders (kamal proxy export_certs / import_certs).
3
+ #
4
+ # Archives leave through the apps-config bind mount — the one container path
5
+ # that is also a host path — and arrive through stdin into a one-off container:
6
+ # a bind-mounted source would need host permissions the container user cannot
7
+ # be guaranteed to have, and the store must be written as the image's own user
8
+ # or the proxy cannot read it afterwards.
9
+ #
10
+ # The including class provides `container_name`, `cert_store_volume_args` (the
11
+ # config volume mount) and `one_off_image` (the image tokens for a one-off
12
+ # container).
13
+ module Kamal::Commands::Proxy::CertTransfer
14
+ CERT_ARCHIVE_FILENAME = "certs-export.tar.gz"
15
+ CERT_IMPORT_STAGING_FILENAME = "certs-import"
16
+ CONTAINER_IMPORT_PATH = "/tmp/kamal-cert-import"
17
+
18
+ # Through the RPC socket of the running container, under the proxy's own
19
+ # certificate write lock, so a backup taken mid-renewal is never torn.
20
+ def export_certs
21
+ docker :exec, container_name, "kamal-proxy", :export, :certs, certs_archive_container_path
22
+ end
23
+
24
+ # Reads the data directory offline over the config volume — only safe when
25
+ # the container is stopped, which Kamal::Cli::Proxy guarantees.
26
+ def export_certs_offline
27
+ docker :run, "--rm",
28
+ *cert_store_volume_args,
29
+ *config.proxy_boot.apps_volume.docker_args,
30
+ *one_off_image,
31
+ "kamal-proxy", :export, :certs, certs_archive_container_path
32
+ end
33
+
34
+ # Offline by design (kamal-proxy import has no RPC path): the one-off
35
+ # container mounts the config volume — creating it when no proxy has booted
36
+ # yet, which is the Traefik-migration case — and the staged source streams
37
+ # through stdin.
38
+ def import_certs(traefik_acme: false, resolver: nil, force: false, verify: false)
39
+ source_flag = traefik_acme ? "traefik-acme" : "archive"
40
+ # Base#shell single-quotes the payload and escapes embedded apostrophes -
41
+ # without that, an apostrophe in a resolver name would end the quoting and
42
+ # run whatever follows on the target host.
43
+ import_command = shell [
44
+ "cat > #{CONTAINER_IMPORT_PATH} &&",
45
+ "kamal-proxy import certs",
46
+ *optionize({ source_flag => CONTAINER_IMPORT_PATH, resolver: resolver, force: force || nil, verify: verify || nil }.compact, with: "=")
47
+ ]
48
+
49
+ [
50
+ *docker(:run, "--rm", "--interactive", *cert_store_volume_args, *one_off_image, *import_command),
51
+ "<", certs_import_host_path
52
+ ]
53
+ end
54
+
55
+ def certs_archive_host_path
56
+ File.join config.proxy_boot.apps_directory, CERT_ARCHIVE_FILENAME
57
+ end
58
+
59
+ def certs_archive_container_path
60
+ File.join config.proxy_boot.apps_container_directory, CERT_ARCHIVE_FILENAME
61
+ end
62
+
63
+ def certs_import_host_path
64
+ File.join config.proxy_boot.host_directory, CERT_IMPORT_STAGING_FILENAME
65
+ end
66
+
67
+ def remove_certs_archive
68
+ remove_file certs_archive_host_path
69
+ end
70
+
71
+ def remove_certs_import
72
+ remove_file certs_import_host_path
73
+ end
74
+ end
@@ -1,4 +1,6 @@
1
1
  class Kamal::Commands::Proxy < Kamal::Commands::Base
2
+ include Kamal::Commands::Proxy::CertTransfer
3
+
2
4
  delegate :argumentize, :optionize, to: Kamal::Utils
3
5
  attr_reader :proxy_run_config
4
6
 
@@ -113,6 +115,12 @@ class Kamal::Commands::Proxy < Kamal::Commands::Base
113
115
  container_id_for(container_name: proxy_run_config.holder_container_name, only_running: true)
114
116
  end
115
117
 
118
+ # Cancel the restart policy before draining: drain makes the proxy exit on
119
+ # its own, which - unlike `docker stop` - an active restart policy would undo.
120
+ def disable_restart
121
+ docker :update, "--restart=no", container_name
122
+ end
123
+
116
124
  def drain(timeout: nil)
117
125
  docker :exec, container_name, "kamal-proxy", :drain, *("--drain-timeout=#{timeout}s" if timeout)
118
126
  end
@@ -226,6 +234,20 @@ class Kamal::Commands::Proxy < Kamal::Commands::Base
226
234
  config.proxy_boot.container_name
227
235
  end
228
236
 
237
+ def cert_store_volume_args
238
+ [ "--volume", "kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy" ]
239
+ end
240
+
241
+ # Same fallback as #pull: without a run config the image comes from the
242
+ # legacy boot config files on the host.
243
+ def one_off_image
244
+ if proxy_run_config
245
+ [ proxy_run_config.image ]
246
+ else
247
+ [ "#{substitute(read_image)}:#{substitute(read_image_version)}" ]
248
+ end
249
+ end
250
+
229
251
  def config_digest_label_args(digest)
230
252
  [ "--label", "#{CONFIG_DIGEST_LABEL}=#{digest}" ] if digest
231
253
  end
@@ -313,6 +313,33 @@ proxy:
313
313
  - from: /api/(.*)
314
314
  to: /v2/$1
315
315
 
316
+ # Dynamic redirect map
317
+ #
318
+ # Fetch a host-scoped redirect map from the application itself, so redirects
319
+ # ship with a content change instead of a deploy. `source` is a path resolved
320
+ # against this service, or an absolute http(s) URL. The app publishes
321
+ # `{"hosts": {...}}` entries — per-host `redirect_to`, path rules and
322
+ # trailing-slash policy — and the proxy answers matching requests before they
323
+ # reach the app.
324
+ #
325
+ # The map composes with the static `redirects` above: the map is consulted
326
+ # first, and the static rules run when it misses.
327
+ #
328
+ # `interval` is the poll interval in seconds (proxy default 300, minimum 10).
329
+ #
330
+ # Authentication tokens live in the PROXY's environment, not the app's: polls
331
+ # send `KAMAL_PROXY_REDIRECTS_TOKEN` as a bearer token when set, and
332
+ # `POST /.kamal-proxy/redirects/refresh` nudges an immediate re-poll when
333
+ # authenticated with `KAMAL_PROXY_REFRESH_TOKEN`. Set both via
334
+ # `proxy.run.options.env`, next to `KAMAL_PROXY_DOMAINS_TOKEN` — never as
335
+ # deploy flags, which leak into process listings and audit logs.
336
+ #
337
+ # When a loadbalancer is configured the map answers at the loadbalancer, same
338
+ # as `ssl_domains` and `canonical_host` — the per-host proxies never see it.
339
+ redirects_source:
340
+ source: /api/v1/proxy/redirects
341
+ interval: 300
342
+
316
343
  # Canonical host
317
344
  #
318
345
  # Redirect every request to this host, to force apex or www one way.
@@ -459,6 +486,25 @@ proxy:
459
486
  allow_ips:
460
487
  - 10.0.0.0/8
461
488
  - 192.168.0.0/16
489
+
490
+ # IP deny list
491
+ #
492
+ # Refuse this service to these addresses and ranges with a 403. Checked before
493
+ # `allow_ips`: an address on both lists is denied. Denied clients never spend
494
+ # rate-limit budget. Combine with `client_ip` above when behind a CDN, or the
495
+ # list is matched against the CDN's addresses rather than your visitors'.
496
+ deny_ips:
497
+ - 203.0.113.0/24
498
+ - 198.51.100.7
499
+
500
+ # User-agent deny list
501
+ #
502
+ # Refuse requests whose full User-Agent matches one of these RE2 patterns,
503
+ # checked after the IP rules. A missing User-Agent only matches an explicit
504
+ # '^$' pattern. Patterns are matched by kamal-proxy (Go RE2), so kamal checks
505
+ # only their shape, not their syntax.
506
+ deny_user_agents:
507
+ - 'BadBot/.*'
462
508
  #
463
509
  # ### Notes
464
510
  # - The health check path is served without an address check and without a rate
@@ -786,9 +832,9 @@ proxy:
786
832
  # `repository` to a host-less path (e.g.
787
833
  # myfork/kamal-proxy) - the default repository
788
834
  # below already embeds its ghcr.io host
789
- repository: ghcr.io/mhenrixon/kamal-proxy # Container repository for the
835
+ repository: ghcr.io/zoolutions/kamal-proxy # Container repository for the
790
836
  # kamal-proxy image (this is the default)
791
- version: v1.0.0.1 # Version tag of the kamal-proxy image to use.
837
+ version: v1.0.0.3 # Version tag of the kamal-proxy image to use.
792
838
  # Defaults to the minimum version this gem
793
839
  # requires - only pin it to roll forward early,
794
840
  # never below the default
@@ -810,6 +856,12 @@ proxy:
810
856
  # unsupported name is rejected here, at config time - kamal-proxy would only
811
857
  # log a warning and then never issue a certificate.
812
858
  #
859
+ # A plain string (`dns_provider: cloudflare`) uses one provider for every
860
+ # zone. The hash form pins zones to the DNS host that actually serves them,
861
+ # with `default` covering unmatched zones - for estates whose domains are
862
+ # spread across registrars. Each provider's API credentials must be present
863
+ # under `credentials` for its challenges to succeed.
864
+ #
813
865
  # kamal-proxy also accepts short aliases for the canonical names - `cf`
814
866
  # (cloudflare), `do` (digitalocean), `gcp`/`google`/`googledns` (gcloud),
815
867
  # `gd` (godaddy), `hz` (hetzner), `nc` (namecheap), `aws`/`r53` (route53)
@@ -837,7 +889,10 @@ proxy:
837
889
  # `kamal proxy reboot` yourself after a rotation.
838
890
  acme:
839
891
  email: admin@example.com
840
- dns_provider: cloudflare
892
+ dns_provider:
893
+ platform.example: cloudflare
894
+ legacy.example: hetzner
895
+ default: route53
841
896
  prefer_wildcard: true
842
897
  http_fallback: false
843
898
  directory: https://acme-staging-v02.api.letsencrypt.org/directory
@@ -12,12 +12,24 @@ class Kamal::Configuration::Loadbalancer < Kamal::Configuration::Proxy
12
12
 
13
13
  # The load balancer fans a single service out to many targets, so unlike the
14
14
  # per-app proxy deploy (which takes one target) it takes the full list and
15
- # joins them into a single --target flag, honouring app_port for each.
15
+ # joins them into a single --target flag.
16
+ #
17
+ # Each target is a per-host proxy, reached on its published HTTP port
18
+ # (run.http_port, default 80) - the only cross-host surface it exposes.
19
+ # Never app_port: that is how a per-host proxy reaches the app container
20
+ # inside its own docker network, and nothing listens on it across hosts.
16
21
  def deploy_command_args(targets:)
17
- target_arg = targets.map { |target| "#{target}:#{app_port}" }.join(",")
22
+ target_arg = targets.map { |target| "#{target}:#{run.http_port}" }.join(",")
18
23
  optionize ({ target: target_arg }).merge(deploy_options), with: "="
19
24
  end
20
25
 
26
+ # The hosts the load balancer forwards to: every host of every role that
27
+ # runs a proxy. One source of truth for the deploy step and the reboot
28
+ # re-registration, so their target lists cannot diverge.
29
+ def target_hosts
30
+ config.roles.select(&:running_proxy?).flat_map(&:hosts)
31
+ end
32
+
21
33
  def directory
22
34
  File.join config.run_directory, "loadbalancer"
23
35
  end
@@ -60,10 +60,25 @@ class Kamal::Configuration::Proxy::Acme
60
60
  def run_command_options
61
61
  {
62
62
  "acme-email": acme_config["email"],
63
- "acme-dns-provider": acme_config["dns_provider"],
63
+ "acme-dns-provider": dns_provider_entries,
64
64
  "acme-directory": acme_config["directory"],
65
65
  "acme-prefer-wildcard": acme_config["prefer_wildcard"],
66
66
  "acme-http-fallback": acme_config["http_fallback"]
67
67
  }.compact
68
68
  end
69
+
70
+ private
71
+ # The hash form pins zones to the DNS host that serves them; `default`
72
+ # covers unmatched zones. kamal-proxy takes repeatable --acme-dns-provider
73
+ # entries — zone=provider pairs plus at most one bare default — so the hash
74
+ # becomes an array and Utils.optionize repeats the flag. The string form
75
+ # passes through untouched and keeps meaning what it always has.
76
+ def dns_provider_entries
77
+ provider = acme_config["dns_provider"]
78
+ return provider unless provider.is_a?(Hash)
79
+
80
+ zones, default = provider.partition { |zone, _| zone != "default" }
81
+
82
+ [ *zones.map { |zone, zone_provider| "#{zone}=#{zone_provider}" }, *default.map(&:last) ]
83
+ end
69
84
  end
@@ -30,7 +30,7 @@ class Kamal::Configuration::Proxy::Boot
30
30
  end
31
31
 
32
32
  def repository_name
33
- "ghcr.io/mhenrixon"
33
+ "ghcr.io/zoolutions"
34
34
  end
35
35
 
36
36
  def image_name
@@ -1,5 +1,5 @@
1
1
  class Kamal::Configuration::Proxy::Run
2
- MINIMUM_VERSION = "v1.0.0.1"
2
+ MINIMUM_VERSION = "v1.0.0.3"
3
3
  DEFAULT_HTTP_PORT = 80
4
4
  DEFAULT_HTTPS_PORT = 443
5
5
  DEFAULT_LOG_MAX_SIZE = "10m"
@@ -95,7 +95,7 @@ class Kamal::Configuration::Proxy::Run
95
95
  end
96
96
 
97
97
  def repository
98
- run_config.fetch("repository", "ghcr.io/mhenrixon/kamal-proxy")
98
+ run_config.fetch("repository", "ghcr.io/zoolutions/kamal-proxy")
99
99
  end
100
100
 
101
101
  def image
@@ -51,6 +51,8 @@ class Kamal::Configuration::Proxy
51
51
  # request (its peer is the LB) and one limiter would count the whole
52
52
  # fleet as a single client.
53
53
  "allow-ip": :edge,
54
+ "deny-ip": :edge,
55
+ "deny-user-agent": :edge,
54
56
  "trusted-proxy": :edge,
55
57
  "client-ip-header": :edge,
56
58
  "rate-limit": :edge,
@@ -69,9 +71,12 @@ class Kamal::Configuration::Proxy
69
71
  "session-affinity-cookie": :edge,
70
72
 
71
73
  # --- Edge: redirectURLIfNeeded consults r.TLS only, so behind the LB a
72
- # per-app redirect emits http:// Locations to HTTPS clients.
74
+ # per-app redirect emits http:// Locations to HTTPS clients. The dynamic
75
+ # redirect map answers where clients connect, for the same reason.
73
76
  "canonical-host": :edge,
74
77
  redirect: :edge,
78
+ "redirects-source": :edge,
79
+ "redirects-interval": :edge,
75
80
 
76
81
  # --- Edge: one response cache, at the edge — two layers of cache would
77
82
  # double the storage and let the inner cache serve entries the edge
@@ -499,6 +504,10 @@ class Kamal::Configuration::Proxy
499
504
  "remove-response-header": headers_config.dig("response", "remove").presence,
500
505
  redirect: path_rules("redirects"),
501
506
  rewrite: path_rules("rewrites"),
507
+ # The dynamic map is consulted before the static redirect rules above -
508
+ # they compose, the rules running when the map misses.
509
+ "redirects-source": proxy_config.dig("redirects_source", "source"),
510
+ "redirects-interval": seconds_duration(proxy_config.dig("redirects_source", "interval")),
502
511
  "canonical-host": proxy_config["canonical_host"],
503
512
  "intercept-errors": proxy_config["intercept_errors"].presence
504
513
  }.compact
@@ -532,6 +541,10 @@ class Kamal::Configuration::Proxy
532
541
 
533
542
  {
534
543
  "allow-ip": proxy_config["allow_ips"].presence,
544
+ # Checked before the allow list on the proxy side - an address on both
545
+ # lists is denied. UA patterns run after the IP rules.
546
+ "deny-ip": proxy_config["deny_ips"].presence,
547
+ "deny-user-agent": proxy_config["deny_user_agents"].presence,
535
548
  "trusted-proxy": client_ip["trusted_proxies"].presence,
536
549
  "client-ip-header": client_ip["header"],
537
550
  "rate-limit": rate_limit["requests"],
@@ -32,6 +32,10 @@ class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator
32
32
  validate_ssl_domains! config["ssl_domains"]
33
33
  end
34
34
 
35
+ if config["redirects_source"]
36
+ validate_redirects_source! config["redirects_source"]
37
+ end
38
+
35
39
  if config["basic_auth"].is_a?(Hash)
36
40
  validate_basic_auth! config["basic_auth"]
37
41
  end
@@ -109,6 +113,25 @@ class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator
109
113
  end
110
114
  end
111
115
 
116
+ # Same source shape as ssl_domains. The interval minimum is kamal-proxy's
117
+ # own (MinRedirectsInterval, 10s) - it rejects a smaller one after the SSH
118
+ # round trip, so catch it here at config time.
119
+ def validate_redirects_source!(redirects_source)
120
+ with_context("redirects_source") do
121
+ source = redirects_source["source"]
122
+
123
+ if source.blank?
124
+ error "Missing source setting (required when redirects_source is set)"
125
+ elsif !valid_ssl_domains_source?(source)
126
+ error "source must be a path starting with '/' or an http(s) URL"
127
+ end
128
+
129
+ if (interval = redirects_source["interval"]) && (!interval.is_a?(Integer) || interval < 10)
130
+ error "interval must be an integer of at least 10 seconds"
131
+ end
132
+ end
133
+ end
134
+
112
135
  # kamal-proxy only logs a warning for a DNS provider it does not recognise
113
136
  # and then carries on with no provider at all, so the symptom is a
114
137
  # certificate that never issues rather than a failed boot. Catch it here,
@@ -122,7 +145,9 @@ class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator
122
145
 
123
146
  provider = acme["dns_provider"]
124
147
 
125
- if provider.present? && !Kamal::Configuration::Proxy::Acme::SUPPORTED_DNS_PROVIDERS.include?(provider.to_s.downcase)
148
+ if provider.is_a?(Hash)
149
+ validate_dns_provider_zones! provider
150
+ elsif provider.present? && !supported_dns_provider?(provider)
126
151
  error "unsupported dns_provider '#{provider}'. " \
127
152
  "Supported providers: #{Kamal::Configuration::Proxy::Acme::DNS_PROVIDERS.join(", ")}"
128
153
  end
@@ -130,6 +155,35 @@ class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator
130
155
  end
131
156
  end
132
157
 
158
+ # The hash form maps zones to providers, with `default` covering unmatched
159
+ # zones. Wire format is repeatable zone=provider entries cut at the first
160
+ # '=', so a zone carrying one would silently build a different entry.
161
+ def validate_dns_provider_zones!(provider)
162
+ error "dns_provider cannot be an empty hash - map zones to providers, or use the string form" if provider.empty?
163
+
164
+ provider.each do |zone, zone_provider|
165
+ if zone.to_s.include?("=")
166
+ error "dns_provider zone '#{zone}' cannot contain '=' - " \
167
+ "the zone=provider wire format cuts at the first '=' and would silently build a different entry"
168
+ end
169
+
170
+ # A malformed zone key would emit an entry no zone ever matches, and the
171
+ # symptom is a certificate that never issues - a long way from here.
172
+ unless zone.is_a?(String) && zone.present? && !zone.match?(/\s/)
173
+ error "dns_provider zone '#{zone}' must be a non-empty string without whitespace"
174
+ end
175
+
176
+ unless zone_provider.is_a?(String) && supported_dns_provider?(zone_provider)
177
+ error "unsupported dns_provider '#{zone_provider}' for '#{zone}'. " \
178
+ "Supported providers: #{Kamal::Configuration::Proxy::Acme::DNS_PROVIDERS.join(", ")}"
179
+ end
180
+ end
181
+ end
182
+
183
+ def supported_dns_provider?(provider)
184
+ Kamal::Configuration::Proxy::Acme::SUPPORTED_DNS_PROVIDERS.include?(provider.to_s.downcase)
185
+ end
186
+
133
187
  # The whole TLS surface lives in the one `ssl` hash: certificate material,
134
188
  # on-demand issuance and the mTLS client CA. kamal-proxy rejects the
135
189
  # on-demand combinations outright rather than picking a winner
@@ -409,6 +463,15 @@ class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator
409
463
  return true
410
464
  end
411
465
 
466
+ # A string or a zone→provider hash; the docs example can only show one
467
+ # shape, and zone names are the operator's to choose, so the example-driven
468
+ # unknown-key check would reject every real config. validate_acme! does the
469
+ # semantic checking (provider names, zone shapes).
470
+ if key.to_s == "dns_provider"
471
+ validate_type! value, String, Hash
472
+ return true
473
+ end
474
+
412
475
  return false unless key.to_s == "rate_limit"
413
476
 
414
477
  validate_type! value, Hash
@@ -429,14 +492,27 @@ class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator
429
492
  # otherwise surface only once the deploy has reached a host.
430
493
  def validate_access_control!
431
494
  allow_ips = Array(config["allow_ips"])
495
+ deny_ips = Array(config["deny_ips"])
432
496
  client_ip = config["client_ip"] || {}
433
497
  rate_limit = config["rate_limit"] || {}
434
498
  trusted_proxies = Array(client_ip["trusted_proxies"])
435
499
  rate_limited = rate_limit["requests"].present?
436
500
 
437
501
  with_context("allow_ips") { allow_ips.each { |entry| validate_ip_entry! entry } }
502
+ with_context("deny_ips") { deny_ips.each { |entry| validate_ip_entry! entry } }
438
503
  with_context("rate_limit") { with_context("exempt") { Array(rate_limit["exempt"]).each { |entry| validate_ip_entry! entry } } }
439
504
 
505
+ # The patterns are deliberately not compiled - Go's RE2 and Ruby's Onigmo
506
+ # differ at the edges, same as redirects/rewrites - so only the shape is
507
+ # checked: a non-string or blank entry can never match anything.
508
+ with_context("deny_user_agents") do
509
+ Array(config["deny_user_agents"]).each do |pattern|
510
+ unless pattern.is_a?(String) && pattern.present?
511
+ error "'#{pattern}' is not a user agent pattern - each entry must be a non-empty RE2 pattern string"
512
+ end
513
+ end
514
+ end
515
+
440
516
  with_context("client_ip") do
441
517
  with_context("trusted_proxies") do
442
518
  trusted_proxies.each do |entry|
@@ -451,8 +527,8 @@ class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator
451
527
  end
452
528
  end
453
529
 
454
- if trusted_proxies.any? && allow_ips.empty? && !rate_limited
455
- error "trusted_proxies has no effect without allow_ips or rate_limit"
530
+ if trusted_proxies.any? && allow_ips.empty? && deny_ips.empty? && !rate_limited
531
+ error "trusted_proxies has no effect without allow_ips, deny_ips or rate_limit"
456
532
  end
457
533
 
458
534
  # Unconditional: even without allow_ips/rate_limit, kamal-proxy rewrites
@@ -475,10 +551,10 @@ class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator
475
551
 
476
552
  # kamal-proxy serves the health check path without an address check and
477
553
  # 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") == "/"
554
+ # straight through all three features.
555
+ if (allow_ips.any? || deny_ips.any? || rate_limited) && config.dig("healthcheck", "path") == "/"
480
556
  with_context("healthcheck") do
481
- error "path cannot be '/' when allow_ips or rate_limit is set, " \
557
+ error "path cannot be '/' when allow_ips, deny_ips or rate_limit is set, " \
482
558
  "as that path is served without an address check or a rate limit"
483
559
  end
484
560
  end
@@ -531,27 +531,38 @@ class Kamal::Configuration
531
531
  true
532
532
  end
533
533
 
534
- # A rate limiter is only as correct as the address it keys on. `forward_headers:
535
- # true` says something sits in front of the proxy, and without `trusted_proxies`
536
- # kamal-proxy keys on that something's address rather than the client's — so the
537
- # limiter throttles the whole world as one client, or nobody at all. Warn rather
538
- # than raise: the config is legal, just almost certainly not what was meant.
534
+ # Rate limiting and IP deny rules are only as correct as the address they key
535
+ # on. `forward_headers: true` says something sits in front of the proxy, and
536
+ # without `trusted_proxies` kamal-proxy keys on that something's address
537
+ # rather than the client's so the limiter throttles the whole world as one
538
+ # client (or nobody), and a deny list denies nobody it was written for. Warn
539
+ # rather than raise: the config is legal, just almost certainly not what was
540
+ # meant. (deny_user_agents is absent here on purpose — a User-Agent match
541
+ # never keys on the client address.)
539
542
  def ensure_rate_limit_can_identify_clients
540
- offenders = roles.select do |role|
541
- next false unless role.running_proxy?
543
+ offenders = roles.filter_map do |role|
544
+ next unless role.running_proxy?
542
545
 
543
546
  proxy_config = role.proxy.proxy_config
544
- proxy_config.dig("rate_limit", "requests").present? &&
545
- proxy_config["forward_headers"] &&
547
+ next unless proxy_config["forward_headers"] &&
546
548
  Array(proxy_config.dig("client_ip", "trusted_proxies")).empty?
549
+
550
+ features = []
551
+ features << "rate_limit" if proxy_config.dig("rate_limit", "requests").present?
552
+ features << "deny_ips" if proxy_config["deny_ips"].present?
553
+
554
+ [ role.name, features ] if features.any?
547
555
  end
548
556
 
549
557
  return true if offenders.empty?
550
558
 
551
- warn "Role(s) #{offenders.map(&:name).join(", ")}: rate_limit is set with forward_headers, " \
552
- "but no proxy/client_ip/trusted_proxies. kamal-proxy will key the limiter on the address of " \
553
- "whatever sits in front of it, not on the client's so it throttles every visitor as one " \
554
- "client, or none of them. Declare the proxies in front with `client_ip: trusted_proxies:`."
559
+ offenders.each do |role_name, features|
560
+ warn "Role #{role_name}: #{features.join(" and ")} is set with forward_headers, " \
561
+ "but no proxy/client_ip/trusted_proxies. kamal-proxy will key on the address of " \
562
+ "whatever sits in front of it, not on the client's so the rules apply to every " \
563
+ "visitor as one client, or to none of them. Declare the proxies in front with " \
564
+ "`client_ip: trusted_proxies:`."
565
+ end
555
566
 
556
567
  true
557
568
  end
data/lib/kamal/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Kamal
2
- VERSION = "3.0.0"
2
+ VERSION = "3.1.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dash
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -304,6 +304,7 @@ files:
304
304
  - lib/kamal/commands/loadbalancer.rb
305
305
  - lib/kamal/commands/lock.rb
306
306
  - lib/kamal/commands/proxy.rb
307
+ - lib/kamal/commands/proxy/cert_transfer.rb
307
308
  - lib/kamal/commands/prune.rb
308
309
  - lib/kamal/commands/registry.rb
309
310
  - lib/kamal/commands/server.rb