Commit fd467491 authored by kushalpandya's avatar kushalpandya Committed by Nick Thomas

Frontend for configuring pull mirroring with SSH authentication

parent f885f8cb
export default {
PASSWORD: 'password',
SSH: 'ssh_public_key',
};
import MirrorPull from './mirror_pull';
document.addEventListener('DOMContentLoaded', () => {
const mirrorPull = new MirrorPull('.js-project-mirror-push-form');
mirrorPull.init();
});
/* global Flash */
import AUTH_METHOD from './constants';
export default class MirrorPull {
constructor(formSelector) {
this.backOffRequestCounter = 0;
this.$form = $(formSelector);
this.$repositoryUrl = this.$form.find('.js-repo-url');
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-pull-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');
}
init() {
this.toggleAuthWell(this.$dropdownAuthType.val());
this.$repositoryUrl.on('keyup', e => this.handleRepositoryUrlInput(e));
this.$form.find('.js-known-hosts').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));
}
/**
* Method to monitor Git Repository URL input
*/
handleRepositoryUrlInput() {
const protocol = this.$repositoryUrl.val().split('://')[0];
const protRegEx = /http|git/;
// Validate URL and verify if it consists only supported protocols
if (this.$form.get(0).checkValidity()) {
// Hide/Show SSH Host keys section only for SSH URLs
this.$sectionSSHHostKeys.toggleClass('hidden', protocol !== 'ssh');
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
this.$dropdownAuthType.toggleClass('hidden', protRegEx.test(protocol));
}
}
/**
* Click event handler to detect SSH Host key and fingerprints from
* provided Git Repository URL.
*/
handleDetectHostKeys() {
const projectMirrorSSHEndpoint = this.$form.data('project-mirror-endpoint');
const repositoryUrl = this.$repositoryUrl.val();
const $btnLoadSpinner = this.$btnDetectHostKeys.find('.detect-host-keys-load-spinner');
// Disable button while we make request
this.$btnDetectHostKeys.disable();
$btnLoadSpinner.removeClass('hidden');
// Make backOff polling to get data
gl.utils.backOff((next, stop) => {
$.getJSON(`${projectMirrorSSHEndpoint}?ssh_url=${repositoryUrl}`)
.done((res, statusText, header) => {
if (header.status === 204) {
this.backOffRequestCounter = this.backOffRequestCounter += 1;
if (this.backOffRequestCounter < 3) {
next();
} else {
stop(res);
}
} else {
stop(res);
}
})
.fail(stop);
})
.then((res) => {
$btnLoadSpinner.addClass('hidden');
// Once data is received, we show verification info along with Host keys and fingerprints
this.$hostKeysInformation.find('.js-fingerprint-verification').toggleClass('hidden', res.changes_project_import_data);
if (res.known_hosts && res.fingerprints) {
this.showSSHInformation(res);
}
})
.catch((res) => {
// Show failure message when there's an error and re-enable Detect host keys button
const failureMessage = res.responseJSON ? res.responseJSON.message : 'Something went wrong on our end.';
Flash(failureMessage); // eslint-disable-line
$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').addClass('hidden');
}
/**
* Click event handler for `Show advanced` button under SSH Host keys section
*/
handleSSHHostsAdvanced() {
const $knownHost = this.$sectionSSHHostKeys.find('.js-ssh-known-hosts');
$knownHost.toggleClass('hidden');
this.$btnSSHHostsShowAdvanced.toggleClass('show-advanced', $knownHost.hasClass('hidden'));
}
/**
* 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();
// Construct request body
const authTypeData = {
project: {
import_data_attributes: {
regenerate_ssh_private_key: true,
},
},
};
// Show load spinner and hide other containers
this.$wellAuthTypeChanging.removeClass('hidden');
this.$wellPasswordAuth.addClass('hidden');
this.$wellSSHAuth.addClass('hidden');
// 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()) {
this.$dropdownAuthType.disable();
$.ajax({
type: 'PUT',
url: projectMirrorAuthTypeEndpoint,
contentType: 'application/json; charset=utf-8',
data: JSON.stringify(authTypeData),
})
.done((res) => {
// Show SSH public key container and fill in public key
this.toggleAuthWell(selectedAuthType);
this.toggleSSHAuthWellMessage(true);
$sshPublicKey.text(res.import_data_attributes.ssh_public_key);
})
.fail(() => {
Flash('Something went wrong on our end.');
})
.always(() => {
this.$wellAuthTypeChanging.addClass('hidden');
this.$dropdownAuthType.enable();
});
} else {
this.$wellAuthTypeChanging.addClass('hidden');
this.toggleAuthWell(selectedAuthType);
this.$wellSSHAuth.find('.js-ssh-public-key-present').removeClass('hidden');
}
}
/**
* 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.removeClass('hidden');
$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.toggleClass('hidden', authType !== AUTH_METHOD.PASSWORD);
this.$wellSSHAuth.toggleClass('hidden', authType !== AUTH_METHOD.SSH);
}
/**
* Toggle SSH auth information message
*/
toggleSSHAuthWellMessage(sshKeyPresent) {
this.$sshPublicKeyWrap.toggleClass('hidden', !sshKeyPresent);
this.$wellSSHAuth.find('.js-ssh-public-key-present').toggleClass('hidden', !sshKeyPresent);
this.$wellSSHAuth.find('.js-btn-regenerate-ssh-key').toggleClass('hidden', !sshKeyPresent);
this.$wellSSHAuth.find('.js-ssh-public-key-pending').toggleClass('hidden', sshKeyPresent);
}
}
......@@ -990,3 +990,76 @@ a.allowed-to-push {
border-color: $border-color;
}
}
/* EE-specific styles */
.project-mirror-settings {
.fingerprint-verified {
color: $green-500;
}
.ssh-public-key,
.btn-copy-ssh-public-key {
float: left;
}
.ssh-public-key {
width: 95%;
word-wrap: break-word;
word-break: break-all;
}
.btn-copy-ssh-public-key {
margin-left: 5px;
}
.known-hosts {
font-family: $monospace_font;
}
.btn-show-advanced {
min-width: 135px;
.label-show {
display: none;
}
.label-hide {
display: inline;
}
.fa.fa-chevron::before {
content: "\f077";
}
&.show-advanced {
.label-show {
display: inline;
}
.label-hide {
display: none;
}
.fa.fa-chevron::before {
content: "\f078";
}
}
}
.fingerprints-list {
code {
display: block;
padding: 8px;
margin-bottom: 5px;
}
&.invalidate {
text-decoration: line-through;
}
}
.changing-auth-method {
display: flex;
justify-content: center;
}
}
.account-well.prepend-top-default.append-bottom-default
%ul
%li
The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>.
The repository must be accessible over <code>http://</code>, <code>https://</code>, <code>ssh://</code> or <code>git://</code>.
%li
If your HTTP repository is not publicly accessible, add authentication information to the URL: <code>https://username:password@gitlab.company.com/group/project.git</code>.
Include the username in the URL if required: <code>https://username@gitlab.company.com/group/project.git</code>.
%li
The update action will time out after 10 minutes. For big repositories, use a clone/push combination.
%li
......
- expanded = Rails.env.test?
- import_data = @project.import_data || @project.build_import_data
- protocols = AddressableUrlValidator::DEFAULT_OPTIONS[:protocols].join('|')
%section.settings.project-mirror-settings
.settings-header
%h4
......@@ -10,30 +13,26 @@
updated from an upstream repository.
= link_to 'Read more', help_page_path('workflow/repository_mirroring', anchor: 'pulling-from-a-remote-repository'), target: '_blank'
.settings-content.no-animate{ class: ('expanded' if expanded) }
= form_for @project, url: project_mirror_path(@project) do |f|
= form_for @project, url: project_mirror_path(@project), html: { class: 'gl-show-field-errors project-mirror-push-form js-project-mirror-push-form', autocomplete: 'false', data: { project_mirror_endpoint: ssh_host_keys_project_mirror_path(@project, :json) } } do |f|
%div
= form_errors(@project)
%h5
Set up mirror repository
= render "shared/mirror_update_button"
- if @project.mirror_last_update_failed?
.panel.panel-danger
.panel-heading
The repository failed to update #{time_ago_with_tooltip(@project.mirror_last_update_at)}.
- if @project.mirror_ever_updated_successfully?
Last successful update #{time_ago_with_tooltip(@project.mirror_last_successful_update_at)}.
.panel-body
%pre
:preserve
#{h(@project.import_error.try(:strip))}
= render "projects/mirrors/pull/mirror_update_fail"
.form-group
= f.check_box :mirror, class: "pull-left"
.prepend-left-20
= f.label :mirror, "Mirror repository", class: "label-light append-bottom-0"
.form-group
= f.label :import_url, "Git repository URL", class: "label-light"
= f.text_field :import_url, class: 'form-control', placeholder: 'https://username:password@gitlab.company.com/group/project.git'
= render "projects/mirrors/instructions"
= f.label :username_only_import_url, "Git repository URL", class: "label-light"
= f.text_field :username_only_import_url, class: 'form-control js-repo-url', placeholder: 'https://username@gitlab.company.com/group/project.git', required: 'required', pattern: "(#{protocols}):\/\/.+", title: 'URL must have protocol present (eg; ssh://...)'
= render "projects/mirrors/instructions"
= f.fields_for :import_data, import_data do |import_form|
= render partial: "projects/mirrors/pull/ssh_host_keys", locals: { f: import_form }
= render partial: "projects/mirrors/pull/authentication_method", locals: { f: import_form }
.form-group
= f.label :mirror_user_id, "Mirror user", class: "label-light"
= select_tag('project[mirror_user_id]', options_for_mirror_user, class: "select2 lg", required: true)
......
- content_for :page_specific_javascripts do
= webpack_bundle_tag 'mirrors'
- if @project.feature_available?(:repository_mirrors)
= render 'projects/mirrors/pull'
= render 'projects/mirrors/push'
- import_data = f.object
- regen_data = { auth_method: 'ssh_public_key', regenerate_ssh_private_key: true }
- ssh_key_auth = import_data.ssh_key_auth?
- ssh_public_key_present = import_data.ssh_public_key.present?
.form-group
= f.label :auth_method, 'Authentication method', class: 'label-light'
= f.select :auth_method,
options_for_select([['Password authentication', 'password'], ['SSH public key authentication', 'ssh_public_key']], import_data.auth_method),
{}, { class: "form-control js-pull-mirror-auth-type #{'hidden' unless import_data.ssh_import?}" }
.form-group
.account-well.changing-auth-method.hidden.js-well-changing-auth
= icon('spinner spin lg')
.account-well.well-password-auth.hidden.js-well-password-auth
= f.label :password, "Password", class: "label-light"
= f.password_field :password, value: import_data.password, class: 'form-control'
.account-well.well-ssh-auth.hidden.js-well-ssh-auth
%p.js-ssh-public-key-present{ class: ('hidden' 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: ('hidden' 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: ('hidden' unless ssh_public_key_present) }
%code.prepend-top-10.ssh-public-key
= import_data.ssh_public_key
= clipboard_button(text: import_data.ssh_public_key, title: _("Copy SSH public key to clipboard"), class: 'prepend-top-10 btn-copy-ssh-public-key')
= link_to 'Regenerate key', project_mirror_path(@project, project: { import_data_attributes: regen_data }),
method: :patch,
data: { confirm: 'Are you sure you want to regenerate public key? You will have to update the public key on the remote server before mirroring will work again.' },
class: "btn btn-inverted btn-warning prepend-top-10 js-btn-regenerate-ssh-key #{ 'hidden' unless ssh_public_key_present }"
- if @project.mirror_last_update_failed?
.panel.panel-danger
.panel-heading
The repository failed to update #{time_ago_with_tooltip(@project.mirror_last_update_at)}.
- if @project.mirror_ever_updated_successfully?
Last successful update #{time_ago_with_tooltip(@project.mirror_last_successful_update_at)}.
.panel-body
%pre
:preserve
#{h(@project.import_error.try(:strip))}
- import_data = f.object
- verified_by = import_data.ssh_known_hosts_verified_by
- verified_at = import_data.ssh_known_hosts_verified_at
.form-group.js-ssh-host-keys-section{ class: ('hidden' unless import_data.ssh_import?) }
%button.btn.btn-inverted.btn-success.append-bottom-15.js-detect-host-keys{ type: 'button' }
= icon('spinner spin', class: 'detect-host-keys-load-spinner hidden')
Detect host keys
.fingerprint-ssh-info.js-fingerprint-ssh-info{ class: ('hidden' unless import_data.ssh_import?) }
%label.label-light
Fingerprints
.fingerprints-list.js-fingerprints-list
- import_data.ssh_known_hosts_fingerprints.each do |fp|
%code= fp.fingerprint
- if verified_by || verified_at
.help-block.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
%button.btn.btn-sm.btn-default.prepend-top-10.append-bottom-15.btn-show-advanced.show-advanced{ type: 'button' }
%span.label-show
Show advanced
%span.label-hide
Hide advanced
= icon('chevron')
.js-ssh-known-hosts.hidden
= f.label :ssh_known_hosts, 'SSH host keys', class: 'label-light'
= f.text_area :ssh_known_hosts, class: 'form-control known-hosts js-known-hosts', rows: '10'
......@@ -54,6 +54,7 @@ var config = {
locale: './locale/index.js',
main: './main.js',
merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js',
mirrors: './mirrors',
monitoring: './monitoring/monitoring_bundle.js',
network: './network/network_bundle.js',
notebook_viewer: './blob/notebook_viewer.js',
......
......@@ -184,6 +184,18 @@ describe Projects::MirrorsController do
expect(import_data.ssh_known_hosts_verified_at).to be_nil
expect(import_data.ssh_known_hosts_verified_by).to be_nil
end
it 'only allows the current user to be the mirror user' do
mirror_user = project.mirror_user
other_user = create(:user)
project.add_master(other_user)
do_put(project, { mirror_user_id: other_user.id }, format: :json)
expect(response).to have_http_status(200)
expect(project.mirror_user(true)).to eq(mirror_user)
end
end
context 'HTML' do
......
......@@ -44,15 +44,5 @@ describe 'Project settings > [EE] repository' do
expect(page).to have_selector('#project_remote_mirrors_attributes_0_enabled')
expect(page).to have_selector('#project_remote_mirrors_attributes_0_url')
end
it 'sets mirror user' do
page.within('.project-mirror-settings') do
select2(user2.id, from: '#project_mirror_user_id')
click_button('Save changes')
expect(find('.select2-chosen')).to have_content(user.name)
end
end
end
end
require 'spec_helper'
feature 'Project mirror' do
feature 'Project mirror', js: true do
include ReactiveCachingHelpers
let(:project) { create(:project, :mirror, :import_finished, :repository, creator: user, name: 'Victorialand') }
let(:user) { create(:user) }
describe 'On a project', js: true do
describe 'On a project' do
before do
project.team << [user, :master]
project.add_master(user)
sign_in user
end
......@@ -59,4 +61,157 @@ feature 'Project mirror' do
end
end
end
describe 'configuration' do
# Start from a project with no mirroring set up
let(:project) { create(:project, :repository, creator: user) }
let(:import_data) { project.import_data(true) }
before do
project.add_master(user)
sign_in(user)
visit project_settings_repository_path(project)
end
describe 'password authentication' do
it 'can be set up' do
page.within('.project-mirror-settings') do
check 'Mirror repository'
fill_in 'Git repository URL', with: 'http://user@example.com'
fill_in 'Password', with: 'foo'
click_without_sidekiq 'Save changes'
end
expect(page).to have_content('Mirroring settings were successfully updated')
project.reload
expect(project.mirror?).to be_truthy
expect(import_data.auth_method).to eq('password')
expect(project.import_url).to eq('http://user:foo@example.com')
end
end
describe 'SSH public key authentication' do
it 'can be set up' do
page.within('.project-mirror-settings') do
check 'Mirror repository'
fill_in 'Git repository URL', with: 'ssh://user@example.com'
select 'SSH public key authentication', from: 'Authentication method'
# Generates an SSH public key with an asynchronous PUT and displays it
wait_for_requests
expect(import_data.ssh_public_key).not_to be_nil
expect(page).to have_content(import_data.ssh_public_key)
click_without_sidekiq 'Save changes'
end
# We didn't set any host keys
expect(page).to have_content('Mirroring settings were successfully updated')
expect(page).not_to have_content('Verified by')
project.reload
import_data.reload
expect(project.mirror?).to be_truthy
expect(project.username_only_import_url).to eq('ssh://user@example.com')
expect(import_data.auth_method).to eq('ssh_public_key')
expect(import_data.password).to be_blank
first_key = import_data.ssh_public_key
expect(page).to have_content(first_key)
# Check regenerating the public key works
click_without_sidekiq 'Regenerate key'
wait_for_requests
expect(page).not_to have_content(first_key)
expect(page).to have_content(import_data.reload.ssh_public_key)
end
end
describe 'host key management', use_clean_rails_memory_store_caching: true do
let(:key) { Gitlab::KeyFingerprint.new(SSHKeygen.generate) }
let(:cache) { SshHostKey.new(project: project, url: "ssh://example.com:22") }
it 'fills fingerprints and host keys when detecting' do
stub_reactive_cache(cache, known_hosts: key.key)
page.within('.project-mirror-settings') do
fill_in 'Git repository URL', with: 'ssh://example.com'
click_on 'Detect host keys'
wait_for_requests
expect(page).to have_content(key.fingerprint)
click_on 'Show advanced'
expect(page).to have_field('SSH host keys', with: key.key)
end
end
it 'displays error if detection fails' do
stub_reactive_cache(cache, error: 'Some error text here')
page.within('.project-mirror-settings') do
fill_in 'Git repository URL', with: 'ssh://example.com'
click_on 'Detect host keys'
wait_for_requests
end
# Appears in the flash
expect(page).to have_content('Some error text here')
end
it 'allows manual host keys entry' do
page.within('.project-mirror-settings') do
fill_in 'Git repository URL', with: 'ssh://example.com'
click_on 'Show advanced'
fill_in 'SSH host keys', with: "example.com #{key.key}"
click_without_sidekiq 'Save changes'
expect(page).to have_content(key.fingerprint)
expect(page).to have_content("Verified by #{h(user.name)} less than a minute ago")
end
end
end
describe 'authentication methods' do
it 'shows SSH related fields for an SSH URL' do
page.within('.project-mirror-settings') do
fill_in 'Git repository URL', with: 'ssh://example.com'
expect(page).to have_select('Authentication method')
# SSH can use password authentication but needs host keys
select 'Password authentication', from: 'Authentication method'
expect(page).to have_field('Password')
expect(page).to have_button('Detect host keys')
expect(page).to have_button('Show advanced')
# SSH public key authentication also needs host keys but no password
select 'SSH public key authentication', from: 'Authentication method'
expect(page).not_to have_field('Password')
expect(page).to have_button('Detect host keys')
expect(page).to have_button('Show advanced')
end
end
it 'hides SSH-related fields for a HTTP URL' do
page.within('.project-mirror-settings') do
fill_in 'Git repository URL', with: 'https://example.com'
# HTTPS can't use public key authentication and doesn't need host keys
expect(page).to have_field('Password')
expect(page).not_to have_select('Authentication method')
expect(page).not_to have_button('Detect host keys')
expect(page).not_to have_button('Show advanced')
end
end
end
def click_without_sidekiq(*args)
Sidekiq::Testing.fake! { click_on(*args) }
end
end
end
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