dash 3.0.1 → 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 +4 -4
- data/lib/kamal/cli/proxy.rb +104 -0
- data/lib/kamal/commands/loadbalancer.rb +14 -2
- data/lib/kamal/commands/proxy/cert_transfer.rb +74 -0
- data/lib/kamal/commands/proxy.rb +16 -0
- data/lib/kamal/configuration/docs/proxy.yml +58 -3
- data/lib/kamal/configuration/proxy/acme.rb +16 -1
- data/lib/kamal/configuration/proxy/boot.rb +1 -1
- data/lib/kamal/configuration/proxy/run.rb +2 -2
- data/lib/kamal/configuration/proxy.rb +14 -1
- data/lib/kamal/configuration/validator/proxy.rb +82 -6
- data/lib/kamal/configuration.rb +24 -13
- data/lib/kamal/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 76de5dcf1c023a36baae2b55567695e691d2a02ac65df587469742d95f043d55
|
|
4
|
+
data.tar.gz: d1b681c9d34d5313840bb588d24235c4f8d30058f7b01d1778390dd7056f56fd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3418967bd8855f630c65a2a27090248ebda77b2f585f97039ca0b27b7d173936b905823854e9c2f156c1986003eedc18cb9373da56661d7267df425ab6dc83a7
|
|
7
|
+
data.tar.gz: '0439bbced6c8a6d496283970df7a21e5e679aa99052fc90d2e8ef6ba5072c0a954b2206e03c5c01200622b169d5d1bbec73d96c48c0fbaf9cc36ed879f25a752'
|
data/lib/kamal/cli/proxy.rb
CHANGED
|
@@ -504,6 +504,83 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
|
|
|
504
504
|
end
|
|
505
505
|
end
|
|
506
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
|
+
|
|
507
584
|
desc "remove_container", "Remove proxy container from servers", hide: true
|
|
508
585
|
def remove_container
|
|
509
586
|
modify(lock: true) do
|
|
@@ -556,6 +633,33 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
|
|
|
556
633
|
end
|
|
557
634
|
|
|
558
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
|
+
|
|
559
663
|
# A shared load balancer tier is the exact case this guard exists for, so it
|
|
560
664
|
# has to cover the load balancer host too - `remove_container` and
|
|
561
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
|
|
@@ -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
|
data/lib/kamal/commands/proxy.rb
CHANGED
|
@@ -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
|
|
|
@@ -232,6 +234,20 @@ class Kamal::Commands::Proxy < Kamal::Commands::Base
|
|
|
232
234
|
config.proxy_boot.container_name
|
|
233
235
|
end
|
|
234
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
|
+
|
|
235
251
|
def config_digest_label_args(digest)
|
|
236
252
|
[ "--label", "#{CONFIG_DIGEST_LABEL}=#{digest}" ] if digest
|
|
237
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/
|
|
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.
|
|
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:
|
|
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
|
|
@@ -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":
|
|
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
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
class Kamal::Configuration::Proxy::Run
|
|
2
|
-
MINIMUM_VERSION = "v1.0.0.
|
|
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/
|
|
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.
|
|
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
|
|
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
|
data/lib/kamal/configuration.rb
CHANGED
|
@@ -531,27 +531,38 @@ class Kamal::Configuration
|
|
|
531
531
|
true
|
|
532
532
|
end
|
|
533
533
|
|
|
534
|
-
#
|
|
535
|
-
# true` says something sits in front of the proxy, and
|
|
536
|
-
# kamal-proxy keys on that something's address
|
|
537
|
-
#
|
|
538
|
-
#
|
|
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.
|
|
541
|
-
next
|
|
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
|
|
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
|
-
|
|
552
|
-
"
|
|
553
|
-
|
|
554
|
-
|
|
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
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
|
|
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
|