diff --git a/lib/kamal/configuration/docs/proxy.yml b/lib/kamal/configuration/docs/proxy.yml index 87e428e3c..c5d03aa17 100644 --- a/lib/kamal/configuration/docs/proxy.yml +++ b/lib/kamal/configuration/docs/proxy.yml @@ -47,6 +47,18 @@ proxy: # Defaults to `false`: ssl: true + # On-demand TLS + # + # For apps serving customer-supplied domains, which aren't known at deploy time. + # kamal-proxy asks the given endpoint whether a host may have a certificate issued, + # sending the hostname as a `host` query parameter and provisioning only on a 200 + # response. The value is a path served by the app, or an absolute http(s) URL. + # + # Requires `ssl: true`, and cannot be combined with `host`/`hosts` or a custom SSL + # certificate. The app becomes the wildcard target, so it must approve its own + # domain too. + tls_on_demand_url: /up/tls_on_demand + # Custom SSL certificate # # In some cases, using Let's Encrypt for automatic certificate management is not an diff --git a/lib/kamal/configuration/proxy.rb b/lib/kamal/configuration/proxy.rb index 3f2d83fad..433c1c254 100644 --- a/lib/kamal/configuration/proxy.rb +++ b/lib/kamal/configuration/proxy.rb @@ -29,6 +29,10 @@ def hosts proxy_config["hosts"] || proxy_config["host"]&.split(",") || [] end + def tls_on_demand_url + proxy_config["tls_on_demand_url"] + end + def custom_ssl_certificate? ssl = proxy_config["ssl"] return false unless ssl.is_a?(Hash) @@ -73,6 +77,7 @@ def deploy_options tls: ssl? ? true : nil, "tls-certificate-path": container_tls_cert, "tls-private-key-path": container_tls_key, + "tls-on-demand-url": tls_on_demand_url, "deploy-timeout": seconds_duration(config.deploy_timeout), "drain-timeout": seconds_duration(config.drain_timeout), "health-check-interval": seconds_duration(proxy_config.dig("healthcheck", "interval")), diff --git a/lib/kamal/configuration/validator/proxy.rb b/lib/kamal/configuration/validator/proxy.rb index 5dc3fd46b..ede48c2b1 100644 --- a/lib/kamal/configuration/validator/proxy.rb +++ b/lib/kamal/configuration/validator/proxy.rb @@ -3,7 +3,7 @@ def validate! unless config.nil? super - if config["host"].blank? && config["hosts"].blank? && config["ssl"] + if config["host"].blank? && config["hosts"].blank? && config["ssl"] && config["tls_on_demand_url"].blank? error "Must set a host to enable automatic SSL" end @@ -11,6 +11,22 @@ def validate! error "Specify one of 'host' or 'hosts', not both" end + if config.key?("tls_on_demand_url") + unless config["ssl"] + error "Must enable ssl to use tls_on_demand_url" + end + + if config["host"].present? || config["hosts"].present? + error "Cannot set a host when using tls_on_demand_url" + end + + if config["ssl"].is_a?(Hash) + error "Cannot use a custom SSL certificate with tls_on_demand_url" + end + + ensure_valid_tls_on_demand_url config["tls_on_demand_url"] + end + if config["ssl"].is_a?(Hash) if config["ssl"]["certificate_pem"].present? && config["ssl"]["private_key_pem"].blank? error "Missing private_key_pem setting (required when certificate_pem is present)" @@ -36,6 +52,20 @@ def validate! end private + # Mirrors kamal-proxy's own parsing: a path, or an absolute http(s) URL with a host. + def ensure_valid_tls_on_demand_url(url) + if url.is_a?(String) && url.present? + return if url.start_with?("/") && !url.start_with?("//") + + uri = URI.parse(url) + return if uri.scheme.in?([ "http", "https" ]) && uri.host.present? + end + + error "tls_on_demand_url must be a path or an absolute http(s) URL" + rescue URI::InvalidURIError + error "tls_on_demand_url must be a path or an absolute http(s) URL" + 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 diff --git a/test/configuration/proxy_test.rb b/test/configuration/proxy_test.rb index e0b328f3b..c3537cb91 100644 --- a/test/configuration/proxy_test.rb +++ b/test/configuration/proxy_test.rb @@ -38,6 +38,60 @@ class ConfigurationProxyTest < ActiveSupport::TestCase assert_not config.proxy.ssl? end + test "tls_on_demand_url with no host" do + @deploy[:proxy] = { "ssl" => true, "tls_on_demand_url" => "/up/tls_on_demand" } + assert_equal "/up/tls_on_demand", config.proxy.tls_on_demand_url + assert_includes config.proxy.deploy_command_args(target: "172.1.0.2"), "--tls-on-demand-url=\"/up/tls_on_demand\"" + end + + test "tls_on_demand_url with an absolute url" do + @deploy[:proxy] = { "ssl" => true, "tls_on_demand_url" => "https://example.com/allowed" } + assert_equal "https://example.com/allowed", config.proxy.tls_on_demand_url + end + + test "tls_on_demand_url omitted when not set" do + @deploy[:proxy] = { "ssl" => true, "host" => "example.com" } + assert_nil config.proxy.tls_on_demand_url + assert_not config.proxy.deploy_command_args(target: "172.1.0.2").any? { |arg| arg.start_with?("--tls-on-demand-url") } + end + + test "tls_on_demand_url requires ssl" do + @deploy[:proxy] = { "tls_on_demand_url" => "/up/tls_on_demand" } + assert_raises(Kamal::ConfigurationError) { config.proxy.ssl? } + end + + test "tls_on_demand_url cannot be combined with a host" do + @deploy[:proxy] = { "ssl" => true, "host" => "example.com", "tls_on_demand_url" => "/up/tls_on_demand" } + assert_raises(Kamal::ConfigurationError) { config.proxy.ssl? } + end + + test "tls_on_demand_url cannot be combined with a custom certificate" do + @deploy[:proxy] = { + "ssl" => { "certificate_pem" => "CERTIFICATE_PEM", "private_key_pem" => "PRIVATE_KEY_PEM" }, + "tls_on_demand_url" => "/up/tls_on_demand" + } + assert_raises(Kamal::ConfigurationError) { config.proxy.ssl? } + end + + test "tls_on_demand_url accepts paths and absolute http(s) urls" do + [ "/up/tls_on_demand", "/", "https://example.com/allowed", "HTTP://example.com", "http://example.com:8080/a" ].each do |url| + @deploy[:proxy] = { "ssl" => true, "tls_on_demand_url" => url } + assert_equal url, config.proxy.tls_on_demand_url, "#{url} should be allowed" + end + end + + test "tls_on_demand_url rejects anything else" do + [ "//example.com/allowed", "https://", "ftp://example.com", "example.com", "", 123, true ].each do |url| + @deploy[:proxy] = { "ssl" => true, "tls_on_demand_url" => url } + assert_raises(Kamal::ConfigurationError, "#{url.inspect} should be rejected") { config.proxy.ssl? } + end + end + + test "a blank tls_on_demand_url is rejected rather than passed to the proxy" do + @deploy[:proxy] = { "ssl" => true, "host" => "example.com", "tls_on_demand_url" => "" } + assert_raises(Kamal::ConfigurationError) { config.proxy.deploy_command_args(target: "172.1.0.2") } + end + test "false not allowed" do @deploy[:proxy] = false assert_raises(Kamal::ConfigurationError, "proxy: should be a hash") do