Skip to content
94 changes: 94 additions & 0 deletions lib/kamal/cli/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,73 @@ def domains(subcommand)
end
end

desc "export_certs LOCAL_PATH", "Export the TLS certificate store to a local archive (contains private keys)"
def export_certs(local_path)
Comment thread
mhenrixon marked this conversation as resolved.
load_balancing = KAMAL.config.proxy.load_balancing?

on(cert_store_host) do |host|
execute *KAMAL.auditor.record("Exported the proxy certificate store"), verbosity: :debug

commands = load_balancing ? KAMAL.loadbalancer : KAMAL.proxy(host)
execute *commands.ensure_apps_config_directory

# Running: export through the container's RPC socket, under the proxy's
# certificate write lock. Stopped: read the data directory offline with a
# one-off container - which may first need the image.
if capture_with_info(*commands.container_id(only_running: true), raise_on_non_zero_exit: false).strip.present?
puts capture_with_info(*commands.export_certs)
else
execute *KAMAL.registry.login
puts capture_with_info(*commands.export_certs_offline)
end

download! commands.certs_archive_host_path, local_path
execute *commands.remove_certs_archive, raise_on_non_zero_exit: false
Comment thread
mhenrixon marked this conversation as resolved.
Outdated
end
end

desc "import_certs", "Import certificates into the TLS certificate store from a Traefik acme.json or an exported archive"
option :traefik_acme, type: :string, default: nil, desc: "Local path of a Traefik acme.json to import from"
option :archive, type: :string, default: nil, desc: "Local path of an archive written by kamal proxy export_certs"
option :resolver, type: :string, default: nil, desc: "Import only this Traefik resolver's certificates (default: all, last writer wins per domain)"
option :force, type: :boolean, default: false, desc: "Overwrite a non-empty certificate store when restoring an archive"
option :verify, type: :boolean, default: false, desc: "Only verify the archive: report domains and expiries without touching the store"
def import_certs
validate_import_certs_options!
source = options[:traefik_acme] || options[:archive]
traefik_acme, resolver = options[:traefik_acme].present?, options[:resolver]
force, verify = options[:force], options[:verify]
load_balancing = KAMAL.config.proxy.load_balancing?

modify(lock: true) do
on(cert_store_host) do |host|
commands = load_balancing ? KAMAL.loadbalancer : KAMAL.proxy(host)

# kamal-proxy import runs offline against the data directory - importing
# under a live proxy risks a torn store. --verify only reads the archive.
unless verify
if capture_with_info(*commands.container_id(only_running: true), raise_on_non_zero_exit: false).strip.present?
raise "Cannot import certificates while the #{load_balancing ? "loadbalancer" : "proxy"} " \
"is running on #{host} - stop it first " \
"(kamal proxy #{load_balancing ? "loadbalancer stop" : "stop"}), import, then start it again"
end
end

execute *KAMAL.auditor.record("Imported certificates into the proxy certificate store"), verbosity: :debug
execute *KAMAL.registry.login
execute *commands.ensure_proxy_directory
upload! source, commands.certs_import_host_path, mode: "0600"
Comment thread
mhenrixon marked this conversation as resolved.
Outdated

begin
puts capture_with_info(*commands.import_certs(
traefik_acme: traefik_acme, resolver: resolver, force: force, verify: verify))
ensure
execute *commands.remove_certs_import, raise_on_non_zero_exit: false
end
end
end
end

desc "remove_container", "Remove proxy container from servers", hide: true
def remove_container
modify(lock: true) do
Expand Down Expand Up @@ -556,6 +623,33 @@ def remove_proxy_directory
end

private
# The host that owns TLS, and so the certificate store: the loadbalancer
# host when load balancing (TLS terminates at the edge), else the primary
# host - the same host `loadbalancer: true` would resolve to.
def cert_store_host
KAMAL.config.proxy.load_balancing? ? KAMAL.config.proxy.effective_loadbalancer : KAMAL.primary_host
end

# Mirrors kamal-proxy's own flag groups (import.go), so a contradictory
# invocation fails before anything is uploaded.
def validate_import_certs_options!
if options[:traefik_acme].present? == options[:archive].present?
raise ArgumentError, "Specify exactly one of --traefik-acme or --archive"
end

if options[:resolver].present? && options[:archive].present?
raise ArgumentError, "--resolver only applies to a Traefik import"
end

if options[:traefik_acme].present? && (options[:force] || options[:verify])
raise ArgumentError, "--force and --verify only apply to an archive"
end

if options[:force] && options[:verify]
raise ArgumentError, "--verify does not touch the store, so it cannot be combined with --force"
end
end

# A shared load balancer tier is the exact case this guard exists for, so it
# has to cover the load balancer host too - `remove_container` and
# `remove_image` both act on it.
Expand Down
16 changes: 14 additions & 2 deletions lib/kamal/commands/loadbalancer.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class Kamal::Commands::Loadbalancer < Kamal::Commands::Base
include Kamal::Commands::Proxy::CertTransfer

delegate :argumentize, :optionize, to: Kamal::Utils

attr_reader :loadbalancer_config
Expand Down Expand Up @@ -63,8 +65,8 @@ def config_digest
docker :inspect, container_name, "--format", "'{{ index .Config.Labels \"#{Kamal::Commands::Proxy::CONFIG_DIGEST_LABEL}\" }}'"
end

def container_id
container_id_for(container_name: container_name)
def container_id(only_running: false)
container_id_for(container_name: container_name, only_running: only_running)
end

def info
Expand Down Expand Up @@ -169,4 +171,14 @@ def config_volume
[ "--volume", "kamal-loadbalancer-config:/home/kamal-proxy/.config/kamal-proxy" ]
end
end

# The certificate store lives in whichever config volume this loadbalancer
# actually mounts — the shared kamal-proxy one on a proxy host.
def cert_store_volume_args
config_volume
end

def one_off_image
[ loadbalancer_config.run.image ]
end
end
16 changes: 16 additions & 0 deletions lib/kamal/commands/proxy.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class Kamal::Commands::Proxy < Kamal::Commands::Base
include Kamal::Commands::Proxy::CertTransfer

delegate :argumentize, :optionize, to: Kamal::Utils
attr_reader :proxy_run_config

Expand Down Expand Up @@ -232,6 +234,20 @@ def container_name
config.proxy_boot.container_name
end

def cert_store_volume_args
[ "--volume", "kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy" ]
end

# Same fallback as #pull: without a run config the image comes from the
# legacy boot config files on the host.
def one_off_image
if proxy_run_config
[ proxy_run_config.image ]
else
[ "#{substitute(read_image)}:#{substitute(read_image_version)}" ]
end
end

def config_digest_label_args(digest)
[ "--label", "#{CONFIG_DIGEST_LABEL}=#{digest}" ] if digest
end
Expand Down
71 changes: 71 additions & 0 deletions lib/kamal/commands/proxy/cert_transfer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Certificate store transfer, shared by the proxy and loadbalancer command
# builders (kamal proxy export_certs / import_certs).
#
# Archives leave through the apps-config bind mount — the one container path
# that is also a host path — and arrive through stdin into a one-off container:
# a bind-mounted source would need host permissions the container user cannot
# be guaranteed to have, and the store must be written as the image's own user
# or the proxy cannot read it afterwards.
#
# The including class provides `container_name`, `cert_store_volume_args` (the
# config volume mount) and `one_off_image` (the image tokens for a one-off
# container).
module Kamal::Commands::Proxy::CertTransfer
CERT_ARCHIVE_FILENAME = "certs-export.tar.gz"
CERT_IMPORT_STAGING_FILENAME = "certs-import"
CONTAINER_IMPORT_PATH = "/tmp/kamal-cert-import"

# Through the RPC socket of the running container, under the proxy's own
# certificate write lock, so a backup taken mid-renewal is never torn.
def export_certs
docker :exec, container_name, "kamal-proxy", :export, :certs, certs_archive_container_path
end

# Reads the data directory offline over the config volume — only safe when
# the container is stopped, which Kamal::Cli::Proxy guarantees.
def export_certs_offline
docker :run, "--rm",
*cert_store_volume_args,
*config.proxy_boot.apps_volume.docker_args,
*one_off_image,
"kamal-proxy", :export, :certs, certs_archive_container_path
end

# Offline by design (kamal-proxy import has no RPC path): the one-off
# container mounts the config volume — creating it when no proxy has booted
# yet, which is the Traefik-migration case — and the staged source streams
# through stdin.
def import_certs(traefik_acme: false, resolver: nil, force: false, verify: false)
source_flag = traefik_acme ? "traefik-acme" : "archive"
import_command = [
"cat > #{CONTAINER_IMPORT_PATH} &&",
"kamal-proxy import certs",
*optionize({ source_flag => CONTAINER_IMPORT_PATH, resolver: resolver, force: force || nil, verify: verify || nil }.compact, with: "=")
].join(" ")

[
*docker(:run, "--rm", "--interactive", *cert_store_volume_args, *one_off_image, "sh", "-c", "'#{import_command}'"),
Comment thread
mhenrixon marked this conversation as resolved.
Outdated
"<", certs_import_host_path
]
end

def certs_archive_host_path
File.join config.proxy_boot.apps_directory, CERT_ARCHIVE_FILENAME
end

def certs_archive_container_path
File.join config.proxy_boot.apps_container_directory, CERT_ARCHIVE_FILENAME
end

def certs_import_host_path
File.join config.proxy_boot.host_directory, CERT_IMPORT_STAGING_FILENAME
end

def remove_certs_archive
remove_file certs_archive_host_path
end

def remove_certs_import
remove_file certs_import_host_path
end
end
37 changes: 24 additions & 13 deletions lib/kamal/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -531,27 +531,38 @@ def ensure_intercepted_errors_have_pages
true
end

# A rate limiter is only as correct as the address it keys on. `forward_headers:
# true` says something sits in front of the proxy, and without `trusted_proxies`
# kamal-proxy keys on that something's address rather than the client's — so the
# limiter throttles the whole world as one client, or nobody at all. Warn rather
# than raise: the config is legal, just almost certainly not what was meant.
# Rate limiting and IP deny rules are only as correct as the address they key
# on. `forward_headers: true` says something sits in front of the proxy, and
# without `trusted_proxies` kamal-proxy keys on that something's address
# rather than the client's — so the limiter throttles the whole world as one
# client (or nobody), and a deny list denies nobody it was written for. Warn
# rather than raise: the config is legal, just almost certainly not what was
# meant. (deny_user_agents is absent here on purpose — a User-Agent match
# never keys on the client address.)
def ensure_rate_limit_can_identify_clients
offenders = roles.select do |role|
next false unless role.running_proxy?
offenders = roles.filter_map do |role|
next unless role.running_proxy?

proxy_config = role.proxy.proxy_config
proxy_config.dig("rate_limit", "requests").present? &&
proxy_config["forward_headers"] &&
next unless proxy_config["forward_headers"] &&
Array(proxy_config.dig("client_ip", "trusted_proxies")).empty?

features = []
features << "rate_limit" if proxy_config.dig("rate_limit", "requests").present?
features << "deny_ips" if proxy_config["deny_ips"].present?

[ role.name, features ] if features.any?
end

return true if offenders.empty?

warn "Role(s) #{offenders.map(&:name).join(", ")}: rate_limit is set with forward_headers, " \
"but no proxy/client_ip/trusted_proxies. kamal-proxy will key the limiter on the address of " \
"whatever sits in front of it, not on the client's — so it throttles every visitor as one " \
"client, or none of them. Declare the proxies in front with `client_ip: trusted_proxies:`."
offenders.each do |role_name, features|
warn "Role #{role_name}: #{features.join(" and ")} is set with forward_headers, " \
"but no proxy/client_ip/trusted_proxies. kamal-proxy will key on the address of " \
"whatever sits in front of it, not on the client's — so the rules apply to every " \
"visitor as one client, or to none of them. Declare the proxies in front with " \
"`client_ip: trusted_proxies:`."
end

true
end
Expand Down
59 changes: 57 additions & 2 deletions lib/kamal/configuration/docs/proxy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,33 @@ proxy:
- from: /api/(.*)
to: /v2/$1

# Dynamic redirect map
#
# Fetch a host-scoped redirect map from the application itself, so redirects
# ship with a content change instead of a deploy. `source` is a path resolved
# against this service, or an absolute http(s) URL. The app publishes
# `{"hosts": {...}}` entries — per-host `redirect_to`, path rules and
# trailing-slash policy — and the proxy answers matching requests before they
# reach the app.
#
# The map composes with the static `redirects` above: the map is consulted
# first, and the static rules run when it misses.
#
# `interval` is the poll interval in seconds (proxy default 300, minimum 10).
#
# Authentication tokens live in the PROXY's environment, not the app's: polls
# send `KAMAL_PROXY_REDIRECTS_TOKEN` as a bearer token when set, and
# `POST /.kamal-proxy/redirects/refresh` nudges an immediate re-poll when
# authenticated with `KAMAL_PROXY_REFRESH_TOKEN`. Set both via
# `proxy.run.options.env`, next to `KAMAL_PROXY_DOMAINS_TOKEN` — never as
# deploy flags, which leak into process listings and audit logs.
#
# When a loadbalancer is configured the map answers at the loadbalancer, same
# as `ssl_domains` and `canonical_host` — the per-host proxies never see it.
redirects_source:
source: /api/v1/proxy/redirects
interval: 300

# Canonical host
#
# Redirect every request to this host, to force apex or www one way.
Expand Down Expand Up @@ -459,6 +486,25 @@ proxy:
allow_ips:
- 10.0.0.0/8
- 192.168.0.0/16

# IP deny list
#
# Refuse this service to these addresses and ranges with a 403. Checked before
# `allow_ips`: an address on both lists is denied. Denied clients never spend
# rate-limit budget. Combine with `client_ip` above when behind a CDN, or the
# list is matched against the CDN's addresses rather than your visitors'.
deny_ips:
- 203.0.113.0/24
- 198.51.100.7

# User-agent deny list
#
# Refuse requests whose full User-Agent matches one of these RE2 patterns,
# checked after the IP rules. A missing User-Agent only matches an explicit
# '^$' pattern. Patterns are matched by kamal-proxy (Go RE2), so kamal checks
# only their shape, not their syntax.
deny_user_agents:
- 'BadBot/.*'
#
# ### Notes
# - The health check path is served without an address check and without a rate
Expand Down Expand Up @@ -788,7 +834,7 @@ proxy:
# below already embeds its ghcr.io host
repository: ghcr.io/mhenrixon/kamal-proxy # Container repository for the
# kamal-proxy image (this is the default)
version: v1.0.0.1 # Version tag of the kamal-proxy image to use.
version: v1.0.0.2 # Version tag of the kamal-proxy image to use.
# Defaults to the minimum version this gem
# requires - only pin it to roll forward early,
# never below the default
Expand All @@ -810,6 +856,12 @@ proxy:
# unsupported name is rejected here, at config time - kamal-proxy would only
# log a warning and then never issue a certificate.
#
# A plain string (`dns_provider: cloudflare`) uses one provider for every
# zone. The hash form pins zones to the DNS host that actually serves them,
# with `default` covering unmatched zones - for estates whose domains are
# spread across registrars. Each provider's API credentials must be present
# under `credentials` for its challenges to succeed.
#
# kamal-proxy also accepts short aliases for the canonical names - `cf`
# (cloudflare), `do` (digitalocean), `gcp`/`google`/`googledns` (gcloud),
# `gd` (godaddy), `hz` (hetzner), `nc` (namecheap), `aws`/`r53` (route53)
Expand Down Expand Up @@ -837,7 +889,10 @@ proxy:
# `kamal proxy reboot` yourself after a rotation.
acme:
email: admin@example.com
dns_provider: cloudflare
dns_provider:
platform.example: cloudflare
legacy.example: hetzner
default: route53
prefer_wildcard: true
http_fallback: false
directory: https://acme-staging-v02.api.letsencrypt.org/directory
Expand Down
Loading
Loading