Commit f1bc7b6e authored by Nick Thomas's avatar Nick Thomas

SSH public-key authentication for push mirroring

parent b1b4c944
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() {
......
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 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
# 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
# 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?
......
- 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')
- 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 }
- 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 @@
- @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.
......
---
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
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
......
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
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)
......
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