add support for custom certificates

This commit is contained in:
acidtib
2025-04-26 01:03:15 -06:00
parent 52c6191803
commit 045410368d
15 changed files with 171 additions and 11 deletions

View File

@@ -12,6 +12,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
KAMAL.roles_on(host).each do |role| KAMAL.roles_on(host).each do |role|
Kamal::Cli::App::Assets.new(host, role, self).run Kamal::Cli::App::Assets.new(host, role, self).run
Kamal::Cli::App::SslCertificates.new(host, role, self).run
end end
end end

View File

@@ -0,0 +1,29 @@
class Kamal::Cli::App::SslCertificates
attr_reader :host, :role, :sshkit
delegate :execute, :info, to: :sshkit
def initialize(host, role, sshkit)
@host = host
@role = role
@sshkit = sshkit
end
def run
if role.running_proxy? && role.proxy.custom_ssl_certificate?
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)
end
if key_content = role.proxy.private_key_pem_content
execute *app.write_private_key_file(key_content)
end
execute *app.set_certificate_permissions
end
end
private
def app
@app ||= KAMAL.app(role: role, host: host)
end
end

View File

@@ -21,6 +21,22 @@ module Kamal::Commands::App::Proxy
remove_directory config.proxy_boot.app_directory remove_directory config.proxy_boot.app_directory
end 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") ]
end
def set_certificate_permissions
[ :docker, :exec, "--user", "root", proxy_container_name, "chown", "-R", "kamal-proxy:kamal-proxy", config.proxy_boot.tls_container_directory ]
end
private private
def proxy_exec(*command) def proxy_exec(*command)
docker :exec, proxy_container_name, "kamal-proxy", *command docker :exec, proxy_container_name, "kamal-proxy", *command

View File

@@ -63,7 +63,7 @@ class Kamal::Configuration
@env = Env.new(config: @raw_config.env || {}, secrets: secrets) @env = Env.new(config: @raw_config.env || {}, secrets: secrets)
@logging = Logging.new(logging_config: @raw_config.logging) @logging = Logging.new(logging_config: @raw_config.logging)
@proxy = Proxy.new(config: self, proxy_config: @raw_config.key?(:proxy) ? @raw_config.proxy : {}) @proxy = Proxy.new(config: self, proxy_config: @raw_config.key?(:proxy) ? @raw_config.proxy : {}, secrets: secrets)
@proxy_boot = Proxy::Boot.new(config: self) @proxy_boot = Proxy::Boot.new(config: self)
@ssh = Ssh.new(config: self) @ssh = Ssh.new(config: self)
@sshkit = Sshkit.new(config: self) @sshkit = Sshkit.new(config: self)

View File

@@ -125,7 +125,8 @@ class Kamal::Configuration::Accessory
Kamal::Configuration::Proxy.new \ Kamal::Configuration::Proxy.new \
config: config, config: config,
proxy_config: accessory_config["proxy"], proxy_config: accessory_config["proxy"],
context: "accessories/#{name}/proxy" context: "accessories/#{name}/proxy",
secrets: config.secrets
end end
def initialize_registry def initialize_registry

View File

@@ -16,7 +16,6 @@
# It is disabled by default on all other roles but can be enabled by setting # It is disabled by default on all other roles but can be enabled by setting
# `proxy: true` or providing a proxy configuration. # `proxy: true` or providing a proxy configuration.
proxy: proxy:
# Hosts # Hosts
# #
# The hosts that will be used to serve the app. The proxy will only route requests # The hosts that will be used to serve the app. The proxy will only route requests
@@ -52,6 +51,17 @@ proxy:
# Defaults to `false`: # Defaults to `false`:
ssl: true 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.
#
# A reference to a secret (in this case, `CERTIFICATE_PEM` and `PRIVATE_KEY_PEM`) will look up the secret
# in the local environment:
certificate_pem: CERTIFICATE_PEM
private_key_pem: PRIVATE_KEY_PEM
# SSL redirect # SSL redirect
# #
# By default, kamal-proxy will redirect all HTTP requests to HTTPS when SSL is enabled. # By default, kamal-proxy will redirect all HTTP requests to HTTPS when SSL is enabled.

View File

@@ -6,11 +6,12 @@ class Kamal::Configuration::Proxy
delegate :argumentize, :optionize, to: Kamal::Utils delegate :argumentize, :optionize, to: Kamal::Utils
attr_reader :config, :proxy_config attr_reader :config, :proxy_config, :secrets
def initialize(config:, proxy_config:, context: "proxy") def initialize(config:, proxy_config:, secrets:, context: "proxy")
@config = config @config = config
@proxy_config = proxy_config @proxy_config = proxy_config
@secrets = secrets
validate! @proxy_config, with: Kamal::Configuration::Validator::Proxy, context: context validate! @proxy_config, with: Kamal::Configuration::Validator::Proxy, context: context
end end
@@ -26,10 +27,36 @@ class Kamal::Configuration::Proxy
proxy_config["hosts"] || proxy_config["host"]&.split(",") || [] proxy_config["hosts"] || proxy_config["host"]&.split(",") || []
end end
def custom_ssl_certificate?
proxy_config["certificate_pem"].present? && proxy_config["private_key_pem"].present?
end
def certificate_pem_content
secrets[proxy_config["certificate_pem"]]
end
def private_key_pem_content
secrets[proxy_config["private_key_pem"]]
end
def certificate_pem
tls_file_path("cert.pem")
end
def private_key_pem
tls_file_path("key.pem")
end
def tls_file_path(filename)
File.join(config.proxy_boot.tls_container_directory, filename) if custom_ssl_certificate?
end
def deploy_options def deploy_options
{ {
host: hosts, host: hosts,
tls: proxy_config["ssl"].presence, tls: proxy_config["ssl"].presence,
"tls-certificate-path": certificate_pem,
"tls-private-key-path": private_key_pem,
"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")),
@@ -65,7 +92,7 @@ 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) self.class.new config: config, proxy_config: proxy_config.deep_merge(other.proxy_config), secrets: secrets
end end
private private

View File

@@ -96,6 +96,14 @@ class Kamal::Configuration::Proxy::Boot
File.join app_container_directory, "error_pages" File.join app_container_directory, "error_pages"
end end
def tls_directory
File.join app_directory, "tls"
end
def tls_container_directory
File.join app_container_directory, "tls"
end
private private
def ensure_valid_bind_ips(bind_ips) def ensure_valid_bind_ips(bind_ips)
bind_ips.present? && bind_ips.each do |ip| bind_ips.present? && bind_ips.each do |ip|

View File

@@ -150,8 +150,8 @@ class Kamal::Configuration::Role
end end
def ensure_one_host_for_ssl def ensure_one_host_for_ssl
if running_proxy? && proxy.ssl? && hosts.size > 1 if running_proxy? && proxy.ssl? && hosts.size > 1 && !proxy.custom_ssl_certificate?
raise Kamal::ConfigurationError, "SSL is only supported on a single server, found #{hosts.size} servers for role #{name}" raise Kamal::ConfigurationError, "SSL is only supported on a single server unless you provide custom certificates, found #{hosts.size} servers for role #{name}"
end end
end end
@@ -173,6 +173,7 @@ class Kamal::Configuration::Role
@specialized_proxy = Kamal::Configuration::Proxy.new \ @specialized_proxy = Kamal::Configuration::Proxy.new \
config: config, config: config,
proxy_config: proxy_config, proxy_config: proxy_config,
secrets: config.secrets,
context: "servers/#{name}/proxy" context: "servers/#{name}/proxy"
end end
end end

View File

@@ -10,6 +10,14 @@ class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator
if (config.keys & [ "host", "hosts" ]).size > 1 if (config.keys & [ "host", "hosts" ]).size > 1
error "Specify one of 'host' or 'hosts', not both" error "Specify one of 'host' or 'hosts', not both"
end end
if config["certificate_pem"].present? && config["private_key_pem"].blank?
error "Missing private_key_pem setting (required when certificate_pem is present)"
end
if config["private_key_pem"].present? && config["certificate_pem"].blank?
error "Missing certificate_pem setting (required when private_key_pem is present)"
end
end end
end end
end end

View File

@@ -220,6 +220,22 @@ class CliAppTest < CliTestCase
end end
end end
test "boot with custom ssl certificate" do
Kamal::Configuration::Proxy.any_instance.stubs(:custom_ssl_certificate?).returns(true)
Kamal::Configuration::Proxy.any_instance.stubs(:certificate_pem_content).returns("CERTIFICATE CONTENT")
Kamal::Configuration::Proxy.any_instance.stubs(:private_key_pem_content).returns("PRIVATE KEY CONTENT")
stub_running
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 "docker exec --user root kamal-proxy chown -R kamal-proxy:kamal-proxy", 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
end
end
test "start" do test "start" do
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("999") # old version SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("999") # old version

View File

@@ -143,14 +143,18 @@ class CommandsAppTest < ActiveSupport::TestCase
new_command.deploy(target: "172.1.0.2").join(" ") new_command.deploy(target: "172.1.0.2").join(" ")
end end
test "set certificate permissions" do
assert_equal \
"docker exec --user root kamal-proxy chown -R kamal-proxy:kamal-proxy /home/kamal-proxy/.apps-config/app/tls",
new_command.set_certificate_permissions.join(" ")
end
test "remove" do test "remove" do
assert_equal \ assert_equal \
"docker exec kamal-proxy kamal-proxy remove app-web", "docker exec kamal-proxy kamal-proxy remove app-web",
new_command.remove.join(" ") new_command.remove.join(" ")
end end
test "logs" do test "logs" do
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1", "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1",

View File

@@ -25,5 +25,7 @@ class ConfigurationProxyBootTest < ActiveSupport::TestCase
assert_equal "/home/kamal-proxy/.apps-config/app", @proxy_boot_config.app_container_directory assert_equal "/home/kamal-proxy/.apps-config/app", @proxy_boot_config.app_container_directory
assert_equal ".kamal/proxy/apps-config/app/error_pages", @proxy_boot_config.error_pages_directory assert_equal ".kamal/proxy/apps-config/app/error_pages", @proxy_boot_config.error_pages_directory
assert_equal "/home/kamal-proxy/.apps-config/app/error_pages", @proxy_boot_config.error_pages_container_directory assert_equal "/home/kamal-proxy/.apps-config/app/error_pages", @proxy_boot_config.error_pages_container_directory
assert_equal ".kamal/proxy/apps-config/app/tls", @proxy_boot_config.tls_directory
assert_equal "/home/kamal-proxy/.apps-config/app/tls", @proxy_boot_config.tls_container_directory
end end
end end

View File

@@ -45,6 +45,43 @@ class ConfigurationProxyTest < ActiveSupport::TestCase
end end
end end
test "ssl with certificate and private key from secrets" do
with_test_secrets("secrets" => "CERT_PEM=certificate\nKEY_PEM=private_key") do
@deploy[:proxy] = {
"ssl" => true,
"host" => "example.com",
"certificate_pem" => "CERT_PEM",
"private_key_pem" => "KEY_PEM"
}
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
end
end
test "ssl with certificate and no private key" do
with_test_secrets("secrets" => "CERT_PEM=certificate") do
@deploy[:proxy] = {
"ssl" => true,
"host" => "example.com",
"certificate_pem" => "CERT_PEM"
}
assert_raises(Kamal::ConfigurationError) { config.proxy.ssl? }
end
end
test "ssl with private key and no certificate" do
with_test_secrets("secrets" => "KEY_PEM=private_key") do
@deploy[:proxy] = {
"ssl" => true,
"host" => "example.com",
"private_key_pem" => "KEY_PEM"
}
assert_raises(Kamal::ConfigurationError) { config.proxy.ssl? }
end
end
private private
def config def config
Kamal::Configuration.new(@deploy) Kamal::Configuration.new(@deploy)

View File

@@ -386,7 +386,7 @@ class ConfigurationTest < ActiveSupport::TestCase
Kamal::Configuration.new(@deploy_with_roles) Kamal::Configuration.new(@deploy_with_roles)
end 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 unless you provide custom certificates, found 2 servers for role workers", exception.message
end end
test "two proxy ssl roles with same host" do test "two proxy ssl roles with same host" do