Merge pull request #1531 from acidtib/feat/custom-ssl
feat: Add support for custom certificates
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
28
lib/kamal/cli/app/ssl_certificates.rb
Normal file
28
lib/kamal/cli/app/ssl_certificates.rb
Normal file
@@ -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
|
||||||
@@ -21,6 +21,18 @@ 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
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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.proxy)
|
@proxy = Proxy.new(config: self, proxy_config: @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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
# run on the same proxy.
|
# run on the same proxy.
|
||||||
#
|
#
|
||||||
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
|
||||||
@@ -45,7 +44,21 @@ proxy:
|
|||||||
# unless you explicitly set `forward_headers: true`
|
# unless you explicitly set `forward_headers: true`
|
||||||
#
|
#
|
||||||
# Defaults to `false`:
|
# 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
|
# SSL redirect
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
@proxy_config = {} if @proxy_config.nil?
|
@proxy_config = {} if @proxy_config.nil?
|
||||||
|
@secrets = secrets
|
||||||
validate! @proxy_config, with: Kamal::Configuration::Validator::Proxy, context: context
|
validate! @proxy_config, with: Kamal::Configuration::Validator::Proxy, context: context
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -27,10 +28,42 @@ class Kamal::Configuration::Proxy
|
|||||||
proxy_config["hosts"] || proxy_config["host"]&.split(",") || []
|
proxy_config["hosts"] || proxy_config["host"]&.split(",") || []
|
||||||
end
|
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
|
def deploy_options
|
||||||
{
|
{
|
||||||
host: hosts,
|
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),
|
"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")),
|
||||||
@@ -68,7 +101,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
|
||||||
|
|||||||
@@ -100,6 +100,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|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ class Kamal::Configuration::Validator
|
|||||||
example_value = example[key]
|
example_value = example[key]
|
||||||
|
|
||||||
if example_value == "..."
|
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
|
validate_type! value, *(Array if key == :servers), Hash
|
||||||
end
|
end
|
||||||
elsif key == "hosts"
|
elsif key == "hosts"
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ 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["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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -220,6 +220,21 @@ 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 "--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
|
||||||
|
|
||||||
|
|||||||
@@ -149,8 +149,6 @@ class CommandsAppTest < ActiveSupport::TestCase
|
|||||||
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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -45,6 +45,64 @@ 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" => {
|
||||||
|
"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
|
private
|
||||||
def config
|
def config
|
||||||
Kamal::Configuration.new(@deploy)
|
Kamal::Configuration.new(@deploy)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user