diff --git a/app/assets/javascripts/mirrors/constants.js b/app/assets/javascripts/mirrors/constants.js new file mode 100644 index 00000000000..8dd6a726425 --- /dev/null +++ b/app/assets/javascripts/mirrors/constants.js @@ -0,0 +1,4 @@ +export default { + PASSWORD: 'password', + SSH: 'ssh_public_key', +}; diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/mirror_repos.js b/app/assets/javascripts/mirrors/mirror_repos.js similarity index 89% rename from app/assets/javascripts/pages/projects/settings/repository/show/mirror_repos.js rename to app/assets/javascripts/mirrors/mirror_repos.js index 4c56af20cc3..0d8f31d6bfc 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/show/mirror_repos.js +++ b/app/assets/javascripts/mirrors/mirror_repos.js @@ -3,10 +3,12 @@ import _ from 'underscore'; import { __ } from '~/locale'; import Flash from '~/flash'; import axios from '~/lib/utils/axios_utils'; +import SSHMirror from './ssh_mirror'; export default class MirrorRepos { constructor(container) { this.$container = $(container); + this.$password = null; this.$form = $('.js-mirror-form', this.$container); this.$urlInput = $('.js-mirror-url', this.$form); this.$protectedBranchesInput = $('.js-mirror-protected', this.$form); @@ -26,6 +28,18 @@ export default class MirrorRepos { this.$authMethod.on('change', () => this.togglePassword()); this.$password.on('input.updateUrl', () => this.debouncedUpdateUrl()); + + this.initMirrorSSH(); + } + + initMirrorSSH() { + if (this.$password) { + this.$password.off('input.updateUrl'); + } + this.$password = undefined; + + this.sshMirror = new SSHMirror('.js-mirror-form'); + this.sshMirror.init(); } updateUrl() { diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js new file mode 100644 index 00000000000..5bdf5d6277a --- /dev/null +++ b/app/assets/javascripts/mirrors/ssh_mirror.js @@ -0,0 +1,299 @@ +import $ from 'jquery'; +import _ from 'underscore'; +import { __ } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import Flash from '~/flash'; +import { backOff } from '~/lib/utils/common_utils'; +import AUTH_METHOD from './constants'; + +export default class SSHMirror { + constructor(formSelector) { + this.backOffRequestCounter = 0; + + this.$form = $(formSelector); + + this.$repositoryUrl = this.$form.find('.js-repo-url'); + this.$knownHosts = this.$form.find('.js-known-hosts'); + + this.$sectionSSHHostKeys = this.$form.find('.js-ssh-host-keys-section'); + this.$hostKeysInformation = this.$form.find('.js-fingerprint-ssh-info'); + this.$btnDetectHostKeys = this.$form.find('.js-detect-host-keys'); + this.$btnSSHHostsShowAdvanced = this.$form.find('.btn-show-advanced'); + this.$dropdownAuthType = this.$form.find('.js-mirror-auth-type'); + + this.$wellAuthTypeChanging = this.$form.find('.js-well-changing-auth'); + this.$wellPasswordAuth = this.$form.find('.js-well-password-auth'); + this.$wellSSHAuth = this.$form.find('.js-well-ssh-auth'); + this.$sshPublicKeyWrap = this.$form.find('.js-ssh-public-key-wrap'); + this.$regeneratePublicSshKeyButton = this.$wellSSHAuth.find('.js-btn-regenerate-ssh-key'); + this.$regeneratePublicSshKeyModal = this.$wellSSHAuth.find( + '.js-regenerate-public-ssh-key-confirm-modal', + ); + } + + init() { + this.handleRepositoryUrlInput(true); + + this.$repositoryUrl.on('keyup', () => this.handleRepositoryUrlInput()); + this.$knownHosts.on('keyup', e => this.handleSSHKnownHostsInput(e)); + this.$dropdownAuthType.on('change', e => this.handleAuthTypeChange(e)); + this.$btnDetectHostKeys.on('click', e => this.handleDetectHostKeys(e)); + this.$btnSSHHostsShowAdvanced.on('click', e => this.handleSSHHostsAdvanced(e)); + this.$regeneratePublicSshKeyButton.on('click', () => + this.$regeneratePublicSshKeyModal.toggle(true), + ); + $('.js-confirm', this.$regeneratePublicSshKeyModal).on('click', e => + this.regeneratePublicSshKey(e), + ); + $('.js-cancel', this.$regeneratePublicSshKeyModal).on('click', () => + this.$regeneratePublicSshKeyModal.toggle(false), + ); + } + + /** + * Method to monitor Git Repository URL input + */ + handleRepositoryUrlInput(forceMatch) { + const protocol = this.$repositoryUrl.val().split('://')[0]; + const protRegEx = /http|git/; + + // Validate URL and verify if it consists only supported protocols + if (forceMatch || this.$form.get(0).checkValidity()) { + const isSsh = protocol === 'ssh'; + // Hide/Show SSH Host keys section only for SSH URLs + this.$sectionSSHHostKeys.collapse(isSsh ? 'show' : 'hide'); + this.$btnDetectHostKeys.enable(); + + // Verify if URL is http, https or git and hide/show Auth type dropdown + // as we don't support auth type SSH for non-SSH URLs + const matchesProtocol = protRegEx.test(protocol); + this.$dropdownAuthType.attr('disabled', matchesProtocol); + + if (forceMatch && isSsh) { + this.$dropdownAuthType.val(AUTH_METHOD.SSH); + this.toggleAuthWell(AUTH_METHOD.SSH); + } else { + this.$dropdownAuthType.val(AUTH_METHOD.PASSWORD); + this.toggleAuthWell(AUTH_METHOD.PASSWORD); + } + } + } + + /** + * Click event handler to detect SSH Host key and fingerprints from + * provided Git Repository URL. + */ + handleDetectHostKeys() { + const projectMirrorSSHEndpoint = this.$form.data('project-mirror-ssh-endpoint'); + const repositoryUrl = this.$repositoryUrl.val(); + const currentKnownHosts = this.$knownHosts.val(); + const $btnLoadSpinner = this.$btnDetectHostKeys.find('.js-spinner'); + + // Disable button while we make request + this.$btnDetectHostKeys.disable(); + $btnLoadSpinner.removeClass('d-none'); + + // Make backOff polling to get data + backOff((next, stop) => { + axios + .get( + `${projectMirrorSSHEndpoint}?ssh_url=${repositoryUrl}&compare_host_keys=${encodeURIComponent( + currentKnownHosts, + )}`, + ) + .then(({ data, status }) => { + if (status === 204) { + this.backOffRequestCounter += 1; + if (this.backOffRequestCounter < 3) { + next(); + } else { + stop(data); + } + } else { + stop(data); + } + }) + .catch(stop); + }) + .then(res => { + $btnLoadSpinner.addClass('d-none'); + // Once data is received, we show verification info along with Host keys and fingerprints + this.$hostKeysInformation + .find('.js-fingerprint-verification') + .collapse(res.host_keys_changed ? 'hide' : 'show'); + if (res.known_hosts && res.fingerprints) { + this.showSSHInformation(res); + } + }) + .catch(({ response }) => { + // Show failure message when there's an error and re-enable Detect host keys button + const failureMessage = response.data + ? response.data.message + : __('An error occurred while detecting host keys'); + Flash(failureMessage); + + $btnLoadSpinner.addClass('hidden'); + this.$btnDetectHostKeys.enable(); + }); + } + + /** + * Method to monitor known hosts textarea input + */ + handleSSHKnownHostsInput() { + // Strike-out fingerprints and remove verification info if `known hosts` value is altered + this.$hostKeysInformation.find('.js-fingerprints-list').addClass('invalidate'); + this.$hostKeysInformation.find('.js-fingerprint-verification').collapse('hide'); + } + + /** + * Click event handler for `Show advanced` button under SSH Host keys section + */ + handleSSHHostsAdvanced() { + const $knownHost = this.$sectionSSHHostKeys.find('.js-ssh-known-hosts'); + const toggleShowAdvanced = $knownHost.hasClass('show'); + + $knownHost.collapse('toggle'); + this.$btnSSHHostsShowAdvanced.toggleClass('show-advanced', toggleShowAdvanced); + } + + /** + * Authentication method dropdown change event listener + */ + handleAuthTypeChange() { + const projectMirrorAuthTypeEndpoint = `${this.$form.attr('action')}.json`; + const $sshPublicKey = this.$sshPublicKeyWrap.find('.ssh-public-key'); + const selectedAuthType = this.$dropdownAuthType.val(); + + this.$wellPasswordAuth.collapse('hide'); + this.$wellSSHAuth.collapse('hide'); + + // This request should happen only if selected Auth type was SSH + // and SSH Public key was not present on page load + if (selectedAuthType === AUTH_METHOD.SSH && !$sshPublicKey.text().trim()) { + if (!this.$wellSSHAuth.length) return; + + // Construct request body + const authTypeData = { + project: { + ...this.$regeneratePublicSshKeyButton.data().projectData, + }, + }; + + this.$wellAuthTypeChanging.collapse('show'); + this.$dropdownAuthType.disable(); + + axios + .put(projectMirrorAuthTypeEndpoint, JSON.stringify(authTypeData), { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + }) + .then(({ data }) => { + // Show SSH public key container and fill in public key + this.toggleAuthWell(selectedAuthType); + this.toggleSSHAuthWellMessage(true); + this.setSSHPublicKey(data.import_data_attributes.ssh_public_key); + + this.$wellAuthTypeChanging.collapse('hide'); + this.$dropdownAuthType.enable(); + }) + .catch(() => { + Flash(__('Something went wrong on our end.')); + + this.$wellAuthTypeChanging.collapse('hide'); + this.$dropdownAuthType.enable(); + }); + } else { + this.toggleAuthWell(selectedAuthType); + this.$wellSSHAuth.find('.js-ssh-public-key-present').collapse('show'); + } + } + + /** + * Method to parse SSH Host keys data and render it + * under SSH host keys section + */ + showSSHInformation(sshHostKeys) { + const $fingerprintsList = this.$hostKeysInformation.find('.js-fingerprints-list'); + let fingerprints = ''; + sshHostKeys.fingerprints.forEach(fingerprint => { + const escFingerprints = _.escape(fingerprint.fingerprint); + fingerprints += `${escFingerprints}`; + }); + + this.$hostKeysInformation.collapse('show'); + $fingerprintsList.removeClass('invalidate'); + $fingerprintsList.html(fingerprints); + this.$sectionSSHHostKeys.find('.js-known-hosts').val(sshHostKeys.known_hosts); + } + + /** + * Toggle Auth type information container based on provided `authType` + */ + toggleAuthWell(authType) { + this.$wellPasswordAuth.collapse(authType === AUTH_METHOD.PASSWORD ? 'show' : 'hide'); + this.$wellSSHAuth.collapse(authType === AUTH_METHOD.SSH ? 'show' : 'hide'); + } + + /** + * Toggle SSH auth information message + */ + toggleSSHAuthWellMessage(sshKeyPresent) { + this.$sshPublicKeyWrap.collapse(sshKeyPresent ? 'show' : 'hide'); + this.$wellSSHAuth.find('.js-ssh-public-key-present').collapse(sshKeyPresent ? 'show' : 'hide'); + this.$regeneratePublicSshKeyButton.collapse(sshKeyPresent ? 'show' : 'hide'); + this.$wellSSHAuth.find('.js-ssh-public-key-pending').collapse(sshKeyPresent ? 'hide' : 'show'); + } + + /** + * Sets SSH Public key to Clipboard button and shows it on UI. + */ + setSSHPublicKey(sshPublicKey) { + this.$sshPublicKeyWrap.find('.ssh-public-key').text(sshPublicKey); + this.$sshPublicKeyWrap + .find('.btn-copy-ssh-public-key') + .attr('data-clipboard-text', sshPublicKey); + } + + regeneratePublicSshKey(event) { + event.preventDefault(); + + this.$regeneratePublicSshKeyModal.toggle(false); + + const button = this.$regeneratePublicSshKeyButton; + const spinner = $('.js-spinner', button); + const endpoint = button.data('endpoint'); + const authTypeData = { + project: { + ...this.$regeneratePublicSshKeyButton.data().projectData, + }, + }; + + button.attr('disabled', 'disabled'); + spinner.removeClass('d-none'); + + axios + .patch(endpoint, authTypeData) + .then(({ data }) => { + button.removeAttr('disabled'); + spinner.addClass('d-none'); + + this.setSSHPublicKey(data.import_data_attributes.ssh_public_key); + }) + .catch(() => { + Flash(_('Unable to regenerate public ssh key.')); + }); + } + + destroy() { + this.$repositoryUrl.off('keyup'); + this.$form.find('.js-known-hosts').off('keyup'); + this.$dropdownAuthType.off('change'); + this.$btnDetectHostKeys.off('click'); + this.$btnSSHHostsShowAdvanced.off('click'); + this.$regeneratePublicSshKeyButton.off('click'); + $('.js-confirm', this.$regeneratePublicSshKeyModal).off('click'); + $('.js-cancel', this.$regeneratePublicSshKeyModal).off('click'); + } +} diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js index 78cf5406e43..1ef4b460263 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js @@ -1,5 +1,5 @@ import initForm from '../form'; -import MirrorRepos from './mirror_repos'; +import MirrorRepos from '~/mirrors/mirror_repos'; document.addEventListener('DOMContentLoaded', () => { initForm(); diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index da3d8aa53ad..c7f986247bd 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -1223,3 +1223,27 @@ pre.light-well { opacity: 1; } } + +.project-mirror-settings { + .btn-show-advanced { + min-width: 135px; + + .label-show { + display: none; + } + + .label-hide { + display: inline; + } + + &.show-advanced { + .label-show { + display: inline; + } + + .label-hide { + display: none; + } + } + } +} diff --git a/app/controllers/projects/mirrors_controller.rb b/app/controllers/projects/mirrors_controller.rb index 53176978416..ab7ab13657a 100644 --- a/app/controllers/projects/mirrors_controller.rb +++ b/app/controllers/projects/mirrors_controller.rb @@ -77,6 +77,10 @@ class Projects::MirrorsController < Projects::ApplicationController id enabled only_protected_branches + auth_method + password + ssh_known_hosts + regenerate_ssh_private_key ] ] end diff --git a/app/helpers/mirror_helper.rb b/app/helpers/mirror_helper.rb index a4025730397..65c7cd82832 100644 --- a/app/helpers/mirror_helper.rb +++ b/app/helpers/mirror_helper.rb @@ -2,6 +2,9 @@ module MirrorHelper def mirrors_form_data_attributes - { project_mirror_endpoint: project_mirror_path(@project) } + { + project_mirror_ssh_endpoint: ssh_host_keys_project_mirror_path(@project, :json), + project_mirror_endpoint: project_mirror_path(@project, :json) + } end end diff --git a/app/models/concerns/mirror_authentication.rb b/app/models/concerns/mirror_authentication.rb new file mode 100644 index 00000000000..e3e1a0441f8 --- /dev/null +++ b/app/models/concerns/mirror_authentication.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +# Mirroring may use password or SSH public-key authentication. This concern +# implements support for persisting the necessary data in a `credentials` +# serialized attribute. It also needs an `url` method to be defined +module MirrorAuthentication + SSH_PRIVATE_KEY_OPTS = { + type: 'RSA', + bits: 4096 + }.freeze + + extend ActiveSupport::Concern + + included do + validates :auth_method, inclusion: { in: %w[password ssh_public_key] }, allow_blank: true + + # We should generate a key even if there's no SSH URL present + before_validation :generate_ssh_private_key!, if: -> { + regenerate_ssh_private_key || ( auth_method == 'ssh_public_key' && ssh_private_key.blank? ) + } + + credentials_field :auth_method, reader: false + credentials_field :ssh_known_hosts + credentials_field :ssh_known_hosts_verified_at + credentials_field :ssh_known_hosts_verified_by_id + credentials_field :ssh_private_key + credentials_field :user + credentials_field :password + end + + class_methods do + def credentials_field(name, reader: true) + if reader + define_method(name) do + credentials[name] if credentials.present? + end + end + + define_method("#{name}=") do |value| + self.credentials ||= {} + + # Removal of the password, username, etc, generally causes an update of + # the value to the empty string. Detect and gracefully handle this case. + if value.present? + self.credentials[name] = value + else + self.credentials.delete(name) + end + end + end + end + + attr_accessor :regenerate_ssh_private_key + + def ssh_key_auth? + ssh_mirror_url? && auth_method == 'ssh_public_key' + end + + def password_auth? + auth_method == 'password' + end + + def ssh_mirror_url? + url&.start_with?('ssh://') + end + + def ssh_known_hosts_verified_by + @ssh_known_hosts_verified_by ||= ::User.find_by(id: ssh_known_hosts_verified_by_id) + end + + def ssh_known_hosts_fingerprints + ::SshHostKey.fingerprint_host_keys(ssh_known_hosts) + end + + def auth_method + auth_method = credentials.fetch(:auth_method, nil) if credentials.present? + + auth_method.presence || 'password' + end + + def ssh_public_key + return nil if ssh_private_key.blank? + + comment = "git@#{::Gitlab.config.gitlab.host}" + ::SSHKey.new(ssh_private_key, comment: comment).ssh_public_key + end + + def generate_ssh_private_key! + self.ssh_private_key = ::SSHKey.generate(SSH_PRIVATE_KEY_OPTS).private_key + end +end diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb index c1f53b5da4f..a3415a4a14c 100644 --- a/app/models/remote_mirror.rb +++ b/app/models/remote_mirror.rb @@ -2,6 +2,7 @@ class RemoteMirror < ActiveRecord::Base include AfterCommitQueue + include MirrorAuthentication PROTECTED_BACKOFF_DELAY = 1.minute UNPROTECTED_BACKOFF_DELAY = 5.minutes @@ -28,6 +29,8 @@ class RemoteMirror < ActiveRecord::Base after_commit :remove_remote, on: :destroy + before_validation :store_credentials + scope :enabled, -> { where(enabled: true) } scope :started, -> { with_update_status(:started) } scope :stuck, -> { started.where('last_update_at < ? OR (last_update_at IS NULL AND updated_at < ?)', 1.day.ago, 1.day.ago) } @@ -84,7 +87,21 @@ class RemoteMirror < ActiveRecord::Base end def update_repository(options) - raw.update(options) + if ssh_mirror_url? + if ssh_key_auth? && ssh_private_key.present? + options[:ssh_key] = ssh_private_key + end + + if ssh_known_hosts.present? + options[:known_hosts] = ssh_known_hosts + end + end + + Gitlab::Git::RemoteMirror.new( + project.repository.raw, + remote_name, + **options + ).update end def sync? @@ -128,7 +145,8 @@ class RemoteMirror < ActiveRecord::Base super(value) && return unless Gitlab::UrlSanitizer.valid?(value) mirror_url = Gitlab::UrlSanitizer.new(value) - self.credentials = mirror_url.credentials + self.credentials ||= {} + self.credentials = self.credentials.merge(mirror_url.credentials) super(mirror_url.sanitized_url) end @@ -152,17 +170,28 @@ class RemoteMirror < ActiveRecord::Base def ensure_remote! return unless project - return unless remote_name && url + return unless remote_name && remote_url # If this fails or the remote already exists, we won't know due to # https://gitlab.com/gitlab-org/gitaly/issues/1317 - project.repository.add_remote(remote_name, url) + project.repository.add_remote(remote_name, remote_url) end private - def raw - @raw ||= Gitlab::Git::RemoteMirror.new(project.repository.raw, remote_name) + def store_credentials + # This is a necessary workaround for attr_encrypted, which doesn't otherwise + # notice that the credentials have changed + self.credentials = self.credentials + end + + # The remote URL omits any password if SSH public-key authentication is in use + def remote_url + return url unless ssh_key_auth? && password.present? + + Gitlab::UrlSanitizer.new(read_attribute(:url), credentials: { user: user }).full_url + rescue + super end def fallback_remote_name @@ -214,7 +243,7 @@ class RemoteMirror < ActiveRecord::Base project.repository.async_remove_remote(prev_remote_name) end - project.repository.add_remote(remote_name, url) + project.repository.add_remote(remote_name, remote_url) end def remove_remote @@ -224,6 +253,6 @@ class RemoteMirror < ActiveRecord::Base end def mirror_url_changed? - url_changed? || encrypted_credentials_changed? + url_changed? || credentials_changed? end end diff --git a/app/serializers/project_mirror_entity.rb b/app/serializers/project_mirror_entity.rb index 8aba244cd11..c13cc3276a7 100644 --- a/app/serializers/project_mirror_entity.rb +++ b/app/serializers/project_mirror_entity.rb @@ -3,11 +3,7 @@ class ProjectMirrorEntity < Grape::Entity expose :id - expose :remote_mirrors_attributes do |project| - next [] unless project.remote_mirrors.present? - - project.remote_mirrors.map do |remote| - remote.as_json(only: %i[id url enabled]) - end + expose :remote_mirrors_attributes, using: RemoteMirrorEntity do |project| + project.remote_mirrors end end diff --git a/app/serializers/remote_mirror_entity.rb b/app/serializers/remote_mirror_entity.rb new file mode 100644 index 00000000000..8835c6d4647 --- /dev/null +++ b/app/serializers/remote_mirror_entity.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class RemoteMirrorEntity < Grape::Entity + expose :id + expose :url + expose :enabled + + expose :auth_method + expose :ssh_known_hosts + expose :ssh_public_key + + expose :ssh_known_hosts_fingerprints do |remote_mirror| + remote_mirror.ssh_known_hosts_fingerprints.as_json + end +end diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb index 9d0877d1ab2..1244a0f72a7 100644 --- a/app/services/projects/update_remote_mirror_service.rb +++ b/app/services/projects/update_remote_mirror_service.rb @@ -11,7 +11,7 @@ module Projects begin remote_mirror.ensure_remote! - repository.fetch_remote(remote_mirror.remote_name, no_tags: true) + repository.fetch_remote(remote_mirror.remote_name, ssh_auth: remote_mirror, no_tags: true) opts = {} if remote_mirror.only_protected_branches? diff --git a/app/views/projects/mirrors/_authentication_method.html.haml b/app/views/projects/mirrors/_authentication_method.html.haml new file mode 100644 index 00000000000..8dc042d87d1 --- /dev/null +++ b/app/views/projects/mirrors/_authentication_method.html.haml @@ -0,0 +1,36 @@ +- mirror = f.object +- is_push = local_assigns.fetch(:is_push, false) +- auth_options = [[_('Password'), 'password'], [_('SSH public key'), 'ssh_public_key']] +- regen_data = { auth_method: 'ssh_public_key', regenerate_ssh_private_key: true } +- ssh_public_key_present = mirror.ssh_public_key.present? + +.form-group + = f.label :auth_method, _('Authentication method'), class: 'label-bold' + = f.select :auth_method, + options_for_select(auth_options, mirror.auth_method), + {}, { class: "form-control js-mirror-auth-type" } + +.form-group + .collapse.js-well-changing-auth + .changing-auth-method= icon('spinner spin lg') + .well-password-auth.collapse.js-well-password-auth + = f.label :password, _("Password"), class: "label-bold" + = f.password_field :password, value: mirror.password, class: 'form-control' + - unless is_push + .well-ssh-auth.collapse.js-well-ssh-auth + %p.js-ssh-public-key-present{ class: ('collapse' unless ssh_public_key_present) } + = _('Here is the public SSH key that needs to be added to the remote server. For more information, please refer to the documentation.') + %p.js-ssh-public-key-pending{ class: ('collapse' if ssh_public_key_present) } + = _('An SSH key will be automatically generated when the form is submitted. For more information, please refer to the documentation.') + + .clearfix.js-ssh-public-key-wrap{ class: ('collapse' unless ssh_public_key_present) } + %code.prepend-top-10.ssh-public-key + = mirror.ssh_public_key + = clipboard_button(text: mirror.ssh_public_key, title: _("Copy SSH public key to clipboard"), class: 'prepend-top-10 btn-copy-ssh-public-key') + + = button_tag type: 'button', + data: { endpoint: project_mirror_path(@project), project_data: { import_data_attributes: regen_data } }, + class: "btn btn-inverted btn-warning prepend-top-10 js-btn-regenerate-ssh-key#{ ' collapse' unless ssh_public_key_present }" do + = icon('spinner spin', class: 'js-spinner d-none') + = _('Regenerate key') + = render 'projects/mirrors/regenerate_public_ssh_key_confirm_modal' diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index d523df1cd90..2f9bd5b04b6 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -59,5 +59,7 @@ .badge.mirror-error-badge{ data: { toggle: 'tooltip', html: 'true' }, title: html_escape(mirror.last_error.try(:strip)) }= _('Error') %td.mirror-action-buttons .btn-group.mirror-actions-group.pull-right{ role: 'group' } + - if mirror.ssh_key_auth? + = clipboard_button(text: mirror.ssh_public_key, class: 'btn btn-default', title: _('Copy SSH public key')) = render 'shared/remote_mirror_update_button', remote_mirror: mirror %button.js-delete-mirror.btn.btn-danger{ type: 'button', data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' }, title: _('Remove') }= icon('trash-o') diff --git a/app/views/projects/mirrors/_mirror_repos_form.html.haml b/app/views/projects/mirrors/_mirror_repos_form.html.haml index 93994cb30ac..a2cce83bfab 100644 --- a/app/views/projects/mirrors/_mirror_repos_form.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_form.html.haml @@ -1,18 +1,5 @@ -- protocols = Gitlab::UrlSanitizer::ALLOWED_SCHEMES.join('|') - .form-group = label_tag :mirror_direction, _('Mirror direction'), class: 'label-light' = select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control js-mirror-direction', disabled: true -= f.fields_for :remote_mirrors, @project.remote_mirrors.build do |rm_f| - = rm_f.hidden_field :enabled, value: '1' - = rm_f.hidden_field :url, class: 'js-mirror-url-hidden', required: true, pattern: "(#{protocols}):\/\/.+" - = rm_f.hidden_field :only_protected_branches, class: 'js-mirror-protected-hidden' - -.form-group - = label_tag :auth_method, _('Authentication method'), class: 'label-bold' - = select_tag :auth_method, options_for_select([[_('None'), 'none'], [_('Password'), 'password']], 'none'), { class: "form-control js-auth-method" } - -.form-group.js-password-group.collapse - = label_tag :password, _('Password'), class: 'label-bold' - = text_field_tag :password, '', class: 'form-control js-password' += render partial: "projects/mirrors/mirror_repos_push", locals: { f: f } diff --git a/app/views/projects/mirrors/_mirror_repos_push.html.haml b/app/views/projects/mirrors/_mirror_repos_push.html.haml new file mode 100644 index 00000000000..1d9c83653fe --- /dev/null +++ b/app/views/projects/mirrors/_mirror_repos_push.html.haml @@ -0,0 +1,8 @@ +- protocols = Gitlab::UrlSanitizer::ALLOWED_SCHEMES.join('|') + += f.fields_for :remote_mirrors, @project.remote_mirrors.build do |rm_f| + = rm_f.hidden_field :enabled, value: '1' + = rm_f.hidden_field :url, class: 'js-mirror-url-hidden', required: true, pattern: "(#{protocols}):\/\/.+" + = rm_f.hidden_field :only_protected_branches, class: 'js-mirror-protected-hidden' + = render partial: 'projects/mirrors/ssh_host_keys', locals: { f: rm_f } + = render partial: 'projects/mirrors/authentication_method', locals: { f: rm_f, is_push: true } diff --git a/app/views/projects/mirrors/_regenerate_public_ssh_key_confirm_modal.html.haml b/app/views/projects/mirrors/_regenerate_public_ssh_key_confirm_modal.html.haml new file mode 100644 index 00000000000..327552c9b2c --- /dev/null +++ b/app/views/projects/mirrors/_regenerate_public_ssh_key_confirm_modal.html.haml @@ -0,0 +1,13 @@ +.modal.js-regenerate-public-ssh-key-confirm-modal{ tabindex: -1 } + .modal-dialog + .modal-content + .modal-header + %h3.modal-title.page-title + Regenerate public SSH key? + %button.close.js-cancel{ type: 'button', 'data-dismiss': 'modal', 'aria-label' => _('Close') } + %span{ 'aria-hidden': true } × + .modal-body + %p= _('Are you sure you want to regenerate the public key? You will have to update the public key on the remote server before mirroring will work again.') + .form-actions.modal-footer + = button_tag _('Cancel'), type: 'button', class: 'btn js-cancel' + = button_tag _('Regenerate key'), type: 'button', class: 'btn btn-inverted btn-warning js-confirm' diff --git a/app/views/projects/mirrors/_show.html.haml b/app/views/projects/mirrors/_show.html.haml deleted file mode 100644 index 8318d5898a1..00000000000 --- a/app/views/projects/mirrors/_show.html.haml +++ /dev/null @@ -1 +0,0 @@ -= render 'projects/mirrors/mirror_repos' diff --git a/app/views/projects/mirrors/_ssh_host_keys.html.haml b/app/views/projects/mirrors/_ssh_host_keys.html.haml new file mode 100644 index 00000000000..f61aa6ecd11 --- /dev/null +++ b/app/views/projects/mirrors/_ssh_host_keys.html.haml @@ -0,0 +1,33 @@ +- mirror = f.object +- verified_by = mirror.ssh_known_hosts_verified_by +- verified_at = mirror.ssh_known_hosts_verified_at + +.form-group.js-ssh-host-keys-section{ class: ('collapse' unless mirror.ssh_mirror_url?) } + %button.btn.btn-inverted.btn-success.inline.js-detect-host-keys.append-right-10{ type: 'button' } + = icon('spinner spin', class: 'js-spinner d-none') + = _('Detect host keys') + .fingerprint-ssh-info.js-fingerprint-ssh-info.prepend-top-10.append-bottom-10{ class: ('collapse' unless mirror.ssh_mirror_url?) } + %label.label-bold + = _('Fingerprints') + .fingerprints-list.js-fingerprints-list + - mirror.ssh_known_hosts_fingerprints.each do |fp| + %code= fp.fingerprint + - if verified_at + .form-text.text-muted.js-fingerprint-verification + %i.fa.fa-check.fingerprint-verified + Verified by + - if verified_by + = link_to verified_by.name, user_path(verified_by) + - else + = _('a deleted user') + #{time_ago_in_words(verified_at)} ago + + .js-ssh-hosts-advanced.inline + %button.btn.btn-default.btn-show-advanced.show-advanced{ type: 'button' } + %span.label-show + = _('Input host keys manually') + %span.label-hide + = _('Hide host keys manual input') + .js-ssh-known-hosts.collapse.prepend-top-default + = f.label :ssh_known_hosts, _('SSH host keys'), class: 'label-bold' + = f.text_area :ssh_known_hosts, class: 'form-control known-hosts js-known-hosts', rows: '10' diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index a0bcaaf3c54..c14e95a382c 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -3,7 +3,7 @@ - @content_class = "limit-container-width" unless fluid_layout = render "projects/default_branch/show" -= render "projects/mirrors/show" += render "projects/mirrors/mirror_repos" -# Protected branches & tags use a lot of nested partials. -# The shared parts of the views can be found in the `shared` directory. diff --git a/changelogs/unreleased/49565-ssh-push-mirroring.yml b/changelogs/unreleased/49565-ssh-push-mirroring.yml new file mode 100644 index 00000000000..2dfeffa4088 --- /dev/null +++ b/changelogs/unreleased/49565-ssh-push-mirroring.yml @@ -0,0 +1,5 @@ +--- +title: Allow SSH public-key authentication for push mirroring +merge_request: 22982 +author: +type: added diff --git a/doc/workflow/repository_mirroring.md b/doc/workflow/repository_mirroring.md index 4225d1aa31d..7eb324e3ece 100644 --- a/doc/workflow/repository_mirroring.md +++ b/doc/workflow/repository_mirroring.md @@ -135,23 +135,25 @@ If the mirror updates successfully, it will be enqueued once again with a small If the mirror fails (for example, a branch diverged from upstream), the project's backoff period is increased each time it fails, up to a maximum amount of time. -### SSH authentication **[STARTER]** +### SSH authentication -> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/2551) in [GitLab Starter](https://about.gitlab.com/pricing/) 9.5. +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/2551) for Push mirroring in [GitLab Starter](https://about.gitlab.com/pricing/) 9.5. +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22982) for Pull mirroring in [GitLab Core](https://about.gitlab.com/pricing/) 11.6 SSH authentication is mutual: - You have to prove to the server that you're allowed to access the repository. - The server also has to prove to *you* that it's who it claims to be. -You provide your credentials as a password or public key. The server that the source repository -resides on provides its credentials as a "host key", the fingerprint of which needs to be verified manually. +You provide your credentials as a password or public key. The server that the +other repository resides on provides its credentials as a "host key", the +fingerprint of which needs to be verified manually. If you're mirroring over SSH (that is, using an `ssh://` URL), you can authenticate using: - Password-based authentication, just as over HTTPS. -- Public key authentication. This is often more secure than password authentication, especially when - the source repository supports [Deploy Keys](../ssh/README.md#deploy-keys). +- Public key authentication. This is often more secure than password authentication, + especially when the other repository supports [Deploy Keys](../ssh/README.md#deploy-keys). To get started: @@ -171,9 +173,9 @@ If you click the: - **Detect host keys** button, GitLab will fetch the host keys from the server and display the fingerprints. - **Input host keys manually** button, a field is displayed where you can paste in host keys. -You now need to verify that the fingerprints are those you expect. GitLab.com -and other code hosting sites publish their fingerprints in the open for you -to check: +Assuming you used the former, you now need to verify that the fingerprints are +those you expect. GitLab.com and other code hosting sites publish their +fingerprints in the open for you to check: - [AWS CodeCommit](http://docs.aws.amazon.com/codecommit/latest/userguide/regions.html#regions-fingerprints) - [Bitbucket](https://confluence.atlassian.com/bitbucket/use-the-ssh-protocol-with-bitbucket-cloud-221449711.html#UsetheSSHprotocolwithBitbucketCloud-KnownhostorBitbucket%27spublickeyfingerprints) @@ -184,7 +186,8 @@ to check: - [SourceForge](https://sourceforge.net/p/forge/documentation/SSH%20Key%20Fingerprints/) Other providers will vary. If you're running self-managed GitLab, or otherwise -have access to the source server, you can securely gather the key fingerprints: +have access to the server for the other repository, you can securely gather the +key fingerprints: ```sh $ cat /etc/ssh/ssh_host*pub | ssh-keygen -E md5 -l -f - @@ -196,25 +199,27 @@ $ cat /etc/ssh/ssh_host*pub | ssh-keygen -E md5 -l -f - NOTE: **Note:** You may need to exclude `-E md5` for some older versions of SSH. -When pulling changes from the source repository, GitLab will now check that at least one of the stored -host keys matches before connecting. This can prevent malicious code from being injected into your -mirror, or your password being stolen. +When mirroring the repository, GitLab will now check that at least one of the +stored host keys matches before connecting. This can prevent malicious code from +being injected into your mirror, or your password being stolen. ### SSH public key authentication -To use SSH public key authentication, you'll also need to choose that option from the **Authentication method** -dropdown. GitLab will generate a 4096-bit RSA key and display the public component of that key to you. +To use SSH public key authentication, you'll also need to choose that option +from the **Authentication method** dropdown. GitLab will generate a 4096-bit RSA +key and display the public component of that key to you. -You then need to add the public SSH key to the source repository configuration. If: +You then need to add the public SSH key to the other repository's configuration: -- The source is hosted on GitLab, you should add the public SSH key as a [Deploy Key](../ssh/README.md#deploy-keys). -- The source is hosted elsewhere, you may need to add the key to your user's `authorized_keys` file. - Paste the entire public SSH key into the file on its own line and save it. +- If the other repository is hosted on GitLab, you should add the public SSH key + as a [Deploy Key](../ssh/README.md#deploy-keys). +- If the other repository is hosted elsewhere, you may need to add the key to + your user's `authorized_keys` file. Paste the entire public SSH key into the + file on its own line and save it. -Once the public key is set up on the source repository, click the **Mirror repository** button and -your mirror will begin working. - -If you need to change the key at any time, you can click the **Regenerate key** button to do so. You'll have to update the source repository with the new key to keep the mirror running. +If you need to change the key at any time, you can remove and re-add the mirror +to generate a new key. You'll have to update the other repository with the new +key to keep the mirror running. ### Overwrite diverged branches **[STARTER]** diff --git a/lib/gitlab/git/remote_mirror.rb b/lib/gitlab/git/remote_mirror.rb index e992d522e7f..df3cd422527 100644 --- a/lib/gitlab/git/remote_mirror.rb +++ b/lib/gitlab/git/remote_mirror.rb @@ -5,14 +5,24 @@ module Gitlab class RemoteMirror include Gitlab::Git::WrapsGitalyErrors - def initialize(repository, ref_name) + attr_reader :repository, :ref_name, :only_branches_matching, :ssh_key, :known_hosts + + def initialize(repository, ref_name, only_branches_matching: [], ssh_key: nil, known_hosts: nil) @repository = repository @ref_name = ref_name + @only_branches_matching = only_branches_matching + @ssh_key = ssh_key + @known_hosts = known_hosts end - def update(only_branches_matching: []) + def update wrapped_gitaly_errors do - @repository.gitaly_remote_client.update_remote_mirror(@ref_name, only_branches_matching) + repository.gitaly_remote_client.update_remote_mirror( + ref_name, + only_branches_matching, + ssh_key: ssh_key, + known_hosts: known_hosts + ) end end end diff --git a/lib/gitlab/gitaly_client/remote_service.rb b/lib/gitlab/gitaly_client/remote_service.rb index 24e8a5e16d3..81fac37ee68 100644 --- a/lib/gitlab/gitaly_client/remote_service.rb +++ b/lib/gitlab/gitaly_client/remote_service.rb @@ -68,13 +68,18 @@ module Gitlab encode_utf8(response.ref) end - def update_remote_mirror(ref_name, only_branches_matching) + def update_remote_mirror(ref_name, only_branches_matching, ssh_key: nil, known_hosts: nil) req_enum = Enumerator.new do |y| - y.yield Gitaly::UpdateRemoteMirrorRequest.new( + first_request = Gitaly::UpdateRemoteMirrorRequest.new( repository: @gitaly_repo, ref_name: ref_name ) + first_request.ssh_key = ssh_key if ssh_key.present? + first_request.known_hosts = known_hosts if known_hosts.present? + + y.yield(first_request) + current_size = 0 slices = only_branches_matching.slice_before do |branch_name| diff --git a/lib/gitlab/gitaly_client/repository_service.rb b/lib/gitlab/gitaly_client/repository_service.rb index f968ebc2cbf..12a0ee16649 100644 --- a/lib/gitlab/gitaly_client/repository_service.rb +++ b/lib/gitlab/gitaly_client/repository_service.rb @@ -69,7 +69,7 @@ module Gitlab no_tags: no_tags, timeout: timeout, no_prune: !prune ) - if ssh_auth&.ssh_import? + if ssh_auth&.ssh_mirror_url? if ssh_auth.ssh_key_auth? && ssh_auth.ssh_private_key.present? request.ssh_key = ssh_auth.ssh_private_key end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index f18821adb5f..952281f717e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -483,6 +483,9 @@ msgstr "" msgid "Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication" msgstr "" +msgid "An SSH key will be automatically generated when the form is submitted. For more information, please refer to the documentation." +msgstr "" + msgid "An application called %{link_to_client} is requesting access to your GitLab account." msgstr "" @@ -531,6 +534,9 @@ msgstr "" msgid "An error occurred when toggling the notification subscription" msgstr "" +msgid "An error occurred while detecting host keys" +msgstr "" + msgid "An error occurred while dismissing the alert. Refresh the page and try again." msgstr "" @@ -645,6 +651,9 @@ msgstr "" msgid "Are you sure you want to lose unsaved changes?" msgstr "" +msgid "Are you sure you want to regenerate the public key? You will have to update the public key on the remote server before mirroring will work again." +msgstr "" + msgid "Are you sure you want to remove %{group_name}?" msgstr "" @@ -1992,6 +2001,12 @@ msgstr "" msgid "Copy SSH clone URL" msgstr "" +msgid "Copy SSH public key" +msgstr "" + +msgid "Copy SSH public key to clipboard" +msgstr "" + msgid "Copy URL to clipboard" msgstr "" @@ -2393,6 +2408,9 @@ msgstr "" msgid "Details" msgstr "" +msgid "Detect host keys" +msgstr "" + msgid "Diff content limits" msgstr "" @@ -2870,6 +2888,9 @@ msgstr "" msgid "Find the newly extracted Takeout/Google Code Project Hosting/GoogleCodeProjectHosting.json file." msgstr "" +msgid "Fingerprints" +msgstr "" + msgid "Finished" msgstr "" @@ -3215,6 +3236,12 @@ msgstr "" msgid "Help page text and support page url." msgstr "" +msgid "Here is the public SSH key that needs to be added to the remote server. For more information, please refer to the documentation." +msgstr "" + +msgid "Hide host keys manual input" +msgstr "" + msgid "Hide payload" msgstr "" @@ -3379,6 +3406,9 @@ msgstr "" msgid "Inline" msgstr "" +msgid "Input host keys manually" +msgstr "" + msgid "Input your repository URL" msgstr "" @@ -5150,6 +5180,9 @@ msgid_plural "Refreshing in %d seconds to show the updated status..." msgstr[0] "" msgstr[1] "" +msgid "Regenerate key" +msgstr "" + msgid "Regex pattern" msgstr "" @@ -5401,6 +5434,12 @@ msgstr "" msgid "SSH Keys" msgstr "" +msgid "SSH host keys" +msgstr "" + +msgid "SSH public key" +msgstr "" + msgid "SSL Verification" msgstr "" @@ -7281,6 +7320,9 @@ msgstr "" msgid "Your projects" msgstr "" +msgid "a deleted user" +msgstr "" + msgid "ago" msgstr "" diff --git a/spec/controllers/projects/mirrors_controller_spec.rb b/spec/controllers/projects/mirrors_controller_spec.rb index 00c1e617e3a..976f480930c 100644 --- a/spec/controllers/projects/mirrors_controller_spec.rb +++ b/spec/controllers/projects/mirrors_controller_spec.rb @@ -15,6 +15,31 @@ describe Projects::MirrorsController do end.to change { RemoteMirror.count }.to(1) end end + + context 'setting up SSH public-key authentication' do + let(:ssh_mirror_attributes) do + { + 'auth_method' => 'ssh_public_key', + 'url' => 'ssh://git@example.com', + 'ssh_known_hosts' => 'test' + } + end + + it 'processes a successful update' do + sign_in(project.owner) + do_put(project, remote_mirrors_attributes: { '0' => ssh_mirror_attributes }) + + expect(response).to redirect_to(project_settings_repository_path(project, anchor: 'js-push-remote-settings')) + + expect(RemoteMirror.count).to eq(1) + expect(RemoteMirror.first).to have_attributes( + auth_method: 'ssh_public_key', + url: 'ssh://git@example.com', + ssh_public_key: match(/\Assh-rsa /), + ssh_known_hosts: 'test' + ) + end + end end describe '#update' do diff --git a/spec/features/projects/settings/repository_settings_spec.rb b/spec/features/projects/settings/repository_settings_spec.rb index 377a75cbcb3..401aac9478d 100644 --- a/spec/features/projects/settings/repository_settings_spec.rb +++ b/spec/features/projects/settings/repository_settings_spec.rb @@ -132,6 +132,27 @@ describe 'Projects > Settings > Repository settings' do it 'shows push mirror settings', :js do expect(page).to have_selector('#mirror_direction') end + + it 'generates an SSH public key on submission', :js do + fill_in 'url', with: 'ssh://user@localhost/project.git' + select 'SSH public key', from: 'Authentication method' + + direction_select = find('#mirror_direction') + + # In CE, this select box is disabled, but in EE, it is enabled + if direction_select.disabled? + expect(direction_select.value).to eq('push') + else + direction_select.select('Push') + end + + Sidekiq::Testing.fake! do + click_button 'Mirror repository' + end + + expect(page).to have_content('Mirroring settings were successfully updated') + expect(page).to have_selector('[title="Copy SSH public key"]') + end end end end diff --git a/spec/lib/gitlab/git/remote_mirror_spec.rb b/spec/lib/gitlab/git/remote_mirror_spec.rb new file mode 100644 index 00000000000..dc63eef7814 --- /dev/null +++ b/spec/lib/gitlab/git/remote_mirror_spec.rb @@ -0,0 +1,28 @@ +require 'spec_helper' + +describe Gitlab::Git::RemoteMirror do + describe '#update' do + let(:project) { create(:project, :repository) } + let(:repository) { project.repository } + let(:ref_name) { 'foo' } + let(:options) { { only_branches_matching: ['master'], ssh_key: 'KEY', known_hosts: 'KNOWN HOSTS' } } + + subject(:remote_mirror) { described_class.new(repository, ref_name, **options) } + + it 'delegates to the Gitaly client' do + expect(repository.gitaly_remote_client) + .to receive(:update_remote_mirror) + .with(ref_name, ['master'], ssh_key: 'KEY', known_hosts: 'KNOWN HOSTS') + + remote_mirror.update + end + + it 'wraps gitaly errors' do + expect(repository.gitaly_remote_client) + .to receive(:update_remote_mirror) + .and_raise(StandardError) + + expect { remote_mirror.update }.to raise_error(StandardError) + end + end +end diff --git a/spec/lib/gitlab/gitaly_client/remote_service_spec.rb b/spec/lib/gitlab/gitaly_client/remote_service_spec.rb index 9030a49983d..aff47599ad6 100644 --- a/spec/lib/gitlab/gitaly_client/remote_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/remote_service_spec.rb @@ -68,6 +68,8 @@ describe Gitlab::GitalyClient::RemoteService do describe '#update_remote_mirror' do let(:ref_name) { 'remote_mirror_1' } let(:only_branches_matching) { ['my-branch', 'master'] } + let(:ssh_key) { 'KEY' } + let(:known_hosts) { 'KNOWN HOSTS' } it 'sends an update_remote_mirror message' do expect_any_instance_of(Gitaly::RemoteService::Stub) @@ -75,7 +77,7 @@ describe Gitlab::GitalyClient::RemoteService do .with(kind_of(Enumerator), kind_of(Hash)) .and_return(double(:update_remote_mirror_response)) - client.update_remote_mirror(ref_name, only_branches_matching) + client.update_remote_mirror(ref_name, only_branches_matching, ssh_key: ssh_key, known_hosts: known_hosts) end end diff --git a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb index d605fcbafee..46ca2340389 100644 --- a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb @@ -130,7 +130,7 @@ describe Gitlab::GitalyClient::RepositoryService do end context 'SSH auth' do - where(:ssh_import, :ssh_key_auth, :ssh_private_key, :ssh_known_hosts, :expected_params) do + where(:ssh_mirror_url, :ssh_key_auth, :ssh_private_key, :ssh_known_hosts, :expected_params) do false | false | 'key' | 'known_hosts' | {} false | true | 'key' | 'known_hosts' | {} true | false | 'key' | 'known_hosts' | { known_hosts: 'known_hosts' } @@ -145,7 +145,7 @@ describe Gitlab::GitalyClient::RepositoryService do let(:ssh_auth) do double( :ssh_auth, - ssh_import?: ssh_import, + ssh_mirror_url?: ssh_mirror_url, ssh_key_auth?: ssh_key_auth, ssh_private_key: ssh_private_key, ssh_known_hosts: ssh_known_hosts diff --git a/spec/models/remote_mirror_spec.rb b/spec/models/remote_mirror_spec.rb index 3d316fb3c5b..da61a5f2771 100644 --- a/spec/models/remote_mirror_spec.rb +++ b/spec/models/remote_mirror_spec.rb @@ -222,14 +222,26 @@ describe RemoteMirror do context '#ensure_remote!' do let(:remote_mirror) { create(:project, :repository, :remote_mirror).remote_mirrors.first } + let(:project) { remote_mirror.project } + let(:repository) { project.repository } it 'adds a remote multiple times with no errors' do - expect(remote_mirror.project.repository).to receive(:add_remote).with(remote_mirror.remote_name, remote_mirror.url).twice.and_call_original + expect(repository).to receive(:add_remote).with(remote_mirror.remote_name, remote_mirror.url).twice.and_call_original 2.times do remote_mirror.ensure_remote! end end + + context 'SSH public-key authentication' do + it 'omits the password from the URL' do + remote_mirror.update!(auth_method: 'ssh_public_key', url: 'ssh://git:pass@example.com') + + expect(repository).to receive(:add_remote).with(remote_mirror.remote_name, 'ssh://git@example.com') + + remote_mirror.ensure_remote! + end + end end context '#updated_since?' do diff --git a/spec/serializers/project_mirror_entity_spec.rb b/spec/serializers/project_mirror_entity_spec.rb new file mode 100644 index 00000000000..ad0a8bbdff0 --- /dev/null +++ b/spec/serializers/project_mirror_entity_spec.rb @@ -0,0 +1,12 @@ +require 'spec_helper' + +describe ProjectMirrorEntity do + let(:project) { create(:project, :repository, :remote_mirror) } + let(:entity) { described_class.new(project) } + + subject { entity.as_json } + + it 'exposes project-specific elements' do + is_expected.to include(:id, :remote_mirrors_attributes) + end +end diff --git a/spec/serializers/remote_mirror_entity_spec.rb b/spec/serializers/remote_mirror_entity_spec.rb new file mode 100644 index 00000000000..885b0b9b423 --- /dev/null +++ b/spec/serializers/remote_mirror_entity_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +describe RemoteMirrorEntity do + let(:project) { create(:project, :repository, :remote_mirror) } + let(:remote_mirror) { project.remote_mirrors.first } + let(:entity) { described_class.new(remote_mirror) } + + subject { entity.as_json } + + it 'exposes remote-mirror-specific elements' do + is_expected.to include( + :id, :url, :enabled, :auth_method, + :ssh_known_hosts, :ssh_public_key, :ssh_known_hosts_fingerprints + ) + end +end diff --git a/spec/services/projects/update_remote_mirror_service_spec.rb b/spec/services/projects/update_remote_mirror_service_spec.rb index cd903bfe8a5..c1e5f788146 100644 --- a/spec/services/projects/update_remote_mirror_service_spec.rb +++ b/spec/services/projects/update_remote_mirror_service_spec.rb @@ -16,7 +16,7 @@ describe Projects::UpdateRemoteMirrorService do end it "ensures the remote exists" do - stub_fetch_remote(project, remote_name: remote_name) + stub_fetch_remote(project, remote_name: remote_name, ssh_auth: remote_mirror) expect(remote_mirror).to receive(:ensure_remote!) @@ -26,13 +26,13 @@ describe Projects::UpdateRemoteMirrorService do it "fetches the remote repository" do expect(project.repository) .to receive(:fetch_remote) - .with(remote_mirror.remote_name, no_tags: true) + .with(remote_mirror.remote_name, no_tags: true, ssh_auth: remote_mirror) service.execute(remote_mirror) end it "returns success when updated succeeds" do - stub_fetch_remote(project, remote_name: remote_name) + stub_fetch_remote(project, remote_name: remote_name, ssh_auth: remote_mirror) result = service.execute(remote_mirror) @@ -41,7 +41,7 @@ describe Projects::UpdateRemoteMirrorService do context 'when syncing all branches' do it "push all the branches the first time" do - stub_fetch_remote(project, remote_name: remote_name) + stub_fetch_remote(project, remote_name: remote_name, ssh_auth: remote_mirror) expect(remote_mirror).to receive(:update_repository).with({}) @@ -51,7 +51,7 @@ describe Projects::UpdateRemoteMirrorService do context 'when only syncing protected branches' do it "sync updated protected branches" do - stub_fetch_remote(project, remote_name: remote_name) + stub_fetch_remote(project, remote_name: remote_name, ssh_auth: remote_mirror) protected_branch = create_protected_branch(project) remote_mirror.only_protected_branches = true @@ -69,10 +69,10 @@ describe Projects::UpdateRemoteMirrorService do end end - def stub_fetch_remote(project, remote_name:) + def stub_fetch_remote(project, remote_name:, ssh_auth:) allow(project.repository) .to receive(:fetch_remote) - .with(remote_name, no_tags: true) { fetch_remote(project.repository, remote_name) } + .with(remote_name, no_tags: true, ssh_auth: ssh_auth) { fetch_remote(project.repository, remote_name) } end def fetch_remote(repository, remote_name)