diff --git a/.gitignore b/.gitignore index e9ff0048c1c..e1561c9db9a 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,4 @@ eslint-report.html /locale/**/*.time_stamp /.rspec /plugins/* +/.gitlab_pages_secret diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION index a3df0a6959e..ac39a106c48 100644 --- a/GITLAB_PAGES_VERSION +++ b/GITLAB_PAGES_VERSION @@ -1 +1 @@ -0.8.0 +0.9.0 diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 8c39a1f2aa9..a292be79a00 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -212,6 +212,8 @@ production: &base artifacts_server: true # external_http: ["1.1.1.1:80", "[2001::1]:80"] # If defined, enables custom domain support in GitLab Pages # external_https: ["1.1.1.1:443", "[2001::1]:443"] # If defined, enables custom domain and certificate support in GitLab Pages + admin: + address: unix:/home/git/gitlab/tmp/sockets/private/pages-admin.socket # TCP connections are supported too (e.g. tcp://host:port) ## Mattermost ## For enabling Add to Mattermost button diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 575f27d1ea9..5248bd858a0 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -215,6 +215,9 @@ Settings.pages['external_http'] ||= false unless Settings.pages['external_ht Settings.pages['external_https'] ||= false unless Settings.pages['external_https'].present? Settings.pages['artifacts_server'] ||= Settings.pages['enabled'] if Settings.pages['artifacts_server'].nil? +Settings.pages['admin'] ||= Settingslogic.new({}) +Settings.pages.admin['certificate'] ||= '' + # # Git LFS # diff --git a/config/initializers/pages.rb b/config/initializers/pages.rb new file mode 100644 index 00000000000..835197557e8 --- /dev/null +++ b/config/initializers/pages.rb @@ -0,0 +1,2 @@ +Gitlab::PagesClient.read_or_create_token +Gitlab::PagesClient.load_certificate diff --git a/lib/gitlab/pages_client.rb b/lib/gitlab/pages_client.rb new file mode 100644 index 00000000000..7b358a3bd1b --- /dev/null +++ b/lib/gitlab/pages_client.rb @@ -0,0 +1,117 @@ +module Gitlab + class PagesClient + class << self + attr_reader :certificate, :token + + def call(service, rpc, request, timeout: nil) + kwargs = request_kwargs(timeout) + stub(service).__send__(rpc, request, kwargs) # rubocop:disable GitlabSecurity/PublicSend + end + + # This function is not thread-safe. Call it from an initializer only. + def read_or_create_token + @token = read_token + rescue Errno::ENOENT + # TODO: uncomment this when omnibus knows how to write the token file for us + # https://gitlab.com/gitlab-org/omnibus-gitlab/merge_requests/2466 + # + # write_token(SecureRandom.random_bytes(64)) + # + # # Read from disk in case someone else won the race and wrote the file + # # before us. If this fails again let the exception bubble up. + # @token = read_token + end + + # This function is not thread-safe. Call it from an initializer only. + def load_certificate + cert_path = config.certificate + return unless cert_path.present? + + @certificate = File.read(cert_path) + end + + def ping + request = Grpc::Health::V1::HealthCheckRequest.new + call(:health_check, :check, request, timeout: 5.seconds) + end + + private + + def request_kwargs(timeout) + encoded_token = Base64.strict_encode64(token.to_s) + metadata = { + 'authorization' => "Bearer #{encoded_token}" + } + + result = { metadata: metadata } + + return result unless timeout + + # Do not use `Time.now` for deadline calculation, since it + # will be affected by Timecop in some tests, but grpc's c-core + # uses system time instead of timecop's time, so tests will fail + # `Time.at(Process.clock_gettime(Process::CLOCK_REALTIME))` will + # circumvent timecop + deadline = Time.at(Process.clock_gettime(Process::CLOCK_REALTIME)) + timeout + result[:deadline] = deadline + + result + end + + def stub(name) + stub_class(name).new(address, grpc_creds) + end + + def stub_class(name) + if name == :health_check + Grpc::Health::V1::Health::Stub + else + # TODO use pages namespace + Gitaly.const_get(name.to_s.camelcase.to_sym).const_get(:Stub) + end + end + + def address + addr = config.address + addr = addr.sub(%r{^tcp://}, '') if URI(addr).scheme == 'tcp' + addr + end + + def grpc_creds + if address.start_with?('unix:') + :this_channel_is_insecure + elsif @certificate + GRPC::Core::ChannelCredentials.new(@certificate) + else + # Use system certificate pool + GRPC::Core::ChannelCredentials.new + end + end + + def config + Gitlab.config.pages.admin + end + + def read_token + File.read(token_path) + end + + def token_path + Rails.root.join('.gitlab_pages_secret').to_s + end + + def write_token(new_token) + Tempfile.open(File.basename(token_path), File.dirname(token_path), encoding: 'ascii-8bit') do |f| + f.write(new_token) + f.close + File.link(f.path, token_path) + end + rescue Errno::EACCES => ex + # TODO stop rescuing this exception in GitLab 11.0 https://gitlab.com/gitlab-org/gitlab-ce/issues/45672 + Rails.logger.error("Could not write pages admin token file: #{ex}") + rescue Errno::EEXIST + # Another process wrote the token file concurrently with us. Use their token, not ours. + end + end + end +end diff --git a/lib/tasks/gitlab/pages.rake b/lib/tasks/gitlab/pages.rake new file mode 100644 index 00000000000..100e480bd66 --- /dev/null +++ b/lib/tasks/gitlab/pages.rake @@ -0,0 +1,9 @@ +namespace :gitlab do + namespace :pages do + desc 'Ping the pages admin API' + task admin_ping: :gitlab_environment do + Gitlab::PagesClient.ping + puts "OK: gitlab-pages admin API is reachable" + end + end +end diff --git a/spec/lib/gitlab/pages_client_spec.rb b/spec/lib/gitlab/pages_client_spec.rb new file mode 100644 index 00000000000..da6d26f4aee --- /dev/null +++ b/spec/lib/gitlab/pages_client_spec.rb @@ -0,0 +1,172 @@ +require 'spec_helper' + +describe Gitlab::PagesClient do + subject { described_class } + + describe '.token' do + it 'returns the token as it is on disk' do + pending 'add omnibus support for generating the secret file https://gitlab.com/gitlab-org/omnibus-gitlab/merge_requests/2466' + expect(subject.token).to eq(File.read('.gitlab_pages_secret')) + end + end + + describe '.read_or_create_token' do + subject { described_class.read_or_create_token } + let(:token_path) { 'tmp/tests/gitlab-pages-secret' } + before do + allow(described_class).to receive(:token_path).and_return(token_path) + FileUtils.rm_f(token_path) + end + + it 'uses the existing token file if it exists' do + secret = 'existing secret' + File.write(token_path, secret) + + subject + expect(described_class.token).to eq(secret) + end + + it 'creates one if none exists' do + pending 'add omnibus support for generating the secret file https://gitlab.com/gitlab-org/omnibus-gitlab/merge_requests/2466' + + old_token = described_class.token + # sanity check + expect(File.exist?(token_path)).to eq(false) + + subject + expect(described_class.token.bytesize).to eq(64) + expect(described_class.token).not_to eq(old_token) + end + end + + describe '.write_token' do + let(:token_path) { 'tmp/tests/gitlab-pages-secret' } + before do + allow(described_class).to receive(:token_path).and_return(token_path) + FileUtils.rm_f(token_path) + end + + it 'writes the secret' do + new_secret = 'hello new secret' + expect(File.exist?(token_path)).to eq(false) + + described_class.send(:write_token, new_secret) + + expect(File.read(token_path)).to eq(new_secret) + end + + it 'does nothing if the file already exists' do + existing_secret = 'hello secret' + File.write(token_path, existing_secret) + + described_class.send(:write_token, 'new secret') + + expect(File.read(token_path)).to eq(existing_secret) + end + end + + describe '.load_certificate' do + subject { described_class.load_certificate } + before do + allow(described_class).to receive(:config).and_return(config) + end + + context 'with no certificate in the config' do + let(:config) { double(:config, certificate: '') } + + it 'does not set @certificate' do + subject + + expect(described_class.certificate).to be_nil + end + end + + context 'with a certificate path in the config' do + let(:certificate_path) { 'tmp/tests/fake-certificate' } + let(:config) { double(:config, certificate: certificate_path) } + + it 'sets @certificate' do + certificate_data = "--- BEGIN CERTIFICATE ---\nbla\n--- END CERTIFICATE ---\n" + File.write(certificate_path, certificate_data) + subject + + expect(described_class.certificate).to eq(certificate_data) + end + end + end + + describe '.request_kwargs' do + let(:token) { 'secret token' } + let(:auth_header) { 'Bearer c2VjcmV0IHRva2Vu' } + before do + allow(described_class).to receive(:token).and_return(token) + end + + context 'without timeout' do + it { expect(subject.send(:request_kwargs, nil)[:metadata]['authorization']).to eq(auth_header) } + end + + context 'with timeout' do + let(:timeout) { 1.second } + + it 'still sets the authorization header' do + expect(subject.send(:request_kwargs, timeout)[:metadata]['authorization']).to eq(auth_header) + end + + it 'sets a deadline value' do + now = Time.now + deadline = subject.send(:request_kwargs, timeout)[:deadline] + + expect(deadline).to be_between(now, now + 2 * timeout) + end + end + end + + describe '.stub' do + before do + allow(described_class).to receive(:address).and_return('unix:/foo/bar') + end + + it { expect(subject.send(:stub, :health_check)).to be_a(Grpc::Health::V1::Health::Stub) } + end + + describe '.address' do + subject { described_class.send(:address) } + + before do + allow(described_class).to receive(:config).and_return(config) + end + + context 'with a unix: address' do + let(:config) { double(:config, address: 'unix:/foo/bar') } + + it { expect(subject).to eq('unix:/foo/bar') } + end + + context 'with a tcp:// address' do + let(:config) { double(:config, address: 'tcp://localhost:1234') } + + it { expect(subject).to eq('localhost:1234') } + end + end + + describe '.grpc_creds' do + subject { described_class.send(:grpc_creds) } + + before do + allow(described_class).to receive(:config).and_return(config) + end + + context 'with a unix: address' do + let(:config) { double(:config, address: 'unix:/foo/bar') } + + it { expect(subject).to eq(:this_channel_is_insecure) } + end + + context 'with a tcp:// address' do + let(:config) { double(:config, address: 'tcp://localhost:1234') } + + it { expect(subject).to be_a(GRPC::Core::ChannelCredentials) } + end + end +end