Skip to content

Commit

Permalink
Respond to /.well-known/acme-challenge requests using ACME_CHALLENGE_…
Browse files Browse the repository at this point in the history
…* envs (#44)
  • Loading branch information
SpComb authored and jakolehm committed Jan 11, 2018
1 parent 9bd0493 commit 8c38532
Show file tree
Hide file tree
Showing 14 changed files with 359 additions and 2 deletions.
3 changes: 3 additions & 0 deletions docker-compose.test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ lb:
KONTENA_LB_CUSTOM_SETTINGS: |
option dontlognull
ACME_CHALLENGE_LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0: |
LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0.9jg46WB3rR_AHD-EBXdN7cBkH1WOu0tA3M9fm21mqTI
SSL_CERT_test1: |
-----BEGIN CERTIFICATE-----
MIIC9TCCAd2gAwIBAgIJAK94fUzfHt1pMA0GCSqGSIb3DQEBCwUAMBExDzANBgNV
Expand Down
51 changes: 51 additions & 0 deletions lib/kontena/acme_challenges.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
module Kontena
class AcmeChallenges
include Kontena::Logging

def self.env
ENV
end

# @return [Boolean]
def self.configured?
env.any? { |env, value| env.start_with? 'ACME_CHALLENGE_' }
end

def self.load_env(env)
challenges = {}

env.each do |env, value|
_, prefix, suffix = env.partition(/^ACME_CHALLENGE_/)

if prefix
challenges[suffix] = value
end
end

challenges
end

# Setup from ENV
def self.boot(env = ENV)
manager = new(load_env(env))
end

attr_reader :challenges

# @param challenges [Hash{String => String}] ACME challenge token => keyAuthorization
def initialize(challenges)
@challenges = challenges
end

# @return [Boolean]
def challenges?
!challenges.empty?
end

# @param challenge [String] ACME challenge token from /.well-known/acme-challenge/...
# @return [String, nil] ACME challenge keyAuthorization
def respond(challenge)
@challenges[challenge]
end
end
end
65 changes: 65 additions & 0 deletions lib/kontena/actors/acme_challenge_server.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
module Kontena::Actors
class AcmeChallengeServer < Concurrent::Actor::RestartingContext
include Kontena::Logging

PORT = 54321

class Servlet < WEBrick::HTTPServlet::AbstractServlet
def initialize(server, acme_challenges)
super(server)
@acme_challenges = acme_challenges
end

def do_GET(req, res)
parts = req.path.split('/')

raise WEBrick::HTTPStatus::NotFound unless parts.length == 4

challenge = parts[-1]

if key_authorization = @acme_challenges.respond(challenge)
res.status = 200
res.content_type = 'text/plain'
res.body = key_authorization
else
res.status = 404
res.content_type = 'text/plain'
res.body = "No key authorization for challenge: #{challenge}\n"
end
end
end

def initialize(acme_challenges, port: PORT, webrick_options: {})
@acme_challenges = acme_challenges

info "initialize"

@server = WEBrick::HTTPServer.new(
BindAddress: '127.0.0.1',
Port: port,
**webrick_options
)
@server.mount '/.well-known/acme-challenge', Servlet, @acme_challenges
end

# @param [Symbol,Array] msg
def on_message(msg)
case msg
when :start
start
else
pass
end
end

def start
# this blocks the actor executor thread in the accept() loop
# the webrick servlets run as separate threads managed by webrick
@server.start
end

def default_executor
Concurrent.global_io_executor
end
end
end
8 changes: 8 additions & 0 deletions lib/kontena/actors/lb_supervisor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,17 @@ def etcd_path
ENV.fetch('ETCD_PATH')
end

def acme_challenges
@acme_challenges ||= Kontena::AcmeChallenges.boot
end

def start
@syslog_server = SyslogServer.spawn!(name: 'syslog_server', supervise: true)
@syslog_server << :start
@acme_challenge_server = AcmeChallengeServer.spawn!(name: 'acme_challenge_server', supervise: true, args: [
acme_challenges,
])
@acme_challenge_server << :start

@config_generator = HaproxyConfigGenerator.spawn!(name: 'haproxy_config_generator', supervise: true)
@config_writer = HaproxyConfigWriter.spawn!(name: 'haproxy_config_writer', supervise: true)
Expand Down
7 changes: 7 additions & 0 deletions lib/kontena/templates/haproxy/http_backends.text.erb
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
<% if acme_challenges? %>
backend acme_challenge
option forwardfor

server localhost 127.0.0.1:54321

<% end %>
<% services.each do |service| %>
backend <%= service.name %>
option forwardfor
Expand Down
5 changes: 5 additions & 0 deletions lib/kontena/templates/haproxy/http_in.text.erb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ listen http-in
errorfile 200 /etc/haproxy/errors/200.http
<% end %>

<% if acme_challenges? %>
acl acme_challenge path_beg /.well-known/acme-challenge/
use_backend acme_challenge if acme_challenge
<% end %>

<% services.each do |service| %>
<% service.virtual_hosts.each do |virtual_host| %>
<% if virtual_host.start_with?('*.') %>
Expand Down
4 changes: 4 additions & 0 deletions lib/kontena/views/http_backends.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ module Kontena::Views
class HttpBackends
include Hanami::View

def acme_challenges?
Kontena::AcmeChallenges.configured?
end

format :text
template 'haproxy/http_backends'
end
Expand Down
4 changes: 4 additions & 0 deletions lib/kontena/views/http_in.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ class HttpIn
format :text
template 'haproxy/http_in'

def acme_challenges?
Kontena::AcmeChallenges.configured?
end

def accept_proxy?
ENV['KONTENA_LB_ACCEPT_PROXY']
end
Expand Down
4 changes: 4 additions & 0 deletions lib/kontena_lb.rb
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
require 'concurrent/actor'
require 'concurrent/future'
require 'hanami/view'
require 'webrick'

require_relative 'kontena/logging'
require_relative 'kontena/models/service'
require_relative 'kontena/models/tcp_service'
require_relative 'kontena/models/upstream'

require_relative 'kontena/acme_challenges'
require_relative 'kontena/cert_splitter'
require_relative 'kontena/cert_manager'

require_relative 'kontena/views/haproxy'

require_relative 'kontena/actors/lb_supervisor'
require_relative 'kontena/actors/acme_challenge_server'
require_relative 'kontena/actors/etcd_watcher'
require_relative 'kontena/actors/haproxy_config_generator'
require_relative 'kontena/actors/haproxy_config_writer'
Expand Down
62 changes: 62 additions & 0 deletions spec/kontena/acme_challenges_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
describe Kontena::AcmeChallenges do
let(:challenge_token) { 'LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0' }
let(:key_authorization) { 'LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0.9jg46WB3rR_AHD-EBXdN7cBkH1WOu0tA3M9fm21mqTI' }

subject { described_class.new(
challenge_token => key_authorization,
) }

describe '#configured?' do
let(:env) { { } }

before do
allow(described_class).to receive(:env).and_return(env)
end

context 'with an empty env' do
it 'is false' do
expect(described_class.configured?).to be_falsey
end
end

context 'with non-challenge envs' do
let(:env) { { 'FOO' => 'bar' } }

it 'is false' do
expect(described_class.configured?).to be_falsey
end
end

context 'with challenge envs' do
let(:env) { { "ACME_CHALLENGE_#{challenge_token}" => key_authorization } }

it 'is true' do
expect(described_class.configured?).to be_truthy
end
end
end

describe '#boot' do
it 'loads nothing from env empty env' do
challenges = described_class.boot({})

expect(challenges.challenges?).to be_falsey
end

it 'loads challenge from ACME_CHALLENGE_*' do
challenges = described_class.boot({"ACME_CHALLENGE_#{challenge_token}" => key_authorization})

expect(challenges.challenges).to eq(challenge_token => key_authorization)
end
end

describe '#respond' do
it 'returns nil for a non-existant challenge' do
expect(subject.respond('foo')).to be_nil
end

it 'returns the key authorization for a known challenge' do
expect(subject.respond(challenge_token)).to eq key_authorization
end
end
end
73 changes: 73 additions & 0 deletions spec/kontena/actors/acme_challenge_server_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
describe Kontena::Actors::AcmeChallengeServer do
let(:acme_challenges) { instance_double(Kontena::AcmeChallenges) }

subject { described_class.new(acme_challenges,
webrick_options: { :DoNotListen => true },
) }
let(:server) { subject.instance_variable_get('@server') }

describe 'HTTP' do
let(:http_request) { instance_double(WEBrick::HTTPRequest,
request_method: http_method,
unparsed_uri: http_path,
path: WEBrick::HTTPUtils::normalize_path(http_path),
) }
let(:http_response) { WEBrick::HTTPResponse.new(server.config) }

before do
# XXX: should not be mocking the WEBrick::HTTPRequest
allow(http_request).to receive(:script_name=)
allow(http_request).to receive(:path_info=)
end

context 'GET /' do
let(:http_method) { 'GET' }
let(:http_path) { '/' }

it 'responds with HTTP 404' do
expect{server.service(http_request, http_response)}.to raise_error(WEBrick::HTTPStatus::NotFound)
end
end

context 'GET /.well-known/acme-challenge' do
let(:http_method) { 'GET' }
let(:http_path) { '/.well-known/acme-challenge' }

it 'responds with HTTP 404' do
expect{server.service(http_request, http_response)}.to raise_error(WEBrick::HTTPStatus::NotFound)
end
end

context 'GET /.well-known/acme-challenge/:token' do
let(:acme_token) { 'LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0' }
let(:acme_authorization) { 'LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0.9jg46WB3rR_AHD-EBXdN7cBkH1WOu0tA3M9fm21mqTI' }
let(:http_method) { 'GET' }
let(:http_path) { "/.well-known/acme-challenge/#{acme_token}" }

context 'with a matching ACME_CHALLENGE' do
before do
expect(acme_challenges).to receive(:respond).with(acme_token).and_return(acme_authorization)
end

it 'responds with HTTP 200' do
server.service(http_request, http_response)

expect(http_response.status).to eq 200
expect(http_response.body).to eq acme_authorization
end
end

context 'without any matching ACME_CHALLENGE' do
before do
expect(acme_challenges).to receive(:respond).with(acme_token).and_return(nil)
end

it 'responds with HTTP 440' do
server.service(http_request, http_response)

expect(http_response.status).to eq 404
end
end
end
end
end
30 changes: 29 additions & 1 deletion spec/kontena/views/http_backends_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -156,5 +156,33 @@
expect(output.match(/server foo-1 10.81.3.2:8080 check/)).to be_truthy
end
end

describe 'acme_challenges?' do
let(:output) { described_class.render(
format: :text,
services: [],
tcp_services: []
) }

context 'when not configured' do
before do
allow(Kontena::AcmeChallenges).to receive(:configured?).and_return(false)
end

it 'does not configure any ACL' do
expect(output).to_not match /backend acme_challenge/
end
end

context 'when configured' do
before do
allow(Kontena::AcmeChallenges).to receive(:configured?).and_return(true)
end

it 'configures the ACL' do
expect(output).to match /backend acme_challenge/
end
end
end
end
end
end
Loading

0 comments on commit 8c38532

Please sign in to comment.