summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorVladimir Shushlin <v.shushlin@gmail.com>2019-04-24 11:05:33 +0200
committerVladimir Shushlin <v.shushlin@gmail.com>2019-05-15 15:38:15 +0300
commit14a97af604a6ee260bd29024c7ffba41b5750ddc (patch)
tree169d9b371b3d0b7d73f090609c88c35358290781
parent3ed275536488e10c10d3c341fbd0fcf7abd891bf (diff)
downloadgitlab-ce-acme-module.tar.gz
Add Let's Encrypt clientacme-module
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
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock3
-rw-r--r--app/controllers/admin/application_settings_controller.rb7
-rw-r--r--app/views/admin/application_settings/_pages.html.haml5
-rw-r--r--config/routes/admin.rb1
-rw-r--r--lib/gitlab/lets_encrypt/challenge.rb17
-rw-r--r--lib/gitlab/lets_encrypt/client.rb74
-rw-r--r--lib/gitlab/lets_encrypt/order.rb23
-rw-r--r--locale/gitlab.pot2
-rw-r--r--spec/lib/gitlab/lets_encrypt/challenge_spec.rb29
-rw-r--r--spec/lib/gitlab/lets_encrypt/client_spec.rb120
-rw-r--r--spec/lib/gitlab/lets_encrypt/order_spec.rb39
-rw-r--r--spec/support/helpers/lets_encrypt_helpers.rb19
13 files changed, 337 insertions, 4 deletions
diff --git a/Gemfile b/Gemfile
index 19432758b34..d20dbe7c2fd 100644
--- a/Gemfile
+++ b/Gemfile
@@ -60,6 +60,8 @@ gem 'u2f', '~> 0.2.1'
# GitLab Pages
gem 'validates_hostname', '~> 1.0.6'
gem 'rubyzip', '~> 1.2.2', require: 'zip'
+# GitLab Pages letsencrypt support
+gem 'acme-client', '~> 2.0.2'
# Browser detection
gem 'browser', '~> 2.5'
diff --git a/Gemfile.lock b/Gemfile.lock
index 1bd88b65124..482352e59d5 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -4,6 +4,8 @@ GEM
RedCloth (4.3.2)
abstract_type (0.0.7)
ace-rails-ap (4.1.2)
+ acme-client (2.0.2)
+ faraday (~> 0.9, >= 0.9.1)
actioncable (5.1.7)
actionpack (= 5.1.7)
nio4r (~> 2.0)
@@ -998,6 +1000,7 @@ PLATFORMS
DEPENDENCIES
RedCloth (~> 4.3.2)
ace-rails-ap (~> 4.1.0)
+ acme-client (~> 2.0.2)
activerecord_sane_schema_dumper (= 1.0)
acts-as-taggable-on (~> 6.0)
addressable (~> 2.5.2)
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index d445be0eb19..d5bc723aa8c 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -89,6 +89,13 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
)
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
def set_application_setting
diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml
index 64e01fa2d00..77795dbf913 100644
--- a/app/views/admin/application_settings/_pages.html.haml
+++ b/app/views/admin/application_settings/_pages.html.haml
@@ -30,8 +30,7 @@
.form-check
= 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
- // Terms of Service should actually be a link, but the best way to get the url is using API
- // So it will be done in later MR
- = _("I have read and agree to the Let's Encrypt Terms of Service")
+ - 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 }
+ = _("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 }
= f.submit _('Save changes'), class: "btn btn-success"
diff --git a/config/routes/admin.rb b/config/routes/admin.rb
index 90d7f4a04d4..bc19219a0b8 100644
--- a/config/routes/admin.rb
+++ b/config/routes/admin.rb
@@ -111,6 +111,7 @@ namespace :admin do
put :reset_health_check_token
put :clear_repository_check_states
get :integrations, :repository, :templates, :ci_cd, :reporting, :metrics_and_profiling, :network, :geo, :preferences
+ get :lets_encrypt_terms_of_service
end
resources :labels
diff --git a/lib/gitlab/lets_encrypt/challenge.rb b/lib/gitlab/lets_encrypt/challenge.rb
new file mode 100644
index 00000000000..6a7f5e965c5
--- /dev/null
+++ b/lib/gitlab/lets_encrypt/challenge.rb
@@ -0,0 +1,17 @@
+# 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
diff --git a/lib/gitlab/lets_encrypt/client.rb b/lib/gitlab/lets_encrypt/client.rb
new file mode 100644
index 00000000000..d7468b06767
--- /dev/null
+++ b/lib/gitlab/lets_encrypt/client.rb
@@ -0,0 +1,74 @@
+# 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
diff --git a/lib/gitlab/lets_encrypt/order.rb b/lib/gitlab/lets_encrypt/order.rb
new file mode 100644
index 00000000000..5109b5e9843
--- /dev/null
+++ b/lib/gitlab/lets_encrypt/order.rb
@@ -0,0 +1,23 @@
+# 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
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 71ec8bcb9ba..0765be61101 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -4872,7 +4872,7 @@ msgstr ""
msgid "I accept the|Terms of Service and Privacy Policy"
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 ""
msgid "ID"
diff --git a/spec/lib/gitlab/lets_encrypt/challenge_spec.rb b/spec/lib/gitlab/lets_encrypt/challenge_spec.rb
new file mode 100644
index 00000000000..74622f356de
--- /dev/null
+++ b/spec/lib/gitlab/lets_encrypt/challenge_spec.rb
@@ -0,0 +1,29 @@
+# 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
diff --git a/spec/lib/gitlab/lets_encrypt/client_spec.rb b/spec/lib/gitlab/lets_encrypt/client_spec.rb
new file mode 100644
index 00000000000..16a16acfd25
--- /dev/null
+++ b/spec/lib/gitlab/lets_encrypt/client_spec.rb
@@ -0,0 +1,120 @@
+# 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
diff --git a/spec/lib/gitlab/lets_encrypt/order_spec.rb b/spec/lib/gitlab/lets_encrypt/order_spec.rb
new file mode 100644
index 00000000000..ee7058baf8d
--- /dev/null
+++ b/spec/lib/gitlab/lets_encrypt/order_spec.rb
@@ -0,0 +1,39 @@
+# 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
diff --git a/spec/support/helpers/lets_encrypt_helpers.rb b/spec/support/helpers/lets_encrypt_helpers.rb
new file mode 100644
index 00000000000..7f0886b451c
--- /dev/null
+++ b/spec/support/helpers/lets_encrypt_helpers.rb
@@ -0,0 +1,19 @@
+# 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