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
This commit is contained in:
Donal McBreen
2025-06-16 17:01:27 +01:00
parent 8a7260d1e9
commit ccbcbbc8c5
16 changed files with 185 additions and 33 deletions

View File

@@ -1,6 +1,6 @@
class Kamal::Cli::App::SslCertificates class Kamal::Cli::App::SslCertificates
attr_reader :host, :role, :sshkit attr_reader :host, :role, :sshkit
delegate :execute, :info, to: :sshkit delegate :execute, :info, :upload!, to: :sshkit
def initialize(host, role, sshkit) def initialize(host, role, sshkit)
@host = host @host = host
@@ -13,10 +13,10 @@ class Kamal::Cli::App::SslCertificates
info "Writing SSL certificates for #{role.name} on #{host}" info "Writing SSL certificates for #{role.name} on #{host}"
execute *app.create_ssl_directory execute *app.create_ssl_directory
if cert_content = role.proxy.certificate_pem_content 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 end
if key_content = role.proxy.private_key_pem_content 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 end
end end

View File

@@ -22,15 +22,7 @@ module Kamal::Commands::App::Proxy
end end
def create_ssl_directory def create_ssl_directory
make_directory(config.proxy_boot.tls_directory) make_directory(File.join(config.proxy_boot.tls_directory, role.name))
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") ]
end end
private private

View File

@@ -6,12 +6,13 @@ class Kamal::Configuration::Proxy
delegate :argumentize, :optionize, to: Kamal::Utils 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 @config = config
@proxy_config = proxy_config @proxy_config = proxy_config
@proxy_config = {} if @proxy_config.nil? @proxy_config = {} if @proxy_config.nil?
@role_name = role_name
@secrets = secrets @secrets = secrets
validate! @proxy_config, with: Kamal::Configuration::Validator::Proxy, context: context validate! @proxy_config, with: Kamal::Configuration::Validator::Proxy, context: context
end end
@@ -46,24 +47,28 @@ class Kamal::Configuration::Proxy
secrets[ssl["private_key_pem"]] secrets[ssl["private_key_pem"]]
end end
def certificate_pem def host_tls_cert
tls_file_path("cert.pem") tls_path(config.proxy_boot.tls_directory, "cert.pem")
end end
def private_key_pem def host_tls_key
tls_file_path("key.pem") tls_path(config.proxy_boot.tls_directory, "key.pem")
end end
def tls_file_path(filename) def container_tls_cert
File.join(config.proxy_boot.tls_container_directory, filename) if custom_ssl_certificate? 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 end
def deploy_options def deploy_options
{ {
host: hosts, host: hosts,
tls: ssl? ? true : nil, tls: ssl? ? true : nil,
"tls-certificate-path": certificate_pem, "tls-certificate-path": container_tls_cert,
"tls-private-key-path": private_key_pem, "tls-private-key-path": container_tls_key,
"deploy-timeout": seconds_duration(config.deploy_timeout), "deploy-timeout": seconds_duration(config.deploy_timeout),
"drain-timeout": seconds_duration(config.drain_timeout), "drain-timeout": seconds_duration(config.drain_timeout),
"health-check-interval": seconds_duration(proxy_config.dig("healthcheck", "interval")), "health-check-interval": seconds_duration(proxy_config.dig("healthcheck", "interval")),
@@ -101,10 +106,14 @@ class Kamal::Configuration::Proxy
end end
def merge(other) 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 end
private private
def tls_path(directory, filename)
File.join([ directory, role_name, filename ].compact) if custom_ssl_certificate?
end
def seconds_duration(value) def seconds_duration(value)
value ? "#{value}s" : nil value ? "#{value}s" : nil
end end

View File

@@ -68,7 +68,7 @@ class Kamal::Configuration::Role
end end
def proxy def proxy
@proxy ||= config.proxy.merge(specialized_proxy) if running_proxy? @proxy ||= specialized_proxy.merge(config.proxy) if running_proxy?
end end
def running_proxy? def running_proxy?
@@ -174,6 +174,7 @@ class Kamal::Configuration::Role
config: config, config: config,
proxy_config: proxy_config, proxy_config: proxy_config,
secrets: config.secrets, secrets: config.secrets,
role_name: name,
context: "servers/#{name}/proxy" context: "servers/#{name}/proxy"
end end
end end

View File

@@ -229,9 +229,9 @@ class CliAppTest < CliTestCase
run_command("boot", config: :with_proxy).tap do |output| run_command("boot", config: :with_proxy).tap do |output|
assert_match "Writing SSL certificates for web on 1.1.1.1", 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 "mkdir -p .kamal/proxy/apps-config/app/tls", output
assert_match "sh -c [REDACTED]", 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/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/key.pem\"", output assert_match "--tls-private-key-path=\"/home/kamal-proxy/.apps-config/app/tls/web/key.pem\"", output
end end
end end

View File

@@ -56,8 +56,10 @@ class ConfigurationProxyTest < ActiveSupport::TestCase
} }
proxy = config.proxy proxy = config.proxy
assert_equal "/home/kamal-proxy/.apps-config/app/tls/cert.pem", proxy.certificate_pem assert_equal ".kamal/proxy/apps-config/app/tls/cert.pem", proxy.host_tls_cert
assert_equal "/home/kamal-proxy/.apps-config/app/tls/key.pem", proxy.private_key_pem 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
end end

View File

@@ -41,6 +41,8 @@ services:
context: docker/vm context: docker/vm
volumes: volumes:
- shared:/shared - shared:/shared
ports:
- "22443:443"
vm2: vm2:
privileged: true privileged: true
@@ -61,6 +63,7 @@ services:
context: docker/load_balancer context: docker/load_balancer
ports: ports:
- "12345:80" - "12345:80"
- "12443:443"
depends_on: depends_on:
- vm1 - vm1
- vm2 - vm2

View File

@@ -18,6 +18,7 @@ RUN apt-get update --fix-missing && apt-get install -y docker-ce docker-ce-cli c
COPY *.sh . COPY *.sh .
COPY app/ app/ COPY app/ app/
COPY app_with_custom_certificate/ app_with_custom_certificate/
COPY app_with_roles/ app_with_roles/ COPY app_with_roles/ app_with_roles/
COPY app_with_traefik/ app_with_traefik/ COPY app_with_traefik/ app_with_traefik/
COPY app_with_proxied_accessory/ app_with_proxied_accessory/ 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.email "deployer@example.com"
RUN git config --global user.name "Deployer" RUN git config --global user.name "Deployer"
RUN cd app && git init && git add . && git commit -am "Initial version" 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_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_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" RUN cd app_with_proxied_accessory && git init && git add . && git commit -am "Initial version"

View File

@@ -0,0 +1,2 @@
CUSTOM_CERT=$(cat certs/cert.pem)
CUSTOM_KEY=$(cat certs/key.pem)

View File

@@ -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

View File

@@ -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-----

View File

@@ -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-----

View File

@@ -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` %>

View File

@@ -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;
}
}

View File

@@ -63,8 +63,8 @@ class IntegrationTest < ActiveSupport::TestCase
assert_match message, response.body.strip if message assert_match message, response.body.strip if message
end end
def assert_app_is_up(version: nil, app: @app) def assert_app_is_up(version: nil, app: @app, cert: nil)
response = app_response(app: app) response = app_response(app: app, cert: cert)
debug_response_code(response, "200") debug_response_code(response, "200")
assert_equal "200", response.code assert_equal "200", response.code
assert_app_version(version, response) if version assert_app_version(version, response) if version
@@ -82,8 +82,14 @@ class IntegrationTest < ActiveSupport::TestCase
assert_equal up_times, up_count assert_equal up_times, up_count
end end
def app_response(app: @app) def app_response(app: @app, cert: nil)
Net::HTTP.get_response(URI.parse("http://#{app_host(app)}:12345/version")) 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 end
def update_app_rev def update_app_rev
@@ -186,4 +192,19 @@ class IntegrationTest < ActiveSupport::TestCase
"localhost" "localhost"
end end
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 end

View File

@@ -142,6 +142,16 @@ class MainTest < IntegrationTest
assert_app_is_up version: first_version assert_app_is_up version: first_version
end 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 private
def assert_envs(version:) def assert_envs(version:)
assert_env :KAMAL_HOST, "vm1", version: version, vm: :vm1 assert_env :KAMAL_HOST, "vm1", version: version, vm: :vm1