SSH public-key authentication for push mirroring
This commit is contained in:
parent
b1b4c94484
commit
f1bc7b6eb5
|
@ -0,0 +1,4 @@
|
|||
export default {
|
||||
PASSWORD: 'password',
|
||||
SSH: 'ssh_public_key',
|
||||
};
|
|
@ -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() {
|
|
@ -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 += `<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');
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import initForm from '../form';
|
||||
import MirrorRepos from './mirror_repos';
|
||||
import MirrorRepos from '~/mirrors/mirror_repos';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initForm();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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?
|
||||
|
|
|
@ -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'
|
|
@ -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')
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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 }
|
|
@ -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'
|
|
@ -1 +0,0 @@
|
|||
= render 'projects/mirrors/mirror_repos'
|
|
@ -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'
|
|
@ -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.
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
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
|
|||
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]**
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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|
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 <code>Takeout/Google Code Project Hosting/GoogleCodeProjectHosting.json</code> 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 ""
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue