From ccbcbbc8c5f5ad872d5b23637911be8d29438cb1 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 16 Jun 2025 17:01:27 +0100 Subject: [PATCH] Custom certs per role - Upload the cert with `sshkit.upload!` - Use the role name to create a directory for each role's certs - Add an integration test for the custom certs --- lib/kamal/cli/app/ssl_certificates.rb | 6 ++-- lib/kamal/commands/app/proxy.rb | 10 +----- lib/kamal/configuration/proxy.rb | 31 ++++++++++------ lib/kamal/configuration/role.rb | 3 +- test/cli/app_test.rb | 6 ++-- test/configuration/proxy_test.rb | 6 ++-- test/integration/docker-compose.yml | 3 ++ test/integration/docker/deployer/Dockerfile | 2 ++ .../.kamal/secrets | 2 ++ .../app_with_custom_certificate/Dockerfile | 10 ++++++ .../certs/cert.pem | 19 ++++++++++ .../app_with_custom_certificate/certs/key.pem | 28 +++++++++++++++ .../config/deploy.yml | 36 +++++++++++++++++++ .../app_with_custom_certificate/default.conf | 17 +++++++++ test/integration/integration_test.rb | 29 ++++++++++++--- test/integration/main_test.rb | 10 ++++++ 16 files changed, 185 insertions(+), 33 deletions(-) create mode 100644 test/integration/docker/deployer/app_with_custom_certificate/.kamal/secrets create mode 100644 test/integration/docker/deployer/app_with_custom_certificate/Dockerfile create mode 100644 test/integration/docker/deployer/app_with_custom_certificate/certs/cert.pem create mode 100644 test/integration/docker/deployer/app_with_custom_certificate/certs/key.pem create mode 100644 test/integration/docker/deployer/app_with_custom_certificate/config/deploy.yml create mode 100644 test/integration/docker/deployer/app_with_custom_certificate/default.conf diff --git a/lib/kamal/cli/app/ssl_certificates.rb b/lib/kamal/cli/app/ssl_certificates.rb index 22ac5095..a025d14a 100644 --- a/lib/kamal/cli/app/ssl_certificates.rb +++ b/lib/kamal/cli/app/ssl_certificates.rb @@ -1,6 +1,6 @@ class Kamal::Cli::App::SslCertificates attr_reader :host, :role, :sshkit - delegate :execute, :info, to: :sshkit + delegate :execute, :info, :upload!, to: :sshkit def initialize(host, role, sshkit) @host = host @@ -13,10 +13,10 @@ class Kamal::Cli::App::SslCertificates info "Writing SSL certificates for #{role.name} on #{host}" execute *app.create_ssl_directory if cert_content = role.proxy.certificate_pem_content - execute *app.write_certificate_file(cert_content) + upload!(StringIO.new(cert_content), role.proxy.host_tls_cert, mode: "0644") end if key_content = role.proxy.private_key_pem_content - execute *app.write_private_key_file(key_content) + upload!(StringIO.new(key_content), role.proxy.host_tls_key, mode: "0644") end end end diff --git a/lib/kamal/commands/app/proxy.rb b/lib/kamal/commands/app/proxy.rb index a23cc4ce..ea5da2c0 100644 --- a/lib/kamal/commands/app/proxy.rb +++ b/lib/kamal/commands/app/proxy.rb @@ -22,15 +22,7 @@ module Kamal::Commands::App::Proxy end def create_ssl_directory - make_directory(config.proxy_boot.tls_directory) - end - - def write_certificate_file(content) - [ :sh, "-c", Kamal::Utils.sensitive("cat > #{config.proxy_boot.tls_directory}/cert.pem << 'KAMAL_CERT_EOF'\n#{content}\nKAMAL_CERT_EOF", redaction: "cat > #{config.proxy_boot.tls_directory}/cert.pem << 'KAMAL_CERT_EOF'\n[CERTIFICATE CONTENT REDACTED]\nKAMAL_CERT_EOF") ] - end - - def write_private_key_file(content) - [ :sh, "-c", Kamal::Utils.sensitive("cat > #{config.proxy_boot.tls_directory}/key.pem << 'KAMAL_KEY_EOF'\n#{content}\nKAMAL_KEY_EOF", redaction: "cat > #{config.proxy_boot.tls_directory}/key.pem << 'KAMAL_KEY_EOF'\n[PRIVATE KEY CONTENT REDACTED]\nKAMAL_KEY_EOF") ] + make_directory(File.join(config.proxy_boot.tls_directory, role.name)) end private diff --git a/lib/kamal/configuration/proxy.rb b/lib/kamal/configuration/proxy.rb index c3d3cff4..d9bd3135 100644 --- a/lib/kamal/configuration/proxy.rb +++ b/lib/kamal/configuration/proxy.rb @@ -6,12 +6,13 @@ class Kamal::Configuration::Proxy delegate :argumentize, :optionize, to: Kamal::Utils - attr_reader :config, :proxy_config, :secrets + attr_reader :config, :proxy_config, :role_name, :secrets - def initialize(config:, proxy_config:, secrets:, context: "proxy") + def initialize(config:, proxy_config:, role_name: nil, secrets:, context: "proxy") @config = config @proxy_config = proxy_config @proxy_config = {} if @proxy_config.nil? + @role_name = role_name @secrets = secrets validate! @proxy_config, with: Kamal::Configuration::Validator::Proxy, context: context end @@ -46,24 +47,28 @@ class Kamal::Configuration::Proxy secrets[ssl["private_key_pem"]] end - def certificate_pem - tls_file_path("cert.pem") + def host_tls_cert + tls_path(config.proxy_boot.tls_directory, "cert.pem") end - def private_key_pem - tls_file_path("key.pem") + def host_tls_key + tls_path(config.proxy_boot.tls_directory, "key.pem") end - def tls_file_path(filename) - File.join(config.proxy_boot.tls_container_directory, filename) if custom_ssl_certificate? + def container_tls_cert + tls_path(config.proxy_boot.tls_container_directory, "cert.pem") + end + + def container_tls_key + tls_path(config.proxy_boot.tls_container_directory, "key.pem") if custom_ssl_certificate? end def deploy_options { host: hosts, tls: ssl? ? true : nil, - "tls-certificate-path": certificate_pem, - "tls-private-key-path": private_key_pem, + "tls-certificate-path": container_tls_cert, + "tls-private-key-path": container_tls_key, "deploy-timeout": seconds_duration(config.deploy_timeout), "drain-timeout": seconds_duration(config.drain_timeout), "health-check-interval": seconds_duration(proxy_config.dig("healthcheck", "interval")), @@ -101,10 +106,14 @@ class Kamal::Configuration::Proxy end def merge(other) - self.class.new config: config, proxy_config: proxy_config.deep_merge(other.proxy_config), secrets: secrets + self.class.new config: config, proxy_config: other.proxy_config.deep_merge(proxy_config), role_name: role_name, secrets: secrets end private + def tls_path(directory, filename) + File.join([ directory, role_name, filename ].compact) if custom_ssl_certificate? + end + def seconds_duration(value) value ? "#{value}s" : nil end diff --git a/lib/kamal/configuration/role.rb b/lib/kamal/configuration/role.rb index 4fcc9913..28bb3bd0 100644 --- a/lib/kamal/configuration/role.rb +++ b/lib/kamal/configuration/role.rb @@ -68,7 +68,7 @@ class Kamal::Configuration::Role end def proxy - @proxy ||= config.proxy.merge(specialized_proxy) if running_proxy? + @proxy ||= specialized_proxy.merge(config.proxy) if running_proxy? end def running_proxy? @@ -174,6 +174,7 @@ class Kamal::Configuration::Role config: config, proxy_config: proxy_config, secrets: config.secrets, + role_name: name, context: "servers/#{name}/proxy" end end diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index 5464e918..535943c6 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -229,9 +229,9 @@ class CliAppTest < CliTestCase run_command("boot", config: :with_proxy).tap do |output| assert_match "Writing SSL certificates for web on 1.1.1.1", output assert_match "mkdir -p .kamal/proxy/apps-config/app/tls", output - assert_match "sh -c [REDACTED]", output - assert_match "--tls-certificate-path=\"/home/kamal-proxy/.apps-config/app/tls/cert.pem\"", output - assert_match "--tls-private-key-path=\"/home/kamal-proxy/.apps-config/app/tls/key.pem\"", output + assert_match "Uploading \"CERTIFICATE CONTENT\" to .kamal/proxy/apps-config/app/tls/web/cert.pem", output + assert_match "--tls-certificate-path=\"/home/kamal-proxy/.apps-config/app/tls/web/cert.pem\"", output + assert_match "--tls-private-key-path=\"/home/kamal-proxy/.apps-config/app/tls/web/key.pem\"", output end end diff --git a/test/configuration/proxy_test.rb b/test/configuration/proxy_test.rb index 143fffc1..e0b328f3 100644 --- a/test/configuration/proxy_test.rb +++ b/test/configuration/proxy_test.rb @@ -56,8 +56,10 @@ class ConfigurationProxyTest < ActiveSupport::TestCase } proxy = config.proxy - assert_equal "/home/kamal-proxy/.apps-config/app/tls/cert.pem", proxy.certificate_pem - assert_equal "/home/kamal-proxy/.apps-config/app/tls/key.pem", proxy.private_key_pem + assert_equal ".kamal/proxy/apps-config/app/tls/cert.pem", proxy.host_tls_cert + assert_equal ".kamal/proxy/apps-config/app/tls/key.pem", proxy.host_tls_key + assert_equal "/home/kamal-proxy/.apps-config/app/tls/cert.pem", proxy.container_tls_cert + assert_equal "/home/kamal-proxy/.apps-config/app/tls/key.pem", proxy.container_tls_key end end diff --git a/test/integration/docker-compose.yml b/test/integration/docker-compose.yml index 6df0935d..b99ab7d4 100644 --- a/test/integration/docker-compose.yml +++ b/test/integration/docker-compose.yml @@ -41,6 +41,8 @@ services: context: docker/vm volumes: - shared:/shared + ports: + - "22443:443" vm2: privileged: true @@ -61,6 +63,7 @@ services: context: docker/load_balancer ports: - "12345:80" + - "12443:443" depends_on: - vm1 - vm2 diff --git a/test/integration/docker/deployer/Dockerfile b/test/integration/docker/deployer/Dockerfile index c25747a2..dbac734c 100644 --- a/test/integration/docker/deployer/Dockerfile +++ b/test/integration/docker/deployer/Dockerfile @@ -18,6 +18,7 @@ RUN apt-get update --fix-missing && apt-get install -y docker-ce docker-ce-cli c COPY *.sh . COPY app/ app/ +COPY app_with_custom_certificate/ app_with_custom_certificate/ COPY app_with_roles/ app_with_roles/ COPY app_with_traefik/ app_with_traefik/ COPY app_with_proxied_accessory/ app_with_proxied_accessory/ @@ -29,6 +30,7 @@ RUN mkdir -p /etc/docker/certs.d/registry:4443 && ln -s /shared/certs/domain.crt RUN git config --global user.email "deployer@example.com" RUN git config --global user.name "Deployer" RUN cd app && git init && git add . && git commit -am "Initial version" +RUN cd app_with_custom_certificate && git init && git add . && git commit -am "Initial version" RUN cd app_with_roles && git init && git add . && git commit -am "Initial version" RUN cd app_with_traefik && git init && git add . && git commit -am "Initial version" RUN cd app_with_proxied_accessory && git init && git add . && git commit -am "Initial version" diff --git a/test/integration/docker/deployer/app_with_custom_certificate/.kamal/secrets b/test/integration/docker/deployer/app_with_custom_certificate/.kamal/secrets new file mode 100644 index 00000000..e50b2f3c --- /dev/null +++ b/test/integration/docker/deployer/app_with_custom_certificate/.kamal/secrets @@ -0,0 +1,2 @@ +CUSTOM_CERT=$(cat certs/cert.pem) +CUSTOM_KEY=$(cat certs/key.pem) diff --git a/test/integration/docker/deployer/app_with_custom_certificate/Dockerfile b/test/integration/docker/deployer/app_with_custom_certificate/Dockerfile new file mode 100644 index 00000000..8aed0e03 --- /dev/null +++ b/test/integration/docker/deployer/app_with_custom_certificate/Dockerfile @@ -0,0 +1,10 @@ +FROM registry:4443/nginx:1-alpine-slim + +COPY default.conf /etc/nginx/conf.d/default.conf + +ARG COMMIT_SHA + +RUN echo $COMMIT_SHA > /usr/share/nginx/html/version +RUN mkdir -p /usr/share/nginx/html/versions && echo "version" > /usr/share/nginx/html/versions/$COMMIT_SHA +RUN mkdir -p /usr/share/nginx/html/versions && echo "hidden" > /usr/share/nginx/html/versions/.hidden +RUN echo "Up!" > /usr/share/nginx/html/up diff --git a/test/integration/docker/deployer/app_with_custom_certificate/certs/cert.pem b/test/integration/docker/deployer/app_with_custom_certificate/certs/cert.pem new file mode 100644 index 00000000..a04163df --- /dev/null +++ b/test/integration/docker/deployer/app_with_custom_certificate/certs/cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDCzCCAfOgAwIBAgIUJHOADjhddzCAdXFfZvhXAsVMwhowDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI1MDYxNzA5MDYxOVoYDzIxMjUw +NTI0MDkwNjE5WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDQaLWwoLZ3/cZdiW/m4pqOe228wCx/CRU9/E2AT9NS +ofuJNtUaxw7QAAFEWIrnf9y3M09lZeox1CNmXe2GADnnx/n906zSGX18SdDmWrxa +L/1t5OZiXl3we5PM3UNvbFPSq1MCnOtvo6jTPM7shIpJ/5/KuuqovyrO31VCnc2+ +ycEzJ2BOcKFUFAeyT/8bk9lAI+1971PLqC6ut9dfy8PVHSPyGrxGiQCpStU7NiQj +LUkqte7x9GcIKTJUjMkWIsvGke9oGoGgEl5gEfqxFAs3ZkA1aYkiHhwFtrUkGOOf +O1C6sqfwnnAhtG8LnULGlFYi3GoKALF2XSIagGpaQM5HAgMBAAGjUzBRMB0GA1Ud +DgQWBBQg2m871YSI220bQEG5APeGzeaz4zAfBgNVHSMEGDAWgBQg2m871YSI220b +QEG5APeGzeaz4zAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBc +yQvjLV+Uym+SI/bmKNKafW7ioWSkWAfTl/bvCB8xCX2OJsSqh1vjiKhkcJ6t0Tcj +cEiYs7Q+2NVC+s+0ztrN1y4Ve8iX9K9D6o/09bD23zTKpftxCMv8NqoBicNVJ7O9 +sINcTqzrIPb+jawE47ogNvlorsU1hi1GTmDHtIqVJPQwiNCIWd8frBLf+WfCHCCK +xRJb4hh5wR05v94L0/QdfKQ8qqCRG0VLyoGGcUyQgC8PLLlHRIWIYuwo3xhUK9nN +Gn8WNiACY4ry1wRauqIp54N3fM1a5sgzpgPKc8++KLVBpxhDy8nRoFAD0k6y1iM0 +2EoVLhbMvwhYwHOHkktp +-----END CERTIFICATE----- diff --git a/test/integration/docker/deployer/app_with_custom_certificate/certs/key.pem b/test/integration/docker/deployer/app_with_custom_certificate/certs/key.pem new file mode 100644 index 00000000..a30ca3ac --- /dev/null +++ b/test/integration/docker/deployer/app_with_custom_certificate/certs/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDQaLWwoLZ3/cZd +iW/m4pqOe228wCx/CRU9/E2AT9NSofuJNtUaxw7QAAFEWIrnf9y3M09lZeox1CNm +Xe2GADnnx/n906zSGX18SdDmWrxaL/1t5OZiXl3we5PM3UNvbFPSq1MCnOtvo6jT +PM7shIpJ/5/KuuqovyrO31VCnc2+ycEzJ2BOcKFUFAeyT/8bk9lAI+1971PLqC6u +t9dfy8PVHSPyGrxGiQCpStU7NiQjLUkqte7x9GcIKTJUjMkWIsvGke9oGoGgEl5g +EfqxFAs3ZkA1aYkiHhwFtrUkGOOfO1C6sqfwnnAhtG8LnULGlFYi3GoKALF2XSIa +gGpaQM5HAgMBAAECggEAM2dIPRb+uozU8vg1qhCFR5RpBi+uKe0vGJlU8kt+F3kN +hhQIrvCfFi2SIm3mYOAYK/WTZTKkd4LX8mVDcxQ2NBWOcw1VKIMSAOhiBpclsub4 +TrUxH90ftXN9in+epOpmqGUKdfAHYANRXjy22v5773GF06aTv2hbYigSqvoqJ57A +PCdpw9q9sTwJqR9reU3f9fHsUyIwLCQpbtFyQc8aU9LHqgs4SAkaogY+4mPmlCrl +pQ5wGljTXmK5g1o/v+mu1WdeGNOzd5//xp0YImkGtyiqh8Ab891MI1wPgivNP5Lo +Ru1wKhegj89XamT/LUCtn6NCcokE/9pqEXrKK7JeVQKBgQD98kGUkdAm+zHjRZsr +KTeQQ/wszFrNcbP9irE5MqnASWskIXcAhGVJrqbtinLPLIeT22BTsJkCUjVJdfX2 +MObjiJP0LMrMVpGQC0b+i4boS8W/lY5T4fM97B+ILc3Y1OYiUedg0gVsFspSR4ef +luNfbKbmdzYYqFz6a/q5vExqBQKBgQDSGC2MJXYAewRJ9Mk3fNvll/6yz73rGCct +tljwNXUgC7y2nEabDverPd74olSxojQwus/kA8JrMVa2IkXo+lKAwLV+nyj3PGHw +3szTeAVWrGIRveWuW6IQ5zOP2IGkX5Jm+XSPVihnMz7SZA6k6qCtWVVywfBubSpi +1dMNWAhs2wKBgBvMVw1yYLzDppRgXDn/SwvJxWMKA66VkcRhWEEQoLBh2Q6dcy9l +TskgCznZe/PdxgGTdBn1LOqqIRcniIMomz2xB7Ek7hYsK8b+1QisMVpgYQc10dyw +0TWoEVOQ4AWqWH7NRGy+0MUiQYd8OQZpN/6MIED+L7fHRlZLV6jZSewZAoGBAJwo +bHJmxbbFuQJfd9BOdgPJXf76emdrpHNNvf2NPml7T+FLdw95qI0Xh8u2nM0Li09N +C4inYrLaEWF/SAdLSFd65WwgUQqzTvkCIaxs4UrzBlG5nCZk5ak6sBCTFIlgoCj5 +8bE4kP9kD6XByUC7RIKUi/aoQFVTvtWHqT+Z12lRAoGAAVoZVxE+xPAfzVyAatpH +M8WwgB23r07thNDiJCUMOQUT8LRFKg/Hyj6jB2W7gj669G/Bvoar++nXJVw7QCiv +MlOk1pfaKuW82rCPnTeUzJwf2KQ8Jg2avasD4GFWZBJVvlHN1ONySViIpb67hhAK +1OcbfGutFiGWhUwXNVkVc4U= +-----END PRIVATE KEY----- diff --git a/test/integration/docker/deployer/app_with_custom_certificate/config/deploy.yml b/test/integration/docker/deployer/app_with_custom_certificate/config/deploy.yml new file mode 100644 index 00000000..a2c34b74 --- /dev/null +++ b/test/integration/docker/deployer/app_with_custom_certificate/config/deploy.yml @@ -0,0 +1,36 @@ +service: app_with_custom_certificate +image: app_with_custom_certificate +servers: + web: + hosts: + - vm1 + - vm2 + workers: + hosts: + - vm3 + cmd: sleep infinity +deploy_timeout: 2 +drain_timeout: 2 +readiness_delay: 0 + +proxy: + host: localhost + ssl: + certificate_pem: CUSTOM_CERT + private_key_pem: CUSTOM_KEY + healthcheck: + interval: 1 + timeout: 1 + path: "/up" + +asset_path: /usr/share/nginx/html/versions + +registry: + server: registry:4443 + username: root + password: root +builder: + driver: docker + arch: <%= Kamal::Utils.docker_arch %> + args: + COMMIT_SHA: <%= `git rev-parse HEAD` %> diff --git a/test/integration/docker/deployer/app_with_custom_certificate/default.conf b/test/integration/docker/deployer/app_with_custom_certificate/default.conf new file mode 100644 index 00000000..e37a9bc1 --- /dev/null +++ b/test/integration/docker/deployer/app_with_custom_certificate/default.conf @@ -0,0 +1,17 @@ +server { + listen 80; + listen [::]:80; + server_name localhost; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + } + + # redirect server error pages to the static page /50x.html + # + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} diff --git a/test/integration/integration_test.rb b/test/integration/integration_test.rb index ef92a278..c6ec741f 100644 --- a/test/integration/integration_test.rb +++ b/test/integration/integration_test.rb @@ -63,8 +63,8 @@ class IntegrationTest < ActiveSupport::TestCase assert_match message, response.body.strip if message end - def assert_app_is_up(version: nil, app: @app) - response = app_response(app: app) + def assert_app_is_up(version: nil, app: @app, cert: nil) + response = app_response(app: app, cert: cert) debug_response_code(response, "200") assert_equal "200", response.code assert_app_version(version, response) if version @@ -82,8 +82,14 @@ class IntegrationTest < ActiveSupport::TestCase assert_equal up_times, up_count end - def app_response(app: @app) - Net::HTTP.get_response(URI.parse("http://#{app_host(app)}:12345/version")) + def app_response(app: @app, cert: nil) + uri = cert ? URI.parse("https://#{app_host(app)}:22443/version") : URI.parse("http://#{app_host(app)}:12345/version") + + if cert + https_response_with_cert(uri, cert) + else + Net::HTTP.get_response(uri) + end end def update_app_rev @@ -186,4 +192,19 @@ class IntegrationTest < ActiveSupport::TestCase "localhost" end end + + def https_response_with_cert(uri, cert) + host = uri.host + port = uri.port + + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + + store = OpenSSL::X509::Store.new + store.add_cert(OpenSSL::X509::Certificate.new(File.read(cert))) + http.cert_store = store + + request = Net::HTTP::Get.new(uri) + http.request(request) + end end diff --git a/test/integration/main_test.rb b/test/integration/main_test.rb index dbba4e25..e1b6e7d7 100644 --- a/test/integration/main_test.rb +++ b/test/integration/main_test.rb @@ -142,6 +142,16 @@ class MainTest < IntegrationTest assert_app_is_up version: first_version end + test "deploy with a custom certificate" do + @app = "app_with_custom_certificate" + + first_version = latest_app_version + + kamal :setup + + assert_app_is_up version: first_version, cert: "test/integration/docker/deployer/app_with_custom_certificate/certs/cert.pem" + end + private def assert_envs(version:) assert_env :KAMAL_HOST, "vm1", version: version, vm: :vm1