Commit 9fe85710 authored by Robert Speicher's avatar Robert Speicher

Merge branch '49565-ssh-push-mirroring' into 'master'

SSH public-key authentication for push mirroring

Closes #49565

See merge request gitlab-org/gitlab-ce!22982
parents 2ea250d4 f1bc7b6e
export default {
PASSWORD: 'password',
SSH: 'ssh_public_key',
};
...@@ -3,10 +3,12 @@ import _ from 'underscore'; ...@@ -3,10 +3,12 @@ import _ from 'underscore';
import { __ } from '~/locale'; import { __ } from '~/locale';
import Flash from '~/flash'; import Flash from '~/flash';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import SSHMirror from './ssh_mirror';
export default class MirrorRepos { export default class MirrorRepos {
constructor(container) { constructor(container) {
this.$container = $(container); this.$container = $(container);
this.$password = null;
this.$form = $('.js-mirror-form', this.$container); this.$form = $('.js-mirror-form', this.$container);
this.$urlInput = $('.js-mirror-url', this.$form); this.$urlInput = $('.js-mirror-url', this.$form);
this.$protectedBranchesInput = $('.js-mirror-protected', this.$form); this.$protectedBranchesInput = $('.js-mirror-protected', this.$form);
...@@ -26,6 +28,18 @@ export default class MirrorRepos { ...@@ -26,6 +28,18 @@ export default class MirrorRepos {
this.$authMethod.on('change', () => this.togglePassword()); this.$authMethod.on('change', () => this.togglePassword());
this.$password.on('input.updateUrl', () => this.debouncedUpdateUrl()); 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() { updateUrl() {
......
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 += `<code>${escFingerprints}</code>`;
});
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');
}
}
import initForm from '../form'; import initForm from '../form';
import MirrorRepos from './mirror_repos'; import MirrorRepos from '~/mirrors/mirror_repos';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initForm(); initForm();
......
...@@ -1223,3 +1223,27 @@ pre.light-well { ...@@ -1223,3 +1223,27 @@ pre.light-well {
opacity: 1; 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;
}
}
}
}
...@@ -77,6 +77,10 @@ class Projects::MirrorsController < Projects::ApplicationController ...@@ -77,6 +77,10 @@ class Projects::MirrorsController < Projects::ApplicationController
id id
enabled enabled
only_protected_branches only_protected_branches
auth_method
password
ssh_known_hosts
regenerate_ssh_private_key
] ]
] ]
end end
......
...@@ -2,6 +2,9 @@ ...@@ -2,6 +2,9 @@
module MirrorHelper module MirrorHelper
def mirrors_form_data_attributes 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
end end
# 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
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
class RemoteMirror < ActiveRecord::Base class RemoteMirror < ActiveRecord::Base
include AfterCommitQueue include AfterCommitQueue
include MirrorAuthentication
PROTECTED_BACKOFF_DELAY = 1.minute PROTECTED_BACKOFF_DELAY = 1.minute
UNPROTECTED_BACKOFF_DELAY = 5.minutes UNPROTECTED_BACKOFF_DELAY = 5.minutes
...@@ -28,6 +29,8 @@ class RemoteMirror < ActiveRecord::Base ...@@ -28,6 +29,8 @@ class RemoteMirror < ActiveRecord::Base
after_commit :remove_remote, on: :destroy after_commit :remove_remote, on: :destroy
before_validation :store_credentials
scope :enabled, -> { where(enabled: true) } scope :enabled, -> { where(enabled: true) }
scope :started, -> { with_update_status(:started) } 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) } 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 ...@@ -84,7 +87,21 @@ class RemoteMirror < ActiveRecord::Base
end end
def update_repository(options) 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 end
def sync? def sync?
...@@ -128,7 +145,8 @@ class RemoteMirror < ActiveRecord::Base ...@@ -128,7 +145,8 @@ class RemoteMirror < ActiveRecord::Base
super(value) && return unless Gitlab::UrlSanitizer.valid?(value) super(value) && return unless Gitlab::UrlSanitizer.valid?(value)
mirror_url = Gitlab::UrlSanitizer.new(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) super(mirror_url.sanitized_url)
end end
...@@ -152,17 +170,28 @@ class RemoteMirror < ActiveRecord::Base ...@@ -152,17 +170,28 @@ class RemoteMirror < ActiveRecord::Base
def ensure_remote! def ensure_remote!
return unless project 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 # If this fails or the remote already exists, we won't know due to
# https://gitlab.com/gitlab-org/gitaly/issues/1317 # https://gitlab.com/gitlab-org/gitaly/issues/1317
project.repository.add_remote(remote_name, url) project.repository.add_remote(remote_name, remote_url)
end end
private private
def raw def store_credentials
@raw ||= Gitlab::Git::RemoteMirror.new(project.repository.raw, remote_name) # 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 end
def fallback_remote_name def fallback_remote_name
...@@ -214,7 +243,7 @@ class RemoteMirror < ActiveRecord::Base ...@@ -214,7 +243,7 @@ class RemoteMirror < ActiveRecord::Base
project.repository.async_remove_remote(prev_remote_name) project.repository.async_remove_remote(prev_remote_name)
end end
project.repository.add_remote(remote_name, url) project.repository.add_remote(remote_name, remote_url)
end end
def remove_remote def remove_remote
...@@ -224,6 +253,6 @@ class RemoteMirror < ActiveRecord::Base ...@@ -224,6 +253,6 @@ class RemoteMirror < ActiveRecord::Base
end end
def mirror_url_changed? def mirror_url_changed?
url_changed? || encrypted_credentials_changed? url_changed? || credentials_changed?
end end
end end
...@@ -3,11 +3,7 @@ ...@@ -3,11 +3,7 @@
class ProjectMirrorEntity < Grape::Entity class ProjectMirrorEntity < Grape::Entity
expose :id expose :id
expose :remote_mirrors_attributes do |project| expose :remote_mirrors_attributes, using: RemoteMirrorEntity do |project|
next [] unless project.remote_mirrors.present? project.remote_mirrors
project.remote_mirrors.map do |remote|
remote.as_json(only: %i[id url enabled])
end
end end
end end
# 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
...@@ -11,7 +11,7 @@ module Projects ...@@ -11,7 +11,7 @@ module Projects
begin begin
remote_mirror.ensure_remote! 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 = {} opts = {}
if remote_mirror.only_protected_branches? if remote_mirror.only_protected_branches?
......
- 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'
...@@ -59,5 +59,7 @@ ...@@ -59,5 +59,7 @@
.badge.mirror-error-badge{ data: { toggle: 'tooltip', html: 'true' }, title: html_escape(mirror.last_error.try(:strip)) }= _('Error') .badge.mirror-error-badge{ data: { toggle: 'tooltip', html: 'true' }, title: html_escape(mirror.last_error.try(:strip)) }= _('Error')
%td.mirror-action-buttons %td.mirror-action-buttons
.btn-group.mirror-actions-group.pull-right{ role: 'group' } .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 = 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') %button.js-delete-mirror.btn.btn-danger{ type: 'button', data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' }, title: _('Remove') }= icon('trash-o')
- protocols = Gitlab::UrlSanitizer::ALLOWED_SCHEMES.join('|')
.form-group .form-group
= label_tag :mirror_direction, _('Mirror direction'), class: 'label-light' = 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 = 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| = render partial: "projects/mirrors/mirror_repos_push", locals: { f: 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'
- 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 }
.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 } &times;
.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'
= render 'projects/mirrors/mirror_repos'
- 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'
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render "projects/default_branch/show" = render "projects/default_branch/show"
= render "projects/mirrors/show" = render "projects/mirrors/mirror_repos"
-# Protected branches & tags use a lot of nested partials. -# Protected branches & tags use a lot of nested partials.
-# The shared parts of the views can be found in the `shared` directory. -# The shared parts of the views can be found in the `shared` directory.
......
---
title: Allow SSH public-key authentication for push mirroring
merge_request: 22982
author:
type: added
...@@ -135,23 +135,25 @@ If the mirror updates successfully, it will be enqueued once again with a small ...@@ -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 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. 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: SSH authentication is mutual:
- You have to prove to the server that you're allowed to access the repository. - 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. - 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 You provide your credentials as a password or public key. The server that the
resides on provides its credentials as a "host key", the fingerprint of which needs to be verified manually. 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: If you're mirroring over SSH (that is, using an `ssh://` URL), you can authenticate using:
- Password-based authentication, just as over HTTPS. - Password-based authentication, just as over HTTPS.
- Public key authentication. This is often more secure than password authentication, especially when - Public key authentication. This is often more secure than password authentication,
the source repository supports [Deploy Keys](../ssh/README.md#deploy-keys). especially when the other repository supports [Deploy Keys](../ssh/README.md#deploy-keys).
To get started: To get started:
...@@ -171,9 +173,9 @@ If you click the: ...@@ -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. - **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. - **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 Assuming you used the former, you now need to verify that the fingerprints are
and other code hosting sites publish their fingerprints in the open for you those you expect. GitLab.com and other code hosting sites publish their
to check: fingerprints in the open for you to check:
- [AWS CodeCommit](http://docs.aws.amazon.com/codecommit/latest/userguide/regions.html#regions-fingerprints) - [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) - [Bitbucket](https://confluence.atlassian.com/bitbucket/use-the-ssh-protocol-with-bitbucket-cloud-221449711.html#UsetheSSHprotocolwithBitbucketCloud-KnownhostorBitbucket%27spublickeyfingerprints)
...@@ -184,7 +186,8 @@ to check: ...@@ -184,7 +186,8 @@ to check:
- [SourceForge](https://sourceforge.net/p/forge/documentation/SSH%20Key%20Fingerprints/) - [SourceForge](https://sourceforge.net/p/forge/documentation/SSH%20Key%20Fingerprints/)
Other providers will vary. If you're running self-managed GitLab, or otherwise 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 ```sh
$ cat /etc/ssh/ssh_host*pub | ssh-keygen -E md5 -l -f - $ 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 - ...@@ -196,25 +199,27 @@ $ cat /etc/ssh/ssh_host*pub | ssh-keygen -E md5 -l -f -
NOTE: **Note:** NOTE: **Note:**
You may need to exclude `-E md5` for some older versions of SSH. 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 When mirroring the repository, GitLab will now check that at least one of the
host keys matches before connecting. This can prevent malicious code from being injected into your stored host keys matches before connecting. This can prevent malicious code from
mirror, or your password being stolen. being injected into your mirror, or your password being stolen.
### SSH public key authentication ### SSH public key authentication
To use SSH public key authentication, you'll also need to choose that option from the **Authentication method** To use SSH public key authentication, you'll also need to choose that option
dropdown. GitLab will generate a 4096-bit RSA key and display the public component of that key to you. 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). - If the other repository is hosted on GitLab, you should add the public SSH key
- The source is hosted elsewhere, you may need to add the key to your user's `authorized_keys` file. as a [Deploy Key](../ssh/README.md#deploy-keys).
Paste the entire public SSH key into the file on its own line and save it. - 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 If you need to change the key at any time, you can remove and re-add the mirror
your mirror will begin working. to generate a new key. You'll have to update the other repository with the new
key to keep the mirror running.
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.
### Overwrite diverged branches **[STARTER]** ### Overwrite diverged branches **[STARTER]**
......
...@@ -5,14 +5,24 @@ module Gitlab ...@@ -5,14 +5,24 @@ module Gitlab
class RemoteMirror class RemoteMirror
include Gitlab::Git::WrapsGitalyErrors 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 @repository = repository
@ref_name = ref_name @ref_name = ref_name
@only_branches_matching = only_branches_matching
@ssh_key = ssh_key
@known_hosts = known_hosts
end end
def update(only_branches_matching: []) def update
wrapped_gitaly_errors do 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 end
end end
......
...@@ -68,13 +68,18 @@ module Gitlab ...@@ -68,13 +68,18 @@ module Gitlab
encode_utf8(response.ref) encode_utf8(response.ref)
end 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| req_enum = Enumerator.new do |y|
y.yield Gitaly::UpdateRemoteMirrorRequest.new( first_request = Gitaly::UpdateRemoteMirrorRequest.new(
repository: @gitaly_repo, repository: @gitaly_repo,
ref_name: ref_name 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 current_size = 0
slices = only_branches_matching.slice_before do |branch_name| slices = only_branches_matching.slice_before do |branch_name|
......
...@@ -69,7 +69,7 @@ module Gitlab ...@@ -69,7 +69,7 @@ module Gitlab
no_tags: no_tags, timeout: timeout, no_prune: !prune 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? if ssh_auth.ssh_key_auth? && ssh_auth.ssh_private_key.present?
request.ssh_key = ssh_auth.ssh_private_key request.ssh_key = ssh_auth.ssh_private_key
end end
......
...@@ -483,6 +483,9 @@ msgstr "" ...@@ -483,6 +483,9 @@ msgstr ""
msgid "Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication" msgid "Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication"
msgstr "" 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." msgid "An application called %{link_to_client} is requesting access to your GitLab account."
msgstr "" msgstr ""
...@@ -531,6 +534,9 @@ msgstr "" ...@@ -531,6 +534,9 @@ msgstr ""
msgid "An error occurred when toggling the notification subscription" msgid "An error occurred when toggling the notification subscription"
msgstr "" msgstr ""
msgid "An error occurred while detecting host keys"
msgstr ""
msgid "An error occurred while dismissing the alert. Refresh the page and try again." msgid "An error occurred while dismissing the alert. Refresh the page and try again."
msgstr "" msgstr ""
...@@ -645,6 +651,9 @@ msgstr "" ...@@ -645,6 +651,9 @@ msgstr ""
msgid "Are you sure you want to lose unsaved changes?" msgid "Are you sure you want to lose unsaved changes?"
msgstr "" 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}?" msgid "Are you sure you want to remove %{group_name}?"
msgstr "" msgstr ""
...@@ -2001,6 +2010,12 @@ msgstr "" ...@@ -2001,6 +2010,12 @@ msgstr ""
msgid "Copy SSH clone URL" msgid "Copy SSH clone URL"
msgstr "" msgstr ""
msgid "Copy SSH public key"
msgstr ""
msgid "Copy SSH public key to clipboard"
msgstr ""
msgid "Copy URL to clipboard" msgid "Copy URL to clipboard"
msgstr "" msgstr ""
...@@ -2405,6 +2420,9 @@ msgstr "" ...@@ -2405,6 +2420,9 @@ msgstr ""
msgid "Details" msgid "Details"
msgstr "" msgstr ""
msgid "Detect host keys"
msgstr ""
msgid "Diff content limits" msgid "Diff content limits"
msgstr "" msgstr ""
...@@ -2906,6 +2924,9 @@ msgstr "" ...@@ -2906,6 +2924,9 @@ msgstr ""
msgid "Find the newly extracted <code>Takeout/Google Code Project Hosting/GoogleCodeProjectHosting.json</code> file." msgid "Find the newly extracted <code>Takeout/Google Code Project Hosting/GoogleCodeProjectHosting.json</code> file."
msgstr "" msgstr ""
msgid "Fingerprints"
msgstr ""
msgid "Finished" msgid "Finished"
msgstr "" msgstr ""
...@@ -3251,6 +3272,12 @@ msgstr "" ...@@ -3251,6 +3272,12 @@ msgstr ""
msgid "Help page text and support page url." msgid "Help page text and support page url."
msgstr "" 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" msgid "Hide payload"
msgstr "" msgstr ""
...@@ -3415,6 +3442,9 @@ msgstr "" ...@@ -3415,6 +3442,9 @@ msgstr ""
msgid "Inline" msgid "Inline"
msgstr "" msgstr ""
msgid "Input host keys manually"
msgstr ""
msgid "Input your repository URL" msgid "Input your repository URL"
msgstr "" msgstr ""
...@@ -5204,6 +5234,9 @@ msgid_plural "Refreshing in %d seconds to show the updated status..." ...@@ -5204,6 +5234,9 @@ msgid_plural "Refreshing in %d seconds to show the updated status..."
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "Regenerate key"
msgstr ""
msgid "Regex pattern" msgid "Regex pattern"
msgstr "" msgstr ""
...@@ -5458,6 +5491,12 @@ msgstr "" ...@@ -5458,6 +5491,12 @@ msgstr ""
msgid "SSH Keys" msgid "SSH Keys"
msgstr "" msgstr ""
msgid "SSH host keys"
msgstr ""
msgid "SSH public key"
msgstr ""
msgid "SSL Verification" msgid "SSL Verification"
msgstr "" msgstr ""
...@@ -7383,6 +7422,9 @@ msgstr "" ...@@ -7383,6 +7422,9 @@ msgstr ""
msgid "Your projects" msgid "Your projects"
msgstr "" msgstr ""
msgid "a deleted user"
msgstr ""
msgid "ago" msgid "ago"
msgstr "" msgstr ""
......
...@@ -15,6 +15,31 @@ describe Projects::MirrorsController do ...@@ -15,6 +15,31 @@ describe Projects::MirrorsController do
end.to change { RemoteMirror.count }.to(1) end.to change { RemoteMirror.count }.to(1)
end end
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 end
describe '#update' do describe '#update' do
......
...@@ -132,6 +132,27 @@ describe 'Projects > Settings > Repository settings' do ...@@ -132,6 +132,27 @@ describe 'Projects > Settings > Repository settings' do
it 'shows push mirror settings', :js do it 'shows push mirror settings', :js do
expect(page).to have_selector('#mirror_direction') expect(page).to have_selector('#mirror_direction')
end 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 end
end end
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
...@@ -68,6 +68,8 @@ describe Gitlab::GitalyClient::RemoteService do ...@@ -68,6 +68,8 @@ describe Gitlab::GitalyClient::RemoteService do
describe '#update_remote_mirror' do describe '#update_remote_mirror' do
let(:ref_name) { 'remote_mirror_1' } let(:ref_name) { 'remote_mirror_1' }
let(:only_branches_matching) { ['my-branch', 'master'] } let(:only_branches_matching) { ['my-branch', 'master'] }
let(:ssh_key) { 'KEY' }
let(:known_hosts) { 'KNOWN HOSTS' }
it 'sends an update_remote_mirror message' do it 'sends an update_remote_mirror message' do
expect_any_instance_of(Gitaly::RemoteService::Stub) expect_any_instance_of(Gitaly::RemoteService::Stub)
...@@ -75,7 +77,7 @@ describe Gitlab::GitalyClient::RemoteService do ...@@ -75,7 +77,7 @@ describe Gitlab::GitalyClient::RemoteService do
.with(kind_of(Enumerator), kind_of(Hash)) .with(kind_of(Enumerator), kind_of(Hash))
.and_return(double(:update_remote_mirror_response)) .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
end end
......
...@@ -130,7 +130,7 @@ describe Gitlab::GitalyClient::RepositoryService do ...@@ -130,7 +130,7 @@ describe Gitlab::GitalyClient::RepositoryService do
end end
context 'SSH auth' do 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 | false | 'key' | 'known_hosts' | {}
false | true | 'key' | 'known_hosts' | {} false | true | 'key' | 'known_hosts' | {}
true | false | 'key' | 'known_hosts' | { known_hosts: 'known_hosts' } true | false | 'key' | 'known_hosts' | { known_hosts: 'known_hosts' }
...@@ -145,7 +145,7 @@ describe Gitlab::GitalyClient::RepositoryService do ...@@ -145,7 +145,7 @@ describe Gitlab::GitalyClient::RepositoryService do
let(:ssh_auth) do let(:ssh_auth) do
double( double(
:ssh_auth, :ssh_auth,
ssh_import?: ssh_import, ssh_mirror_url?: ssh_mirror_url,
ssh_key_auth?: ssh_key_auth, ssh_key_auth?: ssh_key_auth,
ssh_private_key: ssh_private_key, ssh_private_key: ssh_private_key,
ssh_known_hosts: ssh_known_hosts ssh_known_hosts: ssh_known_hosts
......
...@@ -222,14 +222,26 @@ describe RemoteMirror do ...@@ -222,14 +222,26 @@ describe RemoteMirror do
context '#ensure_remote!' do context '#ensure_remote!' do
let(:remote_mirror) { create(:project, :repository, :remote_mirror).remote_mirrors.first } 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 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 2.times do
remote_mirror.ensure_remote! remote_mirror.ensure_remote!
end end
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 end
context '#updated_since?' do context '#updated_since?' do
......
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
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
...@@ -16,7 +16,7 @@ describe Projects::UpdateRemoteMirrorService do ...@@ -16,7 +16,7 @@ describe Projects::UpdateRemoteMirrorService do
end end
it "ensures the remote exists" do 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!) expect(remote_mirror).to receive(:ensure_remote!)
...@@ -26,13 +26,13 @@ describe Projects::UpdateRemoteMirrorService do ...@@ -26,13 +26,13 @@ describe Projects::UpdateRemoteMirrorService do
it "fetches the remote repository" do it "fetches the remote repository" do
expect(project.repository) expect(project.repository)
.to receive(:fetch_remote) .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) service.execute(remote_mirror)
end end
it "returns success when updated succeeds" do 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) result = service.execute(remote_mirror)
...@@ -41,7 +41,7 @@ describe Projects::UpdateRemoteMirrorService do ...@@ -41,7 +41,7 @@ describe Projects::UpdateRemoteMirrorService do
context 'when syncing all branches' do context 'when syncing all branches' do
it "push all the branches the first time" 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({}) expect(remote_mirror).to receive(:update_repository).with({})
...@@ -51,7 +51,7 @@ describe Projects::UpdateRemoteMirrorService do ...@@ -51,7 +51,7 @@ describe Projects::UpdateRemoteMirrorService do
context 'when only syncing protected branches' do context 'when only syncing protected branches' do
it "sync updated 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) protected_branch = create_protected_branch(project)
remote_mirror.only_protected_branches = true remote_mirror.only_protected_branches = true
...@@ -69,10 +69,10 @@ describe Projects::UpdateRemoteMirrorService do ...@@ -69,10 +69,10 @@ describe Projects::UpdateRemoteMirrorService do
end end
end end
def stub_fetch_remote(project, remote_name:) def stub_fetch_remote(project, remote_name:, ssh_auth:)
allow(project.repository) allow(project.repository)
.to receive(:fetch_remote) .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 end
def fetch_remote(repository, remote_name) def fetch_remote(repository, remote_name)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment