Skip to content
Merged
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
17 changes: 17 additions & 0 deletions bin/sync-proxy-flags
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ def flags_for(invocation, subcommand)
help.scan(/^\s+(?:-\w, )?--([a-z0-9-]+)/).flatten.uniq.sort - BUILTIN_FLAGS
end

# The DNS providers kamal-proxy will accept for --acme-dns-provider, read off the
# parenthesised list in the flag's own usage text. The gem validates
# proxy/run/acme/dns_provider against this, and rejecting a provider the proxy
# supports blocks a deploy - so the list has to move with the image, not with
# whoever last remembered to edit a constant.
#
# Whitespace is squashed first because Cobra wraps long usage strings.
def dns_providers_for(invocation)
help = run!(*invocation, "run", "--help").gsub(/\s+/, " ")
advertised = help[/--acme-dns-provider\b[^(]*\(([^)]*)\)/, 1] or
abort "could not find the provider list in --acme-dns-provider's usage text"

advertised.split(",").map(&:strip).sort
end

version = MINIMUM_VERSION

invocation =
Expand All @@ -58,6 +73,7 @@ invocation =
manifest = {
"proxy_version" => version,
"generated_by" => "bin/sync-proxy-flags",
"acme_dns_providers" => dns_providers_for(invocation),
"flags" => SUBCOMMANDS.to_h { |subcommand| [ subcommand, flags_for(invocation, subcommand) ] }
}

Expand All @@ -70,4 +86,5 @@ File.write path, <<~HEADER + manifest.to_yaml.sub(/\A---\n/, "")
HEADER

SUBCOMMANDS.each { |sub| puts "#{sub}: #{manifest["flags"][sub].size} flags" }
puts "acme dns providers: #{manifest["acme_dns_providers"].size}"
puts "wrote #{path} for #{version}"
6 changes: 6 additions & 0 deletions lib/kamal/cli/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ def boot
if version && Kamal::Utils.older_version?(version, Kamal::Configuration::Proxy::Run::MINIMUM_VERSION)
raise "kamal-proxy version #{version} is too old, run `kamal proxy reboot` in order to update to at least #{Kamal::Configuration::Proxy::Run::MINIMUM_VERSION}"
end

if (run_config = proxy.proxy_run_config)&.acme&.credentials?
execute *proxy.ensure_proxy_directory
upload! run_config.secrets_io, run_config.secrets_path, mode: "0600"
end

execute *proxy.ensure_apps_config_directory
execute *proxy.start_or_run(digest: drift.expected_digest)
end
Expand Down
6 changes: 5 additions & 1 deletion lib/kamal/cli/proxy/reboot.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ class Kamal::Cli::Proxy::Reboot
READY_TIMEOUT = 30

attr_reader :host, :sshkit
delegate :execute, :capture_with_info, :info, to: :sshkit
delegate :execute, :capture_with_info, :info, :upload!, to: :sshkit

def initialize(host, sshkit)
@host = host
Expand Down Expand Up @@ -43,6 +43,10 @@ def replace_container
execute *proxy.ensure_proxy_directory
execute *proxy.ensure_apps_config_directory

if (run_config = proxy.proxy_run_config)&.acme&.credentials?
upload! run_config.secrets_io, run_config.secrets_path, mode: "0600"
end

execute *proxy.run(digest: drift.expected_digest)
end

Expand Down
10 changes: 7 additions & 3 deletions lib/kamal/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,13 @@ def validate_secrets!(include_accessories: false)
roles.each do |role|
role.secrets_io(role.hosts.first) if role.hosts.any?

if role.running_proxy? && role.proxy.custom_ssl_certificate?
role.proxy.certificate_pem_content
role.proxy.private_key_pem_content
if role.running_proxy?
role.proxy.run&.secrets_io

if role.proxy.custom_ssl_certificate?
role.proxy.certificate_pem_content
role.proxy.private_key_pem_content
end
end
end

Expand Down
38 changes: 38 additions & 0 deletions lib/kamal/configuration/docs/proxy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,44 @@ proxy:
# Defaults to the minimum version this gem
# requires - only pin it to roll forward early,
# never below the default
# ACME / Let's Encrypt
#
# Certificate issuance for the whole proxy, including DNS-01 challenges.
# These are proxy-wide, not per-app: every service on this proxy shares the
# ACME account and the DNS provider.
#
# `dns_provider` names the provider that answers DNS-01 challenges. Leave it
# at `auto` to let the proxy pick from the credentials it can see. An
# unsupported name is rejected here, at config time - kamal-proxy would only
# log a warning and then never issue a certificate.
#
# `prefer_wildcard` asks for a wildcard certificate when the DNS provider
# supports one, and `http_fallback` falls back to an HTTP-01 challenge when
# DNS-01 fails. Both default to true in the proxy.
#
# `directory` overrides the ACME directory URL - point it at Let's Encrypt's
# staging environment while you are working out a DNS setup, so you do not
# burn production rate limits on failed attempts.
#
# `credentials` names entries in `.kamal/secrets` to pass to the proxy
# container as environment variables - the API credentials your DNS provider
# needs (`CF_API_TOKEN`, `LOOPIA_API_USER`, ...). Name them exactly as the
# provider expects them, since the name is what reaches the container.
#
# They are written to a 0600 env file on the proxy host and passed with
# `--env-file`, never on the command line: a DNS API token can rewrite your
# zone, and `docker run --env` would leave it in process listings and audit
# logs. Adding or renaming one changes the proxy's config digest, so the next
# deploy reboots the proxy to pick it up; rotating a value does not, so run
# `kamal proxy reboot` yourself after a rotation.
acme:
email: admin@example.com
dns_provider: cloudflare
prefer_wildcard: true
http_fallback: false
directory: https://acme-staging-v02.api.letsencrypt.org/directory
credentials:
- CF_API_TOKEN
options: # Additional options to pass to `docker run`
label:
- custom.label=kamal-proxy
Expand Down
67 changes: 67 additions & 0 deletions lib/kamal/configuration/proxy/acme.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# ACME configuration for the kamal-proxy container, including the DNS-01
# challenge credentials.
#
# Everything here except the credentials becomes a `kamal-proxy run` flag, so it
# lands in the run command and therefore in Proxy::Run#config_digest — changing
# the block reboots the proxy on the next deploy.
#
# Credentials go the other way, through an env file uploaded at mode 0600. A DNS
# API token can rewrite your zone; `docker run --env TOKEN=...` would put it in
# the host's process listing and in kamal's own audit log.
class Kamal::Configuration::Proxy::Acme
# The canonical provider names kamal-proxy MINIMUM_VERSION advertises. Kept in
# step with the proxy by test/proxy_flag_coverage_test.rb, which compares this
# list against the manifest bin/sync-proxy-flags generates from the image.
DNS_PROVIDERS = %w[
auto cloudflare digitalocean gcloud godaddy hetzner namecheap route53 vultr
].freeze

# Short forms kamal-proxy's ParseProviderName accepts but does not advertise,
# so they cannot be generated from --help and are not drift-checked.
DNS_PROVIDER_ALIASES = %w[ cf do gcp google googledns gd hz nc aws r53 vr ].freeze

SUPPORTED_DNS_PROVIDERS = (DNS_PROVIDERS + DNS_PROVIDER_ALIASES).freeze

attr_reader :acme_config, :secrets

def initialize(acme_config:, secrets:)
@acme_config = acme_config || {}
@secrets = secrets
end

def configured?
acme_config.present?
end

def credential_names
Array(acme_config["credentials"])
end

def credentials?
credential_names.any?
end

def secrets_io
Kamal::EnvFile.new(credential_names.to_h { |name| [ name, secrets[name] ] }).to_io
end

# Rendered with `=` rather than a space, unlike the rest of the run command:
# Cobra only reads a boolean flag's value in --flag=false form, so
# `--acme-http-fallback false` would set the flag true and leave "false" behind
# as a stray argument. Both booleans default to true in the proxy, which makes
# false the value an operator actually writes.
def run_command_args
Kamal::Utils.optionize(run_command_options, with: "=")
end

# compact, not compact_blank: false is a meaningful value for both booleans.
def run_command_options
{
"acme-email": acme_config["email"],
"acme-dns-provider": acme_config["dns_provider"],
"acme-directory": acme_config["directory"],
"acme-prefer-wildcard": acme_config["prefer_wildcard"],
"acme-http-fallback": acme_config["http_fallback"]
}.compact
end
end
32 changes: 30 additions & 2 deletions lib/kamal/configuration/proxy/run.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,19 @@ def self.digest(*parts)

# Digest of the materialized run invocation, used to detect drift between
# the running proxy container and the current configuration.
#
# The ACME credential names ride along because --env-file names a path, not the
# variables inside it: swapping one credential for another would otherwise leave
# the digest unmoved and the old proxy running. Their values deliberately stay
# out — the digest is published as a docker label, and hashing secret material
# into a world-readable label buys an offline guessing target for nothing.
# Rotating a credential's value still needs an explicit `kamal proxy reboot`.
def config_digest
self.class.digest(image, run_command, *docker_options_args)
self.class.digest(image, run_command, *docker_options_args, *acme.credential_names)
end

def acme
@acme ||= Kamal::Configuration::Proxy::Acme.new(acme_config: run_config["acme"], secrets: config.secrets)
end

def debug?
Expand Down Expand Up @@ -94,7 +105,7 @@ def options_args
end

def run_command
[ "kamal-proxy", "run", *optionize(run_command_options) ].join(" ")
[ "kamal-proxy", "run", *optionize(run_command_options), *acme.run_command_args ].join(" ")
end

def metrics_port
Expand All @@ -115,10 +126,23 @@ def docker_options_args
*publish_args,
*logging_args,
*("--expose=#{metrics_port}" if metrics_port.present?),
*acme_secrets_args,
*options_args
].compact
end

# Where the ACME DNS credentials land on the proxy host. Under the proxy's own
# directory rather than the app's env directory, because the container is
# host-scoped and shared by every app on the host - and so `kamal proxy remove`
# takes the credentials with it.
def secrets_path
File.join host_directory, "acme.env"
end

def secrets_io
acme.secrets_io
end

def host_directory
File.join config.run_directory, "proxy"
end
Expand Down Expand Up @@ -159,6 +183,10 @@ def hash
end

private
def acme_secrets_args
argumentize "--env-file", secrets_path if acme.credentials?
end

def format_bind_ip(ip)
# Ensure IPv6 address inside square brackets - e.g. [::1]
if ip =~ Resolv::IPv6::Regex && ip !~ /\A\[.*\]\z/
Expand Down
25 changes: 25 additions & 0 deletions lib/kamal/configuration/validator/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ def validate!
error "Cannot set http_port, https_port or bind_ips when publish is false"
end
end

if run_config["acme"].is_a?(Hash)
validate_acme! run_config["acme"]
end
end
end
end
Expand Down Expand Up @@ -87,6 +91,27 @@ def validate_tls_domains!(tls_domains)
end
end

# kamal-proxy only logs a warning for a DNS provider it does not recognise
# and then carries on with no provider at all, so the symptom is a
# certificate that never issues rather than a failed boot. Catch it here,
# before any host is contacted.
def validate_acme!(acme)
with_context("run") do
with_context("acme") do
if acme["email"].blank?
error "Missing email setting (required when acme is set)"
end

provider = acme["dns_provider"]

if provider.present? && !Kamal::Configuration::Proxy::Acme::SUPPORTED_DNS_PROVIDERS.include?(provider.to_s.downcase)
error "unsupported dns_provider '#{provider}'. " \
"Supported providers: #{Kamal::Configuration::Proxy::Acme::DNS_PROVIDERS.join(", ")}"
end
end
end
end

def validate_basic_auth!(basic_auth)
with_context("basic_auth") do
if basic_auth["username"].blank?
Expand Down
42 changes: 42 additions & 0 deletions test/cli/proxy_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,37 @@ class CliProxyTest < CliTestCase
end
end

test "boot writes acme credentials to a 0600 env file instead of the command line" do
with_test_secrets("secrets" => "CF_API_TOKEN=zone-rewriting-token") do
uploads = capture_uploads

run_command("boot", fixture: :with_proxy_acme).tap do |output|
assert_match "mkdir -p .kamal/proxy on 1.1.1.1", output
assert_match "--env-file .kamal/proxy/acme.env", output
assert_match "--acme-email=\"admin@example.com\"", output
assert_match "--acme-dns-provider=\"cloudflare\"", output
assert_match "--acme-http-fallback=\"false\"", output

assert_no_match(/zone-rewriting-token/, output)
end

assert_equal [ [ "CF_API_TOKEN=zone-rewriting-token\n", ".kamal/proxy/acme.env", "0600" ] ], uploads
end
end

test "reboot re-uploads the acme credentials before replacing the container" do
with_test_secrets("secrets" => "CF_API_TOKEN=zone-rewriting-token") do
uploads = capture_uploads

run_command("reboot", "-y", fixture: :with_proxy_acme).tap do |output|
assert_match "--env-file .kamal/proxy/acme.env", output
assert_no_match(/zone-rewriting-token/, output)
end

assert_equal [ [ "CF_API_TOKEN=zone-rewriting-token\n", ".kamal/proxy/acme.env", "0600" ] ], uploads
end
end

test "boot with drifted proxy reboots automatically" do
Object.any_instance.stubs(:sleep)
stub_proxy_drift
Expand Down Expand Up @@ -814,6 +845,17 @@ class CliProxyTest < CliTestCase
end

private
# Records what upload! was handed rather than what the printer logged: the
# mode is the point, and the printer does not print it.
def capture_uploads
[].tap do |uploads|
SSHKit::Backend::Printer.any_instance.stubs(:upload!).with do |io, path, **options|
uploads << [ io.string, path, options[:mode] ]
true
end
end
end

def run_command(*command, fixture: :with_proxy)
stdouted { Kamal::Cli::Proxy.start([ *command, "-c", "test/fixtures/deploy_#{fixture}.yml" ]) }
end
Expand Down
Loading