Commit 3c33724e authored by Vladimir Shushlin's avatar Vladimir Shushlin Committed by Nick Thomas

Add Let's Encrypt client

Part of adding Let's Encrypt certificates for pages domains

Add acme-client gem

Client is being initialized by private key stored in secrets.yml
Let's Encrypt account is being created lazily.
If it's already created, Acme::Client just gets account_kid by
calling new_account method

Make Let's Encrypt client an instance
Wrap order and challenge classes
parent c841c877
...@@ -60,6 +60,8 @@ gem 'u2f', '~> 0.2.1' ...@@ -60,6 +60,8 @@ gem 'u2f', '~> 0.2.1'
# GitLab Pages # GitLab Pages
gem 'validates_hostname', '~> 1.0.6' gem 'validates_hostname', '~> 1.0.6'
gem 'rubyzip', '~> 1.2.2', require: 'zip' gem 'rubyzip', '~> 1.2.2', require: 'zip'
# GitLab Pages letsencrypt support
gem 'acme-client', '~> 2.0.2'
# Browser detection # Browser detection
gem 'browser', '~> 2.5' gem 'browser', '~> 2.5'
......
...@@ -4,6 +4,8 @@ GEM ...@@ -4,6 +4,8 @@ GEM
RedCloth (4.3.2) RedCloth (4.3.2)
abstract_type (0.0.7) abstract_type (0.0.7)
ace-rails-ap (4.1.2) ace-rails-ap (4.1.2)
acme-client (2.0.2)
faraday (~> 0.9, >= 0.9.1)
actioncable (5.1.7) actioncable (5.1.7)
actionpack (= 5.1.7) actionpack (= 5.1.7)
nio4r (~> 2.0) nio4r (~> 2.0)
...@@ -998,6 +1000,7 @@ PLATFORMS ...@@ -998,6 +1000,7 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
RedCloth (~> 4.3.2) RedCloth (~> 4.3.2)
ace-rails-ap (~> 4.1.0) ace-rails-ap (~> 4.1.0)
acme-client (~> 2.0.2)
activerecord_sane_schema_dumper (= 1.0) activerecord_sane_schema_dumper (= 1.0)
acts-as-taggable-on (~> 6.0) acts-as-taggable-on (~> 6.0)
addressable (~> 2.5.2) addressable (~> 2.5.2)
......
...@@ -89,6 +89,13 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController ...@@ -89,6 +89,13 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
) )
end end
# Getting ToS url requires `directory` api call to Let's Encrypt
# which could result in 500 error/slow rendering on settings page
# Because of that we use separate controller action
def lets_encrypt_terms_of_service
redirect_to ::Gitlab::LetsEncrypt.terms_of_service_url
end
private private
def set_application_setting def set_application_setting
......
...@@ -30,8 +30,7 @@ ...@@ -30,8 +30,7 @@
.form-check .form-check
= f.check_box :lets_encrypt_terms_of_service_accepted, class: 'form-check-input' = f.check_box :lets_encrypt_terms_of_service_accepted, class: 'form-check-input'
= f.label :lets_encrypt_terms_of_service_accepted, class: 'form-check-label' do = f.label :lets_encrypt_terms_of_service_accepted, class: 'form-check-label' do
// Terms of Service should actually be a link, but the best way to get the url is using API - terms_of_service_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: lets_encrypt_terms_of_service_admin_application_settings_path }
// So it will be done in later MR = _("I have read and agree to the Let's Encrypt %{link_start}Terms of Service%{link_end}").html_safe % { link_start: terms_of_service_link_start, link_end: '</a>'.html_safe }
= _("I have read and agree to the Let's Encrypt Terms of Service")
= f.submit _('Save changes'), class: "btn btn-success" = f.submit _('Save changes'), class: "btn btn-success"
...@@ -111,6 +111,7 @@ namespace :admin do ...@@ -111,6 +111,7 @@ namespace :admin do
put :reset_health_check_token put :reset_health_check_token
put :clear_repository_check_states put :clear_repository_check_states
get :integrations, :repository, :templates, :ci_cd, :reporting, :metrics_and_profiling, :network, :geo, :preferences get :integrations, :repository, :templates, :ci_cd, :reporting, :metrics_and_profiling, :network, :geo, :preferences
get :lets_encrypt_terms_of_service
end end
resources :labels resources :labels
......
# frozen_string_literal: true
module Gitlab
module LetsEncrypt
class Challenge
def initialize(acme_challenge)
@acme_challenge = acme_challenge
end
delegate :url, :token, :file_content, :status, :request_validation, to: :acme_challenge
private
attr_reader :acme_challenge
end
end
end
# frozen_string_literal: true
module Gitlab
module LetsEncrypt
class Client
PRODUCTION_DIRECTORY_URL = 'https://acme-v02.api.letsencrypt.org/directory'
STAGING_DIRECTORY_URL = 'https://acme-staging-v02.api.letsencrypt.org/directory'
def new_order(domain_name)
ensure_account
acme_order = acme_client.new_order(identifiers: [domain_name])
::Gitlab::LetsEncrypt::Order.new(acme_order)
end
def load_order(url)
ensure_account
# rubocop: disable CodeReuse/ActiveRecord
::Gitlab::LetsEncrypt::Order.new(acme_client.order(url: url))
# rubocop: enable CodeReuse/ActiveRecord
end
def load_challenge(url)
ensure_account
::Gitlab::LetsEncrypt::Challenge.new(acme_client.challenge(url: url))
end
def terms_of_service_url
acme_client.terms_of_service
end
def enabled?
return false unless Feature.enabled?(:pages_auto_ssl)
Gitlab::CurrentSettings.lets_encrypt_terms_of_service_accepted
end
private
def acme_client
@acme_client ||= ::Acme::Client.new(private_key: private_key, directory: acme_api_directory_url)
end
def private_key
@private_key ||= OpenSSL::PKey.read(Gitlab::Application.secrets.lets_encrypt_private_key)
end
def admin_email
Gitlab::CurrentSettings.lets_encrypt_notification_email
end
def contact
"mailto:#{admin_email}"
end
def ensure_account
raise 'Acme integration is disabled' unless enabled?
@acme_account ||= acme_client.new_account(contact: contact, terms_of_service_agreed: true)
end
def acme_api_directory_url
if Rails.env.production?
PRODUCTION_DIRECTORY_URL
else
STAGING_DIRECTORY_URL
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module LetsEncrypt
class Order
def initialize(acme_order)
@acme_order = acme_order
end
def new_challenge
authorization = @acme_order.authorizations.first
challenge = authorization.http
::Gitlab::LetsEncrypt::Challenge.new(challenge)
end
delegate :url, :status, to: :acme_order
private
attr_reader :acme_order
end
end
end
...@@ -4905,7 +4905,7 @@ msgstr "" ...@@ -4905,7 +4905,7 @@ msgstr ""
msgid "I accept the|Terms of Service and Privacy Policy" msgid "I accept the|Terms of Service and Privacy Policy"
msgstr "" msgstr ""
msgid "I have read and agree to the Let's Encrypt Terms of Service" msgid "I have read and agree to the Let's Encrypt %{link_start}Terms of Service%{link_end}"
msgstr "" msgstr ""
msgid "ID" msgid "ID"
......
# frozen_string_literal: true
require 'spec_helper'
describe ::Gitlab::LetsEncrypt::Challenge do
delegated_methods = {
url: 'https://example.com/',
status: 'pending',
token: 'tokenvalue',
file_content: 'hereisfilecontent',
request_validation: true
}
let(:acme_challenge) do
acme_challenge = instance_double('Acme::Client::Resources::Challenge')
allow(acme_challenge).to receive_messages(delegated_methods)
acme_challenge
end
let(:challenge) { described_class.new(acme_challenge) }
delegated_methods.each do |method, value|
describe "##{method}" do
it 'delegates to Acme::Client::Resources::Challenge' do
expect(challenge.public_send(method)).to eq(value)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe ::Gitlab::LetsEncrypt::Client do
include LetsEncryptHelpers
let(:client) { described_class.new }
before do
stub_application_setting(
lets_encrypt_notification_email: 'myemail@test.example.com',
lets_encrypt_terms_of_service_accepted: true
)
end
let!(:stub_client) { stub_lets_encrypt_client }
shared_examples 'ensures account registration' do
it 'ensures account registration' do
subject
expect(stub_client).to have_received(:new_account).with(
contact: 'mailto:myemail@test.example.com',
terms_of_service_agreed: true
)
end
context 'when acme integration is disabled' do
before do
stub_application_setting(lets_encrypt_terms_of_service_accepted: false)
end
it 'raises error' do
expect do
subject
end.to raise_error('Acme integration is disabled')
end
end
end
describe '#new_order' do
subject(:new_order) { client.new_order('example.com') }
before do
order_double = instance_double('Acme::Order')
allow(stub_client).to receive(:new_order).and_return(order_double)
end
include_examples 'ensures account registration'
it 'returns order' do
is_expected.to be_a(::Gitlab::LetsEncrypt::Order)
end
end
describe '#load_order' do
let(:url) { 'https://example.com/order' }
subject { client.load_order(url) }
before do
acme_order = instance_double('Acme::Client::Resources::Order')
allow(stub_client).to receive(:order).with(url: url).and_return(acme_order)
end
include_examples 'ensures account registration'
it 'loads order' do
is_expected.to be_a(::Gitlab::LetsEncrypt::Order)
end
end
describe '#load_challenge' do
let(:url) { 'https://example.com/challenge' }
subject { client.load_challenge(url) }
before do
acme_challenge = instance_double('Acme::Client::Resources::Challenge')
allow(stub_client).to receive(:challenge).with(url: url).and_return(acme_challenge)
end
include_examples 'ensures account registration'
it 'loads challenge' do
is_expected.to be_a(::Gitlab::LetsEncrypt::Challenge)
end
end
describe '#enabled?' do
subject { client.enabled? }
context 'when terms of service are accepted' do
it { is_expected.to eq(true) }
context 'when feature flag is disabled' do
before do
stub_feature_flags(pages_auto_ssl: false)
end
it { is_expected.to eq(false) }
end
end
context 'when terms of service are not accepted' do
before do
stub_application_setting(lets_encrypt_terms_of_service_accepted: false)
end
it { is_expected.to eq(false) }
end
end
describe '#terms_of_service_url' do
subject { client.terms_of_service_url }
it 'returns valid url' do
is_expected.to eq("https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf")
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe ::Gitlab::LetsEncrypt::Order do
delegated_methods = {
url: 'https://example.com/',
status: 'valid'
}
let(:acme_order) do
acme_order = instance_double('Acme::Client::Resources::Order')
allow(acme_order).to receive_messages(delegated_methods)
acme_order
end
let(:order) { described_class.new(acme_order) }
delegated_methods.each do |method, value|
describe "##{method}" do
it 'delegates to Acme::Client::Resources::Order' do
expect(order.public_send(method)).to eq(value)
end
end
end
describe '#new_challenge' do
before do
challenge = instance_double('Acme::Client::Resources::Challenges::HTTP01')
authorization = instance_double('Acme::Client::Resources::Authorization')
allow(authorization).to receive(:http).and_return(challenge)
allow(acme_order).to receive(:authorizations).and_return([authorization])
end
it 'returns challenge' do
expect(order.new_challenge).to be_a(::Gitlab::LetsEncrypt::Challenge)
end
end
end
# frozen_string_literal: true
module LetsEncryptHelpers
def stub_lets_encrypt_client
client = instance_double('Acme::Client')
allow(client).to receive(:new_account)
allow(client).to receive(:terms_of_service).and_return(
"https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf"
)
allow(Acme::Client).to receive(:new).with(
private_key: kind_of(OpenSSL::PKey::RSA),
directory: ::Gitlab::LetsEncrypt::Client::STAGING_DIRECTORY_URL
).and_return(client)
client
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment