Skip to content

feat: Add support for custom certificates #1531

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/kamal/cli/app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ def boot

KAMAL.roles_on(host).each do |role|
Kamal::Cli::App::Assets.new(host, role, self).run
Kamal::Cli::App::SslCertificates.new(host, role, self).run
end
end

Expand Down
28 changes: 28 additions & 0 deletions lib/kamal/cli/app/ssl_certificates.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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
end
end

private
def app
@app ||= KAMAL.app(role: role, host: host)
end
end
12 changes: 12 additions & 0 deletions lib/kamal/commands/app/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ def remove_proxy_app_directory
remove_directory config.proxy_boot.app_directory
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

private
def proxy_exec(*command)
docker :exec, proxy_container_name, "kamal-proxy", *command
Expand Down
2 changes: 1 addition & 1 deletion lib/kamal/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def initialize(raw_config, destination: nil, version: nil, validate: true)
@env = Env.new(config: @raw_config.env || {}, secrets: secrets)

@logging = Logging.new(logging_config: @raw_config.logging)
@proxy = Proxy.new(config: self, proxy_config: @raw_config.proxy)
@proxy = Proxy.new(config: self, proxy_config: @raw_config.proxy, secrets: secrets)
@proxy_boot = Proxy::Boot.new(config: self)
@ssh = Ssh.new(config: self)
@sshkit = Sshkit.new(config: self)
Expand Down
3 changes: 2 additions & 1 deletion lib/kamal/configuration/accessory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ def initialize_proxy
Kamal::Configuration::Proxy.new \
config: config,
proxy_config: accessory_config["proxy"],
context: "accessories/#{name}/proxy"
context: "accessories/#{name}/proxy",
secrets: config.secrets
end

def initialize_registry
Expand Down
17 changes: 15 additions & 2 deletions lib/kamal/configuration/docs/proxy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
# run on the same proxy.
#
proxy:

# Hosts
#
# The hosts that will be used to serve the app. The proxy will only route requests
Expand Down Expand Up @@ -45,7 +44,21 @@ proxy:
# unless you explicitly set `forward_headers: true`
#
# Defaults to `false`:
ssl: true
ssl: ...

# Custom SSL certificate
#
# In some cases, using Let's Encrypt for automatic certificate management is not an
# option, or you may already have SSL certificates issued by a different
# Certificate Authority (CA). Kamal supports loading custom SSL certificates
# directly from secrets.
#
# Examples:
# ssl: true # Enable SSL with Let's Encrypt
# ssl: false # Disable SSL
# ssl: # Enable custom SSL
# certificate_pem: CERTIFICATE_PEM
# private_key_pem: PRIVATE_KEY_PEM

# SSL redirect
#
Expand Down
41 changes: 37 additions & 4 deletions lib/kamal/configuration/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ class Kamal::Configuration::Proxy

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
@proxy_config = proxy_config
@proxy_config = {} if @proxy_config.nil?
@secrets = secrets
validate! @proxy_config, with: Kamal::Configuration::Validator::Proxy, context: context
end

Expand All @@ -27,10 +28,42 @@ def hosts
proxy_config["hosts"] || proxy_config["host"]&.split(",") || []
end

def custom_ssl_certificate?
ssl = proxy_config["ssl"]
return false unless ssl.is_a?(Hash)
ssl["certificate_pem"].present? && ssl["private_key_pem"].present?
end

def certificate_pem_content
ssl = proxy_config["ssl"]
return nil unless ssl.is_a?(Hash)
secrets[ssl["certificate_pem"]]
end

def private_key_pem_content
ssl = proxy_config["ssl"]
return nil unless ssl.is_a?(Hash)
secrets[ssl["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
{
host: hosts,
tls: proxy_config["ssl"].presence,
tls: ssl? ? true : nil,
"tls-certificate-path": certificate_pem,
"tls-private-key-path": private_key_pem,
"deploy-timeout": seconds_duration(config.deploy_timeout),
"drain-timeout": seconds_duration(config.drain_timeout),
"health-check-interval": seconds_duration(proxy_config.dig("healthcheck", "interval")),
Expand Down Expand Up @@ -66,7 +99,7 @@ def stop_command_args(**options)
end

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

private
Expand Down
8 changes: 8 additions & 0 deletions lib/kamal/configuration/proxy/boot.rb
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ def error_pages_container_directory
File.join app_container_directory, "error_pages"
end

def tls_directory
File.join app_directory, "tls"
end

def tls_container_directory
File.join app_container_directory, "tls"
end

private
def ensure_valid_bind_ips(bind_ips)
bind_ips.present? && bind_ips.each do |ip|
Expand Down
5 changes: 3 additions & 2 deletions lib/kamal/configuration/role.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 unless you provide custom certificates, found #{hosts.size} servers for role #{name}"
end
end

Expand All @@ -173,6 +173,7 @@ def initialize_specialized_proxy
@specialized_proxy = Kamal::Configuration::Proxy.new \
config: config,
proxy_config: proxy_config,
secrets: config.secrets,
context: "servers/#{name}/proxy"
end
end
Expand Down
4 changes: 3 additions & 1 deletion lib/kamal/configuration/validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ def validate_against_example!(validation_config, example)
example_value = example[key]

if example_value == "..."
unless key.to_s == "proxy" && boolean?(value.class)
if key.to_s == "ssl"
validate_type! value, TrueClass, FalseClass, Hash
elsif key.to_s != "proxy" || !boolean?(value.class)
validate_type! value, *(Array if key == :servers), Hash
end
elsif key == "hosts"
Expand Down
10 changes: 10 additions & 0 deletions lib/kamal/configuration/validator/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ def validate!
if (config.keys & [ "host", "hosts" ]).size > 1
error "Specify one of 'host' or 'hosts', not both"
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)"
end

if config["ssl"]["private_key_pem"].present? && config["ssl"]["certificate_pem"].blank?
error "Missing certificate_pem setting (required when private_key_pem is present)"
end
end
end
end
end
15 changes: 15 additions & 0 deletions test/cli/app_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,21 @@ class CliAppTest < CliTestCase
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 "--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
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("999") # old version

Expand Down
2 changes: 0 additions & 2 deletions test/commands/app_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,6 @@ class CommandsAppTest < ActiveSupport::TestCase
new_command.remove.join(" ")
end



test "logs" do
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",
Expand Down
2 changes: 2 additions & 0 deletions test/configuration/proxy/boot_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,7 @@ class ConfigurationProxyBootTest < ActiveSupport::TestCase
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 "/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
58 changes: 58 additions & 0 deletions test/configuration/proxy_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,64 @@ class ConfigurationProxyTest < ActiveSupport::TestCase
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" => {
"certificate_pem" => "CERT_PEM",
"private_key_pem" => "KEY_PEM"
},
"host" => "example.com"
}

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 "deploy options with custom ssl certificates" do
with_test_secrets("secrets" => "CERT_PEM=certificate\nKEY_PEM=private_key") do
@deploy[:proxy] = {
"ssl" => {
"certificate_pem" => "CERT_PEM",
"private_key_pem" => "KEY_PEM"
},
"host" => "example.com"
}

proxy = config.proxy
options = proxy.deploy_options
assert_equal true, options[:tls]
assert_equal "/home/kamal-proxy/.apps-config/app/tls/cert.pem", options[:"tls-certificate-path"]
assert_equal "/home/kamal-proxy/.apps-config/app/tls/key.pem", options[:"tls-private-key-path"]
end
end

test "ssl with certificate and no private key" do
with_test_secrets("secrets" => "CERT_PEM=certificate") do
@deploy[:proxy] = {
"ssl" => {
"certificate_pem" => "CERT_PEM"
},
"host" => "example.com"
}
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" => {
"private_key_pem" => "KEY_PEM"
},
"host" => "example.com"
}
assert_raises(Kamal::ConfigurationError) { config.proxy.ssl? }
end
end

private
def config
Kamal::Configuration.new(@deploy)
Expand Down
2 changes: 1 addition & 1 deletion test/configuration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ 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 unless you provide custom certificates, found 2 servers for role workers", exception.message
end

test "two proxy ssl roles with same host" do
Expand Down
Loading