Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions lib/kamal/cli/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,26 @@ def loadbalancer(status)
end
end

desc "domains SUBCOMMAND", "Manage dynamic TLS domains (refresh, list, stats)"
def domains(subcommand)
case subcommand
when "refresh", "list", "stats"
# TLS terminates at the load balancer when load balancing, so the
# dynamic domain set lives there rather than on the per-host proxies.
if KAMAL.config.proxy.load_balancing?
on(KAMAL.config.proxy.effective_loadbalancer) do |host|
puts_by_host host, capture_with_info(*KAMAL.loadbalancer.domains(subcommand)), type: "Loadbalancer"
end
else
on(KAMAL.proxy_hosts) do |host|
puts_by_host host, capture_with_info(*KAMAL.proxy(host).domains(subcommand)), type: "Proxy"
end
end
else
puts "Unknown domains subcommand: #{subcommand}. Available: refresh, list, stats"
end
end

desc "remove_container", "Remove proxy container from servers", hide: true
def remove_container
modify(lock: true) do
Expand Down
4 changes: 4 additions & 0 deletions lib/kamal/commands/loadbalancer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ def deploy(targets: [])
*loadbalancer_config.deploy_command_args(targets: targets)
end

def domains(subcommand)
docker :exec, container_name, "kamal-proxy", "domains", subcommand
end

def info
docker :ps, "--filter", "'name=^#{container_name}$'"
end
Expand Down
4 changes: 4 additions & 0 deletions lib/kamal/commands/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ def follow_logs(host:, timestamps: true, grep: nil, grep_options: nil)
).join(" "), host: host
end

def domains(subcommand)
docker :exec, container_name, "kamal-proxy", "domains", subcommand
end

def remove_container
docker :container, :prune, "--force", "--filter", "label=org.opencontainers.image.title=kamal-proxy"
end
Expand Down
24 changes: 24 additions & 0 deletions lib/kamal/configuration/docs/proxy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,30 @@ proxy:
# HTTPS traffic), you can disable this redirect by setting `ssl_redirect: false`:
ssl_redirect: false

# Dynamic TLS domains
#
# kamal-proxy can learn TLS hostnames from your application at runtime
# instead of fixing them at deploy time. It polls `source` — a path
# (resolved against a healthy app target) or an absolute http(s) URL —
# for the domain list and manages Let's Encrypt certificates for it
# automatically. When `source` is set, `ssl: true` is allowed without
# `host`/`hosts`.
#
# `interval` is the poll interval in seconds (proxy default: 300).
#
# `batch_size` controls how many domains share a certificate: 1 (the
# proxy default) issues per-domain certs; 2-25 enables stable SAN batching.
#
# Authentication tokens for the poll endpoint and the refresh nudge are
# read by the proxy from the `KAMAL_PROXY_DOMAINS_TOKEN` and
# `KAMAL_PROXY_REFRESH_TOKEN` environment variables. Set them on the proxy
# container via `proxy.run.options.env` — never as deploy flags, which
# leak into process listings and audit logs.
tls_domains:
source: /api/v1/kamal/domains
interval: 300
batch_size: 1

# Forward headers
#
# Whether to forward the `X-Forwarded-For` and `X-Forwarded-Proto` headers.
Expand Down
6 changes: 4 additions & 2 deletions lib/kamal/configuration/loadbalancer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ def initialize(config:, proxy_config:, secrets:)
def deploy_options
opts = super

# The parent strips host/tls when load balancing (the app-level proxy no
# longer owns them) — the load balancer is where they belong, so re-add.
# The parent strips host/tls/tls-domains when load balancing (the
# app-level proxy no longer owns TLS) — the load balancer is where they
# belong, so re-add.
opts[:host] = hosts if hosts.present?
opts[:tls] = true if ssl?
opts.merge!(tls_domains_options)

opts
end
Expand Down
15 changes: 14 additions & 1 deletion lib/kamal/configuration/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,12 @@ def deploy_options
"log-request-header": proxy_config.dig("logging", "request_headers") || DEFAULT_LOG_REQUEST_HEADERS,
"log-response-header": proxy_config.dig("logging", "response_headers"),
"error-pages": error_pages
}.compact
}.merge(tls_domains_options).compact

if load_balancing?
opts.delete(:host)
opts.delete(:tls)
tls_domains_options.each_key { |key| opts.delete(key) }
end

opts
Expand All @@ -142,6 +143,18 @@ def merge(other)
end

private
# Flags for kamal-proxy's dynamic domain source (runtime TLS hostnames).
# TLS terminates wherever these flags land, so like host/tls they are
# stripped from the per-app deploy when load balancing and re-added by
# Kamal::Configuration::Loadbalancer#deploy_options.
def tls_domains_options
{
"tls-domains-source": proxy_config.dig("tls_domains", "source"),
"tls-domains-interval": seconds_duration(proxy_config.dig("tls_domains", "interval")),
"tls-domains-batch-size": proxy_config.dig("tls_domains", "batch_size")
}.compact
end

def tls_path(directory, filename)
File.join([ directory, role_name, filename ].compact) if custom_ssl_certificate?
end
Expand Down
37 changes: 34 additions & 3 deletions lib/kamal/configuration/validator/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ def validate!
unless config.nil?
super

# Skip SSL host validation when a loadbalancer is present
# since SSL is disabled when using a loadbalancer
if config["host"].blank? && config["hosts"].blank? && config["ssl"] && config["loadbalancer"].blank?
# Skip SSL host validation when a loadbalancer is present (SSL is
# disabled when using a loadbalancer) or when a tls_domains source
# provides the hostnames at runtime.
if config["host"].blank? && config["hosts"].blank? && config["ssl"] && config["loadbalancer"].blank? && config.dig("tls_domains", "source").blank?
error "Must set a host to enable automatic SSL"
end

Expand All @@ -23,6 +24,12 @@ def validate!
end
end

# Truthiness, not present? — an empty hash must still fail the
# "Missing source" check rather than silently disable the feature.
if config["tls_domains"]
validate_tls_domains! config["tls_domains"]
end

if run_config = config["run"]
if run_config["bind_ips"].present?
ensure_valid_bind_ips(config["bind_ips"])
Expand All @@ -38,6 +45,30 @@ def validate!
end

private
def validate_tls_domains!(tls_domains)
with_context("tls_domains") do
source = tls_domains["source"]

if source.blank?
error "Missing source setting (required when tls_domains is set)"
elsif !valid_tls_domains_source?(source)
error "source must be a path starting with '/' or an http(s) URL"
end

if (interval = tls_domains["interval"]) && (!interval.is_a?(Integer) || interval < 1)
error "interval must be a positive integer"
end

if (batch_size = tls_domains["batch_size"]) && (!batch_size.is_a?(Integer) || !batch_size.between?(1, 25))
error "batch_size must be an integer between 1 and 25"
end
end
end

def valid_tls_domains_source?(source)
source.is_a?(String) && (source.start_with?("/") || source.match?(%r{\Ahttps?://\S+\z}i))
end

def ensure_valid_bind_ips(bind_ips)
bind_ips.present? && bind_ips.each do |ip|
next if ip =~ Resolv::IPv4::Regex || ip =~ Resolv::IPv6::Regex
Expand Down
58 changes: 58 additions & 0 deletions test/cli/proxy_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,64 @@ class CliProxyTest < CliTestCase
end
end

# Domains tests
test "domains refresh" do
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :exec, "kamal-proxy", "kamal-proxy", "domains", "refresh")
.returns("Refresh queued")
.at_least_once

run_command("domains", "refresh").tap do |output|
# Runs against every proxy host, not just the primary
assert_match "Proxy Host: 1.1.1.1", output
assert_match "Proxy Host: 1.1.1.2", output
assert_match "Refresh queued", output
end
end

test "domains stats" do
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :exec, "kamal-proxy", "kamal-proxy", "domains", "stats")
.returns("certs: 3 pending: 1")
.at_least_once

run_command("domains", "stats").tap do |output|
assert_match "certs: 3 pending: 1", output
end
end

test "domains list with loadbalancer" do
Kamal::Configuration::Proxy.any_instance.unstub(:load_balancing?)

SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :exec, "load-balancer", "kamal-proxy", "domains", "list")
.returns("customer1.example.com")

run_command("domains", "list", fixture: :with_loadbalancer).tap do |output|
assert_match "Loadbalancer Host: lb.example.com", output
assert_match "customer1.example.com", output
end
end

test "domains refresh with loadbalancer on proxy host uses proxy container name" do
Kamal::Configuration::Proxy.any_instance.unstub(:load_balancing?)

SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :exec, "kamal-proxy", "kamal-proxy", "domains", "refresh")
.returns("Refresh queued")

run_command("domains", "refresh", fixture: :with_loadbalancer_on_proxy_host).tap do |output|
assert_match "Loadbalancer Host: 1.1.1.1", output
assert_match "Refresh queued", output
end
end

test "domains with unknown subcommand" do
run_command("domains", "bogus").tap do |output|
assert_match "Unknown domains subcommand: bogus. Available: refresh, list, stats", output
end
end

# Loadbalancer tests
test "boot with loadbalancer" do
Kamal::Configuration::Proxy.any_instance.unstub(:load_balancing?)
Expand Down
14 changes: 14 additions & 0 deletions test/commands/loadbalancer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,20 @@ class CommandsLoadbalancerTest < ActiveSupport::TestCase
new_command.deploy(targets: [ "1.1.1.1" ]).join(" ")
end

test "deploy propagates tls_domains flags" do
@config[:proxy]["ssl"] = true
@config[:proxy]["tls_domains"] = { "source" => "/api/v1/kamal/domains", "interval" => 300, "batch_size" => 5 }
assert_equal \
"docker exec load-balancer kamal-proxy deploy app --target=\"1.1.1.1:80,1.1.1.2:80\" --deploy-timeout=\"30s\" --drain-timeout=\"30s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\" --host=\"app.example.com\" --tls --tls-domains-source=\"/api/v1/kamal/domains\" --tls-domains-interval=\"300s\" --tls-domains-batch-size=\"5\"",
new_command.deploy(targets: [ "1.1.1.1", "1.1.1.2" ]).join(" ")
end

test "domains" do
assert_equal \
"docker exec load-balancer kamal-proxy domains list",
new_command.domains("list").join(" ")
end

test "info" do
assert_equal \
"docker ps --filter 'name=^load-balancer$'",
Expand Down
6 changes: 6 additions & 0 deletions test/commands/proxy_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,12 @@ class CommandsProxyTest < ActiveSupport::TestCase
new_command.run.join(" ")
end

test "domains" do
assert_equal \
"docker exec kamal-proxy kamal-proxy domains refresh",
new_command.domains("refresh").join(" ")
end

test "debug run config" do
@config[:proxy] = { "run" => { "debug" => true } }
assert_equal \
Expand Down
85 changes: 85 additions & 0 deletions test/configuration/proxy_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,91 @@ class ConfigurationProxyTest < ActiveSupport::TestCase
end
end

test "tls_domains flags in deploy_options" do
@deploy[:proxy] = { "ssl" => true, "host" => "example.com", "tls_domains" => { "source" => "/api/v1/kamal/domains", "interval" => 300, "batch_size" => 5 } }
options = config.proxy.deploy_options
assert_equal "/api/v1/kamal/domains", options[:"tls-domains-source"]
assert_equal "300s", options[:"tls-domains-interval"]
assert_equal 5, options[:"tls-domains-batch-size"]
end

test "tls_domains flags absent when not configured" do
@deploy[:proxy] = { "ssl" => true, "host" => "example.com" }
options = config.proxy.deploy_options
assert_not options.key?(:"tls-domains-source")
assert_not options.key?(:"tls-domains-interval")
assert_not options.key?(:"tls-domains-batch-size")
end

test "tls_domains with source only" do
@deploy[:proxy] = { "ssl" => true, "host" => "example.com", "tls_domains" => { "source" => "https://app.example.com/domains" } }
options = config.proxy.deploy_options
assert_equal "https://app.example.com/domains", options[:"tls-domains-source"]
assert_not options.key?(:"tls-domains-interval")
assert_not options.key?(:"tls-domains-batch-size")
end

test "ssl with no host allowed when tls_domains source is set" do
@deploy[:proxy] = { "ssl" => true, "tls_domains" => { "source" => "/domains" } }
assert config.proxy.ssl?
end

test "deploy_options strips tls_domains flags when load balancing" do
@deploy[:proxy] = { "loadbalancer" => "lb.example.com", "ssl" => true, "hosts" => [ "app.example.com" ], "tls_domains" => { "source" => "/domains" } }
options = config.proxy.deploy_options
assert_not options.key?(:"tls-domains-source")
assert_not options.key?(:"tls-domains-interval")
assert_not options.key?(:"tls-domains-batch-size")
end

test "tls_domains without source" do
@deploy[:proxy] = { "ssl" => true, "host" => "example.com", "tls_domains" => { "interval" => 300 } }
assert_raises(Kamal::ConfigurationError) { config.proxy }
end

test "tls_domains empty hash" do
@deploy[:proxy] = { "ssl" => true, "host" => "example.com", "tls_domains" => {} }
assert_raises(Kamal::ConfigurationError) { config.proxy }
end

test "tls_domains source must be a path or http url" do
[ "example.com/domains", "ftp://example.com/domains", "domains" ].each do |source|
@deploy[:proxy] = { "ssl" => true, "host" => "example.com", "tls_domains" => { "source" => source } }
assert_raises(Kamal::ConfigurationError, "expected #{source.inspect} to be rejected") { config.proxy }
end
end

test "tls_domains source accepts http and https urls" do
[ "http://app.internal/domains", "https://app.example.com/api/v1/kamal/domains" ].each do |source|
@deploy[:proxy] = { "ssl" => true, "host" => "example.com", "tls_domains" => { "source" => source } }
assert_equal source, config.proxy.deploy_options[:"tls-domains-source"]
end
end

test "tls_domains interval must be a positive integer" do
[ 0, -300, "300", 1.5 ].each do |interval|
@deploy[:proxy] = { "ssl" => true, "host" => "example.com", "tls_domains" => { "source" => "/domains", "interval" => interval } }
assert_raises(Kamal::ConfigurationError, "expected interval #{interval.inspect} to be rejected") { config.proxy }
end
end

test "tls_domains batch_size must be between 1 and 25" do
[ 0, 26, "5", 1.5 ].each do |batch_size|
@deploy[:proxy] = { "ssl" => true, "host" => "example.com", "tls_domains" => { "source" => "/domains", "batch_size" => batch_size } }
assert_raises(Kamal::ConfigurationError, "expected batch_size #{batch_size} to be rejected") { config.proxy }
end

[ 1, 25 ].each do |batch_size|
@deploy[:proxy] = { "ssl" => true, "host" => "example.com", "tls_domains" => { "source" => "/domains", "batch_size" => batch_size } }
assert_equal batch_size, config.proxy.deploy_options[:"tls-domains-batch-size"]
end
end

test "tls_domains rejects unknown keys" do
@deploy[:proxy] = { "ssl" => true, "host" => "example.com", "tls_domains" => { "source" => "/domains", "sources" => "/other" } }
assert_raises(Kamal::ConfigurationError) { config.proxy }
end

test "ssl with private key and no certificate" do
with_test_secrets("secrets" => "KEY_PEM=private_key") do
@deploy[:proxy] = {
Expand Down