diff --git a/lib/kamal/configuration/docs/proxy.yml b/lib/kamal/configuration/docs/proxy.yml index 76ec3e41d..977e9014b 100644 --- a/lib/kamal/configuration/docs/proxy.yml +++ b/lib/kamal/configuration/docs/proxy.yml @@ -49,6 +49,17 @@ proxy: # Defaults to `false`: ssl: true + # Custom SSL certificate + # + # In scenarios where Let's Encrypt is not an option, or you already have your own + # certificates from a different Certificate Authority, you can configure kamal-proxy + # to load the certificate and the corresponding private key from disk. + # + # The certificate must be in PEM format and contain the full chain. The private key + # must also be in PEM format. + ssl_certificate_path: /data/cert/foo.example.com/fullchain.pem + ssl_private_key_path: /data/cert/foo.example.com/privkey.pem + # Response timeout # # How long to wait for requests to complete before timing out, defaults to 30 seconds: diff --git a/lib/kamal/configuration/proxy.rb b/lib/kamal/configuration/proxy.rb index 6232c3e03..5529ddf33 100644 --- a/lib/kamal/configuration/proxy.rb +++ b/lib/kamal/configuration/proxy.rb @@ -22,6 +22,10 @@ def ssl? proxy_config.fetch("ssl", false) end + def custom_ssl_certificate? + proxy_config["ssl_certificate_path"].present? + end + def hosts proxy_config["hosts"] || proxy_config["host"]&.split(",") || [] end @@ -30,6 +34,8 @@ def deploy_options { host: hosts, tls: proxy_config["ssl"].presence, + "tls-certificate-path": proxy_config["ssl_certificate_path"], + "tls-private-key-path": proxy_config["ssl_private_key_path"], "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/role.rb b/lib/kamal/configuration/role.rb index 708e77fc2..d5b923da2 100644 --- a/lib/kamal/configuration/role.rb +++ b/lib/kamal/configuration/role.rb @@ -150,8 +150,8 @@ def asset_volume_directory(version = config.version) end def ensure_one_host_for_ssl - if running_proxy? && proxy.ssl? && hosts.size > 1 - raise Kamal::ConfigurationError, "SSL is only supported on a single server, found #{hosts.size} servers for role #{name}" + if running_proxy? && proxy.ssl? && hosts.size > 1 && !proxy.custom_ssl_certificate? + raise Kamal::ConfigurationError, "SSL is only supported on a single server or with custom SSL certificates, found #{hosts.size} servers for role #{name}" end end diff --git a/lib/kamal/configuration/validator/proxy.rb b/lib/kamal/configuration/validator/proxy.rb index b9e11cd99..5eb91a7af 100644 --- a/lib/kamal/configuration/validator/proxy.rb +++ b/lib/kamal/configuration/validator/proxy.rb @@ -10,6 +10,14 @@ def validate! if (config.keys & [ "host", "hosts" ]).size > 1 error "Specify one of 'host' or 'hosts', not both" end + + if config["ssl_certificate_path"].present? && config["ssl_private_key_path"].blank? + error "Must set a private key path to use a custom SSL certificate" + end + + if config["ssl_private_key_path"].present? && config["ssl_certificate_path"].blank? + error "Must set a certificate path to use a custom SSL private key" + end end end end diff --git a/test/commands/app_test.rb b/test/commands/app_test.rb index 0e5cad796..58986309b 100644 --- a/test/commands/app_test.rb +++ b/test/commands/app_test.rb @@ -143,6 +143,14 @@ class CommandsAppTest < ActiveSupport::TestCase new_command.deploy(target: "172.1.0.2").join(" ") end + test "deploy with custom SSL certificate" do + @config[:proxy] = { "ssl" => true, "host" => "example.com", "ssl_certificate_path" => "/path/to/cert.pem", "ssl_private_key_path" => "/path/to/key.pem" } + + assert_equal \ + "docker exec kamal-proxy kamal-proxy deploy app-web --target=\"172.1.0.2:80\" --host=\"example.com\" --tls --tls-certificate-path=\"/path/to/cert.pem\" --tls-private-key-path=\"/path/to/key.pem\" --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\"", + new_command.deploy(target: "172.1.0.2").join(" ") + end + test "remove" do assert_equal \ "docker exec kamal-proxy kamal-proxy remove app-web", diff --git a/test/configuration/proxy_test.rb b/test/configuration/proxy_test.rb index 588e5a350..9ece1637c 100644 --- a/test/configuration/proxy_test.rb +++ b/test/configuration/proxy_test.rb @@ -38,6 +38,16 @@ class ConfigurationProxyTest < ActiveSupport::TestCase assert_not config.proxy.ssl? end + test "ssl with certificate path and no private key path" do + @deploy[:proxy] = { "ssl" => true, "ssl_certificate_path" => "/path/to/cert.pem" } + assert_raises(Kamal::ConfigurationError) { config.proxy.ssl? } + end + + test "ssl with private key path and no certificate path" do + @deploy[:proxy] = { "ssl" => true, "ssl_private_key_path" => "/path/to/key.pem" } + assert_raises(Kamal::ConfigurationError) { config.proxy.ssl? } + end + private def config Kamal::Configuration.new(@deploy) diff --git a/test/configuration_test.rb b/test/configuration_test.rb index c1aaa6971..236e49cef 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -371,7 +371,16 @@ class ConfigurationTest < ActiveSupport::TestCase Kamal::Configuration.new(@deploy_with_roles) end - assert_equal "SSL is only supported on a single server, found 2 servers for role workers", exception.message + assert_equal "SSL is only supported on a single server or with custom SSL certificates, found 2 servers for role workers", exception.message + end + + test "proxy ssl roles with multiple servers and a custom SSL certificate" do + @deploy_with_roles[:servers]["workers"]["proxy"] = { "ssl" => true, "host" => "foo.example.com", "ssl_certificate_path" => "/path/to/cert.pem", "ssl_private_key_path" => "/path/to/key.pem" } + + config = Kamal::Configuration.new(@deploy_with_roles) + + assert config.role(:workers).running_proxy? + assert config.role(:workers).ssl? end test "two proxy ssl roles with same host" do