diff --git a/GITLAB_SHELL_VERSION b/GITLAB_SHELL_VERSION index 0e79152459e0e7b5b95cd2f5c3f011252c07f4c3..2bf50aaf17a6f89855dce48afd4e6cbb554047d6 100644 --- a/GITLAB_SHELL_VERSION +++ b/GITLAB_SHELL_VERSION @@ -1 +1 @@ -8.1.1 +8.3.0 diff --git a/doc/administration/geo/replication/index.md b/doc/administration/geo/replication/index.md index 8c2b6abd59f0cd38ccafa3543e10087ab8b6d0ba..6bc242a4e8cf9b22a96de9702fb8b85a53d439d4 100644 --- a/doc/administration/geo/replication/index.md +++ b/doc/administration/geo/replication/index.md @@ -202,9 +202,8 @@ Read how to [replicate the Container Registry][docker-registry]. extra limitations may be in place. - Pushing code to a secondary redirects the request to the primary instead of handling it directly [gitlab-ee#1381](https://gitlab.com/gitlab-org/gitlab-ee/issues/1381): - * Only push via HTTP is currently supported - * Git LFS is supported - * Pushing via SSH is currently not supported: [gitlab-ee#5387](https://gitlab.com/gitlab-org/gitlab-ee/issues/5387) + * Push via HTTP and SSH supported + * Git LFS also supported - The primary node has to be online for OAuth login to happen (existing sessions and Git are not affected) - The installation takes multiple manual steps that together can take about an hour depending on circumstances; we are working on improving this experience, see [gitlab-org/omnibus-gitlab#2978] for details. diff --git a/ee/changelogs/unreleased/ash-mckenzie-geo-git-push-ssh-proxy.yml b/ee/changelogs/unreleased/ash-mckenzie-geo-git-push-ssh-proxy.yml new file mode 100644 index 0000000000000000000000000000000000000000..91e9b4788e14ab5c15094e3f9bd1b6261f8149b2 --- /dev/null +++ b/ee/changelogs/unreleased/ash-mckenzie-geo-git-push-ssh-proxy.yml @@ -0,0 +1,5 @@ +--- +title: 'Geo: SSH git push to secondary -> proxy to Primary' +merge_request: 6456 +author: +type: added diff --git a/ee/lib/api/geo.rb b/ee/lib/api/geo.rb index 18a8444023fe2db9000a844d64095a38e32dc0a5..167ee6301bf82466919c18ba7d9a5716c17ae512 100644 --- a/ee/lib/api/geo.rb +++ b/ee/lib/api/geo.rb @@ -1,3 +1,5 @@ +require 'base64' + module API class Geo < Grape::API resource :geo do @@ -40,6 +42,51 @@ class Geo < Grape::API render_validation_error!(db_status) end end + + # git push over SSH secondary -> primary related proxying logic + # + resource 'proxy_git_push_ssh' do + format :json + + # Responsible for making HTTP GET /repo.git/info/refs?service=git-receive-pack + # request *from* secondary gitlab-shell to primary + # + params do + requires :secret_token, type: String + requires :data, type: Hash do + requires :gl_id, type: String + requires :primary_repo, type: String + end + end + post 'info_refs' do + authenticate_by_gitlab_shell_token! + params.delete(:secret_token) + + resp = Gitlab::Geo::GitPushSSHProxy.new(params['data']).info_refs + status(resp.code.to_i) + { status: true, message: nil, result: Base64.encode64(resp.body.to_s) } + end + + # Responsible for making HTTP POST /repo.git/git-receive-pack + # request *from* secondary gitlab-shell to primary + # + params do + requires :secret_token, type: String + requires :data, type: Hash do + requires :gl_id, type: String + requires :primary_repo, type: String + end + requires :output, type: String, desc: 'Output from git-receive-pack' + end + post 'push' do + authenticate_by_gitlab_shell_token! + params.delete(:secret_token) + + resp = Gitlab::Geo::GitPushSSHProxy.new(params['data']).push(Base64.decode64(params['output'])) + status(resp.code.to_i) + { status: true, message: nil, result: Base64.encode64(resp.body.to_s) } + end + end end end end diff --git a/ee/lib/ee/gitlab/geo_git_access.rb b/ee/lib/ee/gitlab/geo_git_access.rb index e98313ca0c0c432c8472dd656196c4511d3118d5..d253090a2771db2b265b983329c2e8fedbd882ac 100644 --- a/ee/lib/ee/gitlab/geo_git_access.rb +++ b/ee/lib/ee/gitlab/geo_git_access.rb @@ -3,9 +3,19 @@ module Gitlab module GeoGitAccess include ::Gitlab::ConfigHelper include ::EE::GitlabRoutingHelper + include GrapePathHelpers::NamedRouteMatcher + extend ::Gitlab::Utils::Override GEO_SERVER_DOCS_URL = 'https://docs.gitlab.com/ee/administration/geo/replication/using_a_geo_server.html'.freeze + override :check_custom_action + def check_custom_action(cmd) + custom_action = custom_action_for(cmd) + return custom_action if custom_action + + super + end + protected def project_or_wiki @@ -14,6 +24,27 @@ def project_or_wiki private + def custom_action_for?(cmd) + return unless receive_pack?(cmd) + return unless ::Gitlab::Database.read_only? + + ::Gitlab::Geo.secondary_with_primary? + end + + def custom_action_for(cmd) + return unless custom_action_for?(cmd) + + payload = { + 'action' => 'geo_proxy_to_primary', + 'data' => { + 'api_endpoints' => [api_v4_geo_proxy_git_push_ssh_info_refs_path, api_v4_geo_proxy_git_push_ssh_push_path], + 'primary_repo' => geo_primary_http_url_to_repo(project_or_wiki) + } + } + + ::Gitlab::GitAccessResult::CustomAction.new(payload, 'Attempting to proxy to primary.') + end + def push_to_read_only_message message = super diff --git a/ee/lib/ee/gitlab/middleware/read_only/controller.rb b/ee/lib/ee/gitlab/middleware/read_only/controller.rb index a8705a706ac14119bc1bfcf1d18994ee1309cb0b..0d7c04497d92cac0db23b4a22871e95e3f4de9cb 100644 --- a/ee/lib/ee/gitlab/middleware/read_only/controller.rb +++ b/ee/lib/ee/gitlab/middleware/read_only/controller.rb @@ -17,7 +17,7 @@ module Controller override :whitelisted_routes def whitelisted_routes - super || geo_node_update_route + super || geo_node_update_route || geo_proxy_git_push_ssh_route end def geo_node_update_route @@ -33,6 +33,14 @@ def geo_node_update_route WHITELISTED_GEO_ROUTES_TRACKING_DB[controller]&.include?(action) end end + + def geo_proxy_git_push_ssh_route + routes = ::Gitlab::Middleware::ReadOnly::API_VERSIONS.map do |version| + ["/api/v#{version}/geo/proxy_git_push_ssh/info_refs", + "/api/v#{version}/geo/proxy_git_push_ssh/push"] + end + routes.flatten.include?(request.path) + end end end end diff --git a/ee/lib/gitlab/geo/git_push_ssh_proxy.rb b/ee/lib/gitlab/geo/git_push_ssh_proxy.rb new file mode 100644 index 0000000000000000000000000000000000000000..d065dd5938b5310e8eacad36f72f7dee389a5b08 --- /dev/null +++ b/ee/lib/gitlab/geo/git_push_ssh_proxy.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module Gitlab + module Geo + class GitPushSSHProxy + HTTP_READ_TIMEOUT = 10 + HTTP_SUCCESS_CODE = '200'.freeze + + MustBeASecondaryNode = Class.new(StandardError) + + def initialize(data) + @data = data + end + + def info_refs + ensure_secondary! + + url = "#{primary_repo}/info/refs?service=git-receive-pack" + headers = { + 'Content-Type' => 'application/x-git-upload-pack-request' + } + + resp = get(url, headers) + return resp unless resp.code == HTTP_SUCCESS_CODE + + resp.body = remove_http_service_fragment_from(resp.body) + + resp + end + + def push(info_refs_response) + ensure_secondary! + + url = "#{primary_repo}/git-receive-pack" + headers = { + 'Content-Type' => 'application/x-git-receive-pack-request', + 'Accept' => 'application/x-git-receive-pack-result' + } + + post(url, info_refs_response, headers) + end + + private + + attr_reader :data + + def primary_repo + @primary_repo ||= data['primary_repo'] + end + + def gl_id + @gl_id ||= data['gl_id'] + end + + def base_headers + @base_headers ||= { + 'Geo-GL-Id' => gl_id, + 'Authorization' => Gitlab::Geo::BaseRequest.new.authorization + } + end + + def get(url, headers) + request(url, Net::HTTP::Get, headers) + end + + def post(url, body, headers) + request(url, Net::HTTP::Post, headers, body: body) + end + + def request(url, klass, headers, body: nil) + headers = base_headers.merge(headers) + uri = URI.parse(url) + req = klass.new(uri, headers) + req.body = body if body + + http = Net::HTTP.new(uri.hostname, uri.port) + http.read_timeout = HTTP_READ_TIMEOUT + http.use_ssl = true if uri.is_a?(URI::HTTPS) + + http.start { http.request(req) } + end + + def remove_http_service_fragment_from(body) + # HTTP(S) and SSH responses are very similar, except for the fragment below. + # As we're performing a git HTTP(S) request here, we'll get a HTTP(s) + # suitable git response. However, we're executing in the context of an + # SSH session so we need to make the response suitable for what git over + # SSH expects. + # + # See Downloading Data > HTTP(S) section at: + # https://git-scm.com/book/en/v2/Git-Internals-Transfer-Protocols + body.gsub(/\A001f# service=git-receive-pack\n0000/, '') + end + + def ensure_secondary! + raise MustBeASecondaryNode, 'Node is not a secondary' unless Gitlab::Geo.secondary_with_primary? + end + end + end +end diff --git a/ee/spec/lib/gitlab/geo/git_push_ssh_proxy_spec.rb b/ee/spec/lib/gitlab/geo/git_push_ssh_proxy_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7efcf2ab71b5be1c2872ccc7696ee6b08c683a0b --- /dev/null +++ b/ee/spec/lib/gitlab/geo/git_push_ssh_proxy_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Geo::GitPushSSHProxy, :geo do + include ::EE::GeoHelpers + + set(:primary_node) { create(:geo_node, :primary) } + set(:secondary_node) { create(:geo_node) } + + let(:current_node) { nil } + let(:project) { create(:project, :repository) } + let(:user) { project.creator } + let(:key) { create(:key, user: user) } + let(:base_request) { double(Gitlab::Geo::BaseRequest.new.authorization) } + + let(:info_refs_body_short) do + "008f43ba78b7912f7bf7ef1d7c3b8a0e5ae14a759dfa refs/heads/masterreport-status delete-refs side-band-64k quiet atomic ofs-delta agent=git/2.18.0 +0000" + end + + let(:base_headers) do + { + 'Geo-GL-Id' => "key-#{key.id}", + 'Authorization' => 'secret' + } + end + + let(:data) do + { + 'gl_id' => "key-#{key.id}", + 'primary_repo' => "#{primary_node.url}#{project.repository.full_path}.git" + } + end + + subject { described_class.new(data) } + + before do + stub_current_geo_node(current_node) + + allow(Gitlab::Geo::BaseRequest).to receive(:new).and_return(base_request) + allow(base_request).to receive(:authorization).and_return('secret') + end + + describe '#info_refs' do + context 'against primary node' do + let(:current_node) { primary_node } + + it 'raises an exception' do + expect do + subject.info_refs + end.to raise_error(described_class::MustBeASecondaryNode) + end + end + + context 'against secondary node' do + let(:current_node) { secondary_node } + + let(:full_info_refs_url) { "#{primary_node.url}#{project.full_path}.git/info/refs?service=git-receive-pack" } + let(:info_refs_headers) { base_headers.merge('Content-Type' => 'application/x-git-upload-pack-request') } + let(:info_refs_http_body_full) do + "001f# service=git-receive-pack +0000#{info_refs_body_short}" + end + + before do + stub_request(:get, full_info_refs_url).to_return(status: 200, body: info_refs_http_body_full, headers: info_refs_headers) + end + + it 'returns a Net::HTTPOK' do + expect(subject.info_refs).to be_a(Net::HTTPOK) + end + + it 'returns a modified body' do + expect(subject.info_refs.body).to eql(info_refs_body_short) + end + end + end + + describe '#push' do + context 'against primary node' do + let(:current_node) { primary_node } + + it 'raises an exception' do + expect do + subject.push(info_refs_body_short) + end.to raise_error(described_class::MustBeASecondaryNode) + end + end + + context 'against secondary node' do + let(:current_node) { secondary_node } + + let(:full_git_receive_pack_url) { "#{primary_node.url}#{project.full_path}.git/git-receive-pack" } + let(:push_headers) do + base_headers.merge( + 'Content-Type' => 'application/x-git-receive-pack-request', + 'Accept' => 'application/x-git-receive-pack-result' + ) + end + + before do + stub_request(:post, full_git_receive_pack_url).to_return(status: 201, body: info_refs_body_short, headers: push_headers) + end + + it 'returns a Net::HTTPCreated' do + expect(subject.push(info_refs_body_short)).to be_a(Net::HTTPCreated) + end + end + end +end diff --git a/ee/spec/lib/gitlab/git_access_spec.rb b/ee/spec/lib/gitlab/git_access_spec.rb index 50adeb8e87608c5743b9dce685686324a8be744d..a3cf4b80f7292f39a903ec52bdb6beab2392e799 100644 --- a/ee/spec/lib/gitlab/git_access_spec.rb +++ b/ee/spec/lib/gitlab/git_access_spec.rb @@ -18,25 +18,9 @@ allow(Gitlab::Database).to receive(:read_only?) { true } end - it 'denies push access' do - project.add_maintainer(user) + let(:primary_repo_url) { "https://localhost:3000/gitlab/#{project.full_path}.git" } - expect { push_changes }.to raise_unauthorized("You can't push code to a read-only GitLab instance.") - end - - it 'denies push access with primary present' do - error_message = "You can't push code to a read-only GitLab instance."\ -"\nPlease use the primary node URL instead: https://localhost:3000/gitlab/#{project.full_path}.git. -For more information: #{EE::Gitlab::GeoGitAccess::GEO_SERVER_DOCS_URL}" - - primary_node = create(:geo_node, :primary, url: 'https://localhost:3000/gitlab') - allow(Gitlab::Geo).to receive(:primary).and_return(primary_node) - allow(Gitlab::Geo).to receive(:secondary_with_primary?).and_return(true) - - project.add_maintainer(user) - - expect { push_changes }.to raise_unauthorized(error_message) - end + it_behaves_like 'a read-only GitLab instance' end describe "push_rule_check" do diff --git a/ee/spec/lib/gitlab/git_access_wiki_spec.rb b/ee/spec/lib/gitlab/git_access_wiki_spec.rb index 788f3eed81e9867309f75c30d0656c7f917fbd6a..e51c6062c23a72cd67c8f1b38a9a25ae7a7f56f8 100644 --- a/ee/spec/lib/gitlab/git_access_wiki_spec.rb +++ b/ee/spec/lib/gitlab/git_access_wiki_spec.rb @@ -1,18 +1,13 @@ require 'spec_helper' describe Gitlab::GitAccessWiki do - let(:access) { described_class.new(user, project, 'web', authentication_abilities: authentication_abilities, redirected_path: redirected_path) } - let(:project) { create(:project, :repository) } let(:user) { create(:user) } + let(:project) { create(:project, :repository) } let(:changes) { ['6f6d7e7ed 570e7b2ab refs/heads/master'] } + let(:authentication_abilities) { %i[read_project download_code push_code] } let(:redirected_path) { nil } - let(:authentication_abilities) do - [ - :read_project, - :download_code, - :push_code - ] - end + + let(:access) { described_class.new(user, project, 'web', authentication_abilities: authentication_abilities, redirected_path: redirected_path) } context "when in a read-only GitLab instance" do subject { access.check('git-receive-pack', changes) } @@ -22,26 +17,9 @@ allow(Gitlab::Database).to receive(:read_only?) { true } end - it 'denies push access' do - project.add_maintainer(user) - - expect { subject }.to raise_unauthorized("You can't push code to a read-only GitLab instance.") - end - - it 'denies push access with primary present' do - error_message = "You can't push code to a read-only GitLab instance. -Please use the primary node URL instead: "\ -"https://localhost:3000/gitlab/#{project.full_path}.wiki.git. -For more information: #{EE::Gitlab::GeoGitAccess::GEO_SERVER_DOCS_URL}" - - primary_node = create(:geo_node, :primary, url: 'https://localhost:3000/gitlab') - allow(Gitlab::Geo).to receive(:primary).and_return(primary_node) - allow(Gitlab::Geo).to receive(:secondary_with_primary?).and_return(true) - - project.add_maintainer(user) + let(:primary_repo_url) { "https://localhost:3000/gitlab/#{project.full_path}.wiki.git" } - expect { subject }.to raise_unauthorized(error_message) - end + it_behaves_like 'a read-only GitLab instance' end context 'when wiki is disabled' do @@ -58,6 +36,10 @@ private + def push_changes(changes = '_any') + access.check('git-receive-pack', changes) + end + def raise_unauthorized(message) raise_error(Gitlab::GitAccess::UnauthorizedError, message) end diff --git a/ee/spec/requests/api/geo_spec.rb b/ee/spec/requests/api/geo_spec.rb index d95bb7bbd84263803d5b631f09cd67262f29f57a..c9d57bdda0bd1ff68adb64698da83f34389e7686 100644 --- a/ee/spec/requests/api/geo_spec.rb +++ b/ee/spec/requests/api/geo_spec.rb @@ -25,7 +25,7 @@ end end - describe '/geo/transfers' do + describe 'GET /geo/transfers' do before do stub_current_geo_node(secondary_node) end @@ -287,4 +287,120 @@ it_behaves_like 'with terms enforced' end end + + describe '/geo/proxy_git_push_ssh' do + let(:secret_token) { Gitlab::Shell.secret_token } + let(:data) { { primary_repo: 'http://localhost:3001/testuser/repo.git', gl_id: 'key-1', gl_username: 'testuser' } } + + before do + stub_current_geo_node(secondary_node) + end + + describe 'POST /geo/proxy_git_push_ssh/info_refs' do + context 'with all required params missing' do + it 'responds with 400' do + post api('/geo/proxy_git_push_ssh/info_refs'), nil + + expect(response).to have_gitlab_http_status(400) + expect(json_response['error']).to eql('secret_token is missing, data is missing, data[gl_id] is missing, data[primary_repo] is missing') + end + end + + context 'with all required params' do + let(:git_push_ssh_proxy) { double(Gitlab::Geo::GitPushSSHProxy) } + + before do + allow(Gitlab::Geo::GitPushSSHProxy).to receive(:new).with(data).and_return(git_push_ssh_proxy) + end + + context 'with an invalid secret_token' do + it 'responds with 401' do + post(api('/geo/proxy_git_push_ssh/info_refs'), { secret_token: 'invalid', data: data }) + + expect(response).to have_gitlab_http_status(401) + expect(json_response['error']).to be_nil + end + end + + context 'where an exception occurs' do + it 'responds with 500' do + expect(git_push_ssh_proxy).to receive(:info_refs).and_raise('deliberate exception raised') + + post api('/geo/proxy_git_push_ssh/info_refs'), { secret_token: secret_token, data: data } + + expect(response).to have_gitlab_http_status(500) + expect(json_response['message']).to include('RuntimeError (deliberate exception raised)') + expect(json_response['result']).to be_nil + end + end + + context 'with a valid secret token' do + let(:http_response) { double(Net::HTTPResponse, code: 200, body: 'something here') } + + it 'responds with 200' do + expect(git_push_ssh_proxy).to receive(:info_refs).and_return(http_response) + + post api('/geo/proxy_git_push_ssh/info_refs'), { secret_token: secret_token, data: data } + + expect(response).to have_gitlab_http_status(200) + expect(Base64.decode64(json_response['result'])).to eql('something here') + end + end + end + end + + describe 'POST /geo/proxy_git_push_ssh/push' do + context 'with all required params missing' do + it 'responds with 400' do + post api('/geo/proxy_git_push_ssh/push'), nil + + expect(response).to have_gitlab_http_status(400) + expect(json_response['error']).to eql('secret_token is missing, data is missing, data[gl_id] is missing, data[primary_repo] is missing, output is missing') + end + end + + context 'with all required params' do + let(:text) { 'output text' } + let(:output) { Base64.encode64(text) } + let(:git_push_ssh_proxy) { double(Gitlab::Geo::GitPushSSHProxy) } + + before do + allow(Gitlab::Geo::GitPushSSHProxy).to receive(:new).with(data).and_return(git_push_ssh_proxy) + end + + context 'with an invalid secret_token' do + it 'responds with 401' do + post(api('/geo/proxy_git_push_ssh/push'), { secret_token: 'invalid', data: data, output: output }) + + expect(response).to have_gitlab_http_status(401) + expect(json_response['error']).to be_nil + end + end + + context 'where an exception occurs' do + it 'responds with 500' do + expect(git_push_ssh_proxy).to receive(:push).and_raise('deliberate exception raised') + post api('/geo/proxy_git_push_ssh/push'), { secret_token: secret_token, data: data, output: output } + + expect(response).to have_gitlab_http_status(500) + expect(json_response['message']).to include('RuntimeError (deliberate exception raised)') + expect(json_response['result']).to be_nil + end + end + + context 'with a valid secret token' do + let(:http_response) { double(Net::HTTPResponse, code: 201, body: 'something here') } + + it 'responds with 201' do + expect(git_push_ssh_proxy).to receive(:push).with(text).and_return(http_response) + + post api('/geo/proxy_git_push_ssh/push'), { secret_token: secret_token, data: data, output: output } + + expect(response).to have_gitlab_http_status(201) + expect(Base64.decode64(json_response['result'])).to eql('something here') + end + end + end + end + end end diff --git a/ee/spec/support/shared_examples/git_access_shared_examples.rb b/ee/spec/support/shared_examples/git_access_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..5726bc2403be257555096c8769da9efc5a09c6f4 --- /dev/null +++ b/ee/spec/support/shared_examples/git_access_shared_examples.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +shared_examples 'a read-only GitLab instance' do + it 'denies push access' do + project.add_maintainer(user) + + expect { push_changes }.to raise_unauthorized("You can't push code to a read-only GitLab instance.") + end + + context 'for a Geo setup' do + before do + primary_node = create(:geo_node, :primary, url: 'https://localhost:3000/gitlab') + allow(Gitlab::Geo).to receive(:primary).and_return(primary_node) + allow(Gitlab::Geo).to receive(:secondary_with_primary?).and_return(secondary_with_primary) + end + + context 'that is incorrectly setup' do + let(:secondary_with_primary) { false } + let(:error_message) { "You can't push code to a read-only GitLab instance." } + + it 'denies push access with primary present' do + project.add_maintainer(user) + + expect { push_changes }.to raise_unauthorized(error_message) + end + end + + context 'that is correctly setup' do + let(:secondary_with_primary) { true } + let(:payload) do + { + 'action' => 'geo_proxy_to_primary', + 'data' => { + 'api_endpoints' => %w{/api/v4/geo/proxy_git_push_ssh/info_refs /api/v4/geo/proxy_git_push_ssh/push}, + 'primary_repo' => primary_repo_url + } + } + end + + it 'attempts to proxy to the primary' do + project.add_maintainer(user) + + expect(push_changes).to be_a(Gitlab::GitAccessResult::CustomAction) + expect(push_changes.message).to eql('Attempting to proxy to primary.') + expect(push_changes.payload).to eql(payload) + end + end + end +end