Commit 7559ccc7 authored by Tim Zallmann's avatar Tim Zallmann

Merge branch '98-pull-mirror-ssh-keys-2' into 'master'

Resolve "Pull repository mirroring: Support for SSH keys"

Closes #98

See merge request !2551
parents 5fa10409 6b867eb8
...@@ -405,6 +405,17 @@ gem 'sys-filesystem', '~> 1.1.6' ...@@ -405,6 +405,17 @@ gem 'sys-filesystem', '~> 1.1.6'
# NTP client # NTP client
gem 'net-ntp' gem 'net-ntp'
# SSH host key support
gem 'net-ssh', '~> 4.1.0'
gem 'sshkey', '~> 1.9.0'
# Required for ED25519 SSH host key support
group :ed25519 do
gem 'rbnacl-libsodium'
gem 'rbnacl', '~> 3.2'
gem 'bcrypt_pbkdf', '~> 1.0'
end
# Gitaly GRPC client # Gitaly GRPC client
gem 'gitaly', '~> 0.24.0' gem 'gitaly', '~> 0.24.0'
......
...@@ -83,6 +83,7 @@ GEM ...@@ -83,6 +83,7 @@ GEM
babosa (1.0.2) babosa (1.0.2)
base32 (0.3.2) base32 (0.3.2)
bcrypt (3.1.11) bcrypt (3.1.11)
bcrypt_pbkdf (1.0.0)
benchmark-ips (2.3.0) benchmark-ips (2.3.0)
better_errors (2.1.1) better_errors (2.1.1)
coderay (>= 1.0.0) coderay (>= 1.0.0)
...@@ -504,6 +505,7 @@ GEM ...@@ -504,6 +505,7 @@ GEM
mysql2 (0.4.5) mysql2 (0.4.5)
net-ldap (0.16.0) net-ldap (0.16.0)
net-ntp (2.1.3) net-ntp (2.1.3)
net-ssh (4.1.0)
netrc (0.11.0) netrc (0.11.0)
nokogiri (1.6.8.1) nokogiri (1.6.8.1)
mini_portile2 (~> 2.1.0) mini_portile2 (~> 2.1.0)
...@@ -691,6 +693,10 @@ GEM ...@@ -691,6 +693,10 @@ GEM
rake (12.0.0) rake (12.0.0)
rblineprof (0.3.6) rblineprof (0.3.6)
debugger-ruby_core_source (~> 1.3) debugger-ruby_core_source (~> 1.3)
rbnacl (3.4.0)
ffi
rbnacl-libsodium (1.0.11)
rbnacl (>= 3.0.1)
rdoc (4.2.2) rdoc (4.2.2)
json (~> 1.4) json (~> 1.4)
re2 (1.1.1) re2 (1.1.1)
...@@ -856,6 +862,7 @@ GEM ...@@ -856,6 +862,7 @@ GEM
activesupport (>= 4.0) activesupport (>= 4.0)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
sqlite3 (1.3.13) sqlite3 (1.3.13)
sshkey (1.9.0)
stackprof (0.2.10) stackprof (0.2.10)
state_machines (0.4.0) state_machines (0.4.0)
state_machines-activemodel (0.4.0) state_machines-activemodel (0.4.0)
...@@ -954,6 +961,7 @@ DEPENDENCIES ...@@ -954,6 +961,7 @@ DEPENDENCIES
aws-sdk aws-sdk
babosa (~> 1.0.2) babosa (~> 1.0.2)
base32 (~> 0.3.0) base32 (~> 0.3.0)
bcrypt_pbkdf (~> 1.0)
benchmark-ips (~> 2.3.0) benchmark-ips (~> 2.3.0)
better_errors (~> 2.1.0) better_errors (~> 2.1.0)
binding_of_caller (~> 0.7.2) binding_of_caller (~> 0.7.2)
...@@ -1053,6 +1061,7 @@ DEPENDENCIES ...@@ -1053,6 +1061,7 @@ DEPENDENCIES
mysql2 (~> 0.4.5) mysql2 (~> 0.4.5)
net-ldap net-ldap
net-ntp net-ntp
net-ssh (~> 4.1.0)
nokogiri (~> 1.6.7, >= 1.6.7.2) nokogiri (~> 1.6.7, >= 1.6.7.2)
oauth2 (~> 1.4) oauth2 (~> 1.4)
octokit (~> 4.6.2) octokit (~> 4.6.2)
...@@ -1099,6 +1108,8 @@ DEPENDENCIES ...@@ -1099,6 +1108,8 @@ DEPENDENCIES
rainbow (~> 2.2) rainbow (~> 2.2)
raindrops (~> 0.18) raindrops (~> 0.18)
rblineprof (~> 0.3.6) rblineprof (~> 0.3.6)
rbnacl (~> 3.2)
rbnacl-libsodium
rdoc (~> 4.2) rdoc (~> 4.2)
re2 (~> 1.1.1) re2 (~> 1.1.1)
recaptcha (~> 3.0) recaptcha (~> 3.0)
...@@ -1141,6 +1152,7 @@ DEPENDENCIES ...@@ -1141,6 +1152,7 @@ DEPENDENCIES
spring-commands-rspec (~> 1.0.4) spring-commands-rspec (~> 1.0.4)
spring-commands-spinach (~> 1.1.0) spring-commands-spinach (~> 1.1.0)
sprockets (~> 3.7.0) sprockets (~> 3.7.0)
sshkey (~> 1.9.0)
stackprof (~> 0.2.10) stackprof (~> 0.2.10)
state_machines-activerecord (~> 0.4.0) state_machines-activerecord (~> 0.4.0)
sys-filesystem (~> 1.1.6) sys-filesystem (~> 1.1.6)
......
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 { ...@@ -990,3 +990,76 @@ a.allowed-to-push {
border-color: $border-color; 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;
}
}
...@@ -13,6 +13,22 @@ class Projects::MirrorsController < Projects::ApplicationController ...@@ -13,6 +13,22 @@ class Projects::MirrorsController < Projects::ApplicationController
redirect_to_repository_settings(@project) redirect_to_repository_settings(@project)
end end
def ssh_host_keys
lookup = SshHostKey.new(project: project, url: params[:ssh_url])
if lookup.error.present?
# Failed to read keys
render json: { message: lookup.error }, status: 400
elsif lookup.known_hosts.nil?
# Still working, come back later
render body: nil, status: 204
else
render json: lookup
end
rescue ArgumentError => err
render json: { message: err.message }, status: 400
end
def update def update
if @project.update_attributes(safe_mirror_params) if @project.update_attributes(safe_mirror_params)
if @project.mirror? if @project.mirror?
...@@ -28,7 +44,17 @@ class Projects::MirrorsController < Projects::ApplicationController ...@@ -28,7 +44,17 @@ class Projects::MirrorsController < Projects::ApplicationController
flash[:alert] = @project.errors.full_messages.join(', ').html_safe flash[:alert] = @project.errors.full_messages.join(', ').html_safe
end end
redirect_to_repository_settings(@project) respond_to do |format|
format.html { redirect_to_repository_settings(@project) }
format.json do
if @project.errors.present?
render json: @project.errors, status: :unprocessable_entity
else
render json: ProjectMirrorSerializer.new.represent(@project)
end
end
end
end end
def update_now def update_now
...@@ -50,13 +76,35 @@ class Projects::MirrorsController < Projects::ApplicationController ...@@ -50,13 +76,35 @@ class Projects::MirrorsController < Projects::ApplicationController
end end
def mirror_params def mirror_params
params.require(:project).permit(:mirror, :import_url, :mirror_user_id, params.require(:project)
:mirror_trigger_builds, remote_mirrors_attributes: [:url, :id, :enabled]) .permit(
:mirror,
:import_url,
:username_only_import_url,
:mirror_user_id,
:mirror_trigger_builds,
import_data_attributes: [:id, :auth_method, :password, :ssh_known_hosts, :regenerate_ssh_private_key],
remote_mirrors_attributes: [:url, :id, :enabled]
)
end end
def safe_mirror_params def safe_mirror_params
return mirror_params if valid_mirror_user?(mirror_params) params = mirror_params
params[:mirror_user_id] = current_user.id unless valid_mirror_user?(params)
import_data = params[:import_data_attributes]
if import_data.present?
# Prevent Rails from destroying the existing import data
import_data[:id] ||= project.import_data&.id
# If the known hosts data is being set, store details about who and when
if import_data[:ssh_known_hosts].present?
import_data[:ssh_known_hosts_verified_at] = Time.now
import_data[:ssh_known_hosts_verified_by_id] = current_user.id
end
end
mirror_params.merge(mirror_user_id: current_user.id) params
end end
end end
...@@ -167,7 +167,7 @@ class Project < ActiveRecord::Base ...@@ -167,7 +167,7 @@ class Project < ActiveRecord::Base
has_many :todos has_many :todos
has_many :notification_settings, as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :notification_settings, as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_one :import_data, class_name: 'ProjectImportData' has_one :import_data, class_name: 'ProjectImportData', inverse_of: :project, autosave: true
has_one :project_feature has_one :project_feature
has_one :statistics, class_name: 'ProjectStatistics' has_one :statistics, class_name: 'ProjectStatistics'
...@@ -196,6 +196,7 @@ class Project < ActiveRecord::Base ...@@ -196,6 +196,7 @@ class Project < ActiveRecord::Base
accepts_nested_attributes_for :variables, allow_destroy: true accepts_nested_attributes_for :variables, allow_destroy: true
accepts_nested_attributes_for :project_feature accepts_nested_attributes_for :project_feature
accepts_nested_attributes_for :import_data
delegate :name, to: :owner, allow_nil: true, prefix: true delegate :name, to: :owner, allow_nil: true, prefix: true
delegate :count, to: :forks, prefix: true delegate :count, to: :forks, prefix: true
...@@ -587,8 +588,6 @@ class Project < ActiveRecord::Base ...@@ -587,8 +588,6 @@ class Project < ActiveRecord::Base
project_import_data.credentials ||= {} project_import_data.credentials ||= {}
project_import_data.credentials = project_import_data.credentials.merge(credentials) project_import_data.credentials = project_import_data.credentials.merge(credentials)
end end
project_import_data.save
end end
def import? def import?
......
require 'carrierwave/orm/activerecord' require 'carrierwave/orm/activerecord'
class ProjectImportData < ActiveRecord::Base class ProjectImportData < ActiveRecord::Base
belongs_to :project prepend ::EE::ProjectImportData
belongs_to :project, inverse_of: :import_data
attr_encrypted :credentials, attr_encrypted :credentials,
key: Gitlab::Application.secrets.db_key_base, key: Gitlab::Application.secrets.db_key_base,
marshal: true, marshal: true,
......
...@@ -966,7 +966,7 @@ class Repository ...@@ -966,7 +966,7 @@ class Repository
def fetch_upstream(url) def fetch_upstream(url)
add_remote(Repository::MIRROR_REMOTE, url) add_remote(Repository::MIRROR_REMOTE, url)
fetch_remote(Repository::MIRROR_REMOTE) fetch_remote(Repository::MIRROR_REMOTE, ssh_auth: project&.import_data)
end end
def fetch_geo_mirror(url) def fetch_geo_mirror(url)
...@@ -1088,8 +1088,8 @@ class Repository ...@@ -1088,8 +1088,8 @@ class Repository
false false
end end
def fetch_remote(remote, forced: false, no_tags: false) def fetch_remote(remote, forced: false, ssh_auth: nil, no_tags: false)
gitlab_shell.fetch_remote(repository_storage_path, disk_path, remote, forced: forced, no_tags: no_tags) gitlab_shell.fetch_remote(repository_storage_path, disk_path, remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags)
end end
def fetch_ref(source_path, source_ref, target_ref) def fetch_ref(source_path, source_ref, target_ref)
......
.account-well.prepend-top-default.append-bottom-default .account-well.prepend-top-default.append-bottom-default
%ul %ul
%li %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 %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 %li
The update action will time out after 10 minutes. For big repositories, use a clone/push combination. The update action will time out after 10 minutes. For big repositories, use a clone/push combination.
%li %li
......
- expanded = Rails.env.test? - expanded = Rails.env.test?
- import_data = @project.import_data || @project.build_import_data
- protocols = AddressableUrlValidator::DEFAULT_OPTIONS[:protocols].join('|')
%section.settings.project-mirror-settings %section.settings.project-mirror-settings
.settings-header .settings-header
%h4 %h4
...@@ -10,30 +13,26 @@ ...@@ -10,30 +13,26 @@
updated from an upstream repository. updated from an upstream repository.
= link_to 'Read more', help_page_path('workflow/repository_mirroring', anchor: 'pulling-from-a-remote-repository'), target: '_blank' = 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) } .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 %div
= form_errors(@project) = form_errors(@project)
%h5
Set up mirror repository
= render "shared/mirror_update_button" = render "shared/mirror_update_button"
- if @project.mirror_last_update_failed? = render "projects/mirrors/pull/mirror_update_fail"
.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))}
.form-group .form-group
= f.check_box :mirror, class: "pull-left" = f.check_box :mirror, class: "pull-left"
.prepend-left-20 .prepend-left-20
= f.label :mirror, "Mirror repository", class: "label-light append-bottom-0" = f.label :mirror, "Mirror repository", class: "label-light append-bottom-0"
.form-group .form-group
= f.label :import_url, "Git repository URL", class: "label-light" = f.label :username_only_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' = 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"
= 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 .form-group
= f.label :mirror_user_id, "Mirror user", class: "label-light" = 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) = 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) - if @project.feature_available?(:repository_mirrors)
= render 'projects/mirrors/pull' = render 'projects/mirrors/pull'
= render 'projects/mirrors/push' = 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'
...@@ -16,6 +16,9 @@ ...@@ -16,6 +16,9 @@
To migrate an SVN repository, check out #{link_to "this document", help_page_path('workflow/importing/migrating_from_svn')}. To migrate an SVN repository, check out #{link_to "this document", help_page_path('workflow/importing/migrating_from_svn')}.
%li %li
The Git LFS objects will <strong>not</strong> be imported. The Git LFS objects will <strong>not</strong> be imported.
%li
Once imported, repositories can be mirrored over SSH. Read more
= link_to 'here', help_page_path('/workflow/repository_mirroring.md', anchor: 'ssh-authentication')
.form-group .form-group
.col-sm-offset-2.col-sm-10 .col-sm-offset-2.col-sm-10
......
---
title: Implement SSH public-key support for repository mirroring
merge_request: 2423
author:
...@@ -44,6 +44,7 @@ module Gitlab ...@@ -44,6 +44,7 @@ module Gitlab
#{config.root}/ee/app/models #{config.root}/ee/app/models
#{config.root}/ee/app/models/concerns #{config.root}/ee/app/models/concerns
#{config.root}/ee/app/policies #{config.root}/ee/app/policies
#{config.root}/ee/app/serializers
#{config.root}/ee/app/services #{config.root}/ee/app/services
#{config.root}/ee/app/workers #{config.root}/ee/app/workers
]) ])
......
...@@ -189,6 +189,7 @@ constraints(ProjectUrlConstrainer.new) do ...@@ -189,6 +189,7 @@ constraints(ProjectUrlConstrainer.new) do
## EE-specific ## EE-specific
resource :mirror, only: [:show, :update] do resource :mirror, only: [:show, :update] do
member do member do
get :ssh_host_keys, constraints: { format: :json }
post :update_now post :update_now
end end
end end
......
...@@ -54,6 +54,7 @@ var config = { ...@@ -54,6 +54,7 @@ var config = {
locale: './locale/index.js', locale: './locale/index.js',
main: './main.js', main: './main.js',
merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js', merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js',
mirrors: './mirrors',
monitoring: './monitoring/monitoring_bundle.js', monitoring: './monitoring/monitoring_bundle.js',
network: './network/network_bundle.js', network: './network/network_bundle.js',
notebook_viewer: './blob/notebook_viewer.js', notebook_viewer: './blob/notebook_viewer.js',
......
...@@ -26,9 +26,9 @@ the mirror is already being updated or 5 minutes still haven't passed since its ...@@ -26,9 +26,9 @@ the mirror is already being updated or 5 minutes still haven't passed since its
A few things/limitations to consider: A few things/limitations to consider:
- The repository must be accessible over `http://`, `https://` or `git://`. - The repository must be accessible over `http://`, `https://`, `ssh://` or `git://`.
- If your HTTP repository is not publicly accessible, add authentication - If your HTTP repository is not publicly accessible, add authentication
information to the URL, like: `https://username:password@gitlab.company.com/group/project.git`. information to the URL, like: `https://username@gitlab.company.com/group/project.git`.
In some cases, you might need to use a personal access token instead of a In some cases, you might need to use a personal access token instead of a
password, e.g., you want to mirror to GitHub and have 2FA enabled. password, e.g., you want to mirror to GitHub and have 2FA enabled.
- The import will time out after 15 minutes. For repositories that take longer - The import will time out after 15 minutes. For repositories that take longer
...@@ -84,6 +84,97 @@ this branch to prevent any changes from being lost. ...@@ -84,6 +84,97 @@ this branch to prevent any changes from being lost.
![Diverged branch](repository_mirroring/repository_mirroring_diverged_branch.png) ![Diverged branch](repository_mirroring/repository_mirroring_diverged_branch.png)
### SSH authentication
> [Introduced][ee-2551] in GitLab Enterprise Edition Starter 9.15
If you're mirroring over SSH (i.e., an `ssh://` URL), you can authenticate using
password-based authentication, just as over HTTPS, but you can also use public
key authentication. This is often more secure than password authentication,
especially when the source repository supports [Deploy Keys][deploy-key].
To get started, navigate to **Settings ➔ Repository ➔ Pull from a remote repository**,
enable mirroring (if not already enabled) and enter an `ssh://` URL.
> **NOTE**: SCP-style URLs, e.g., `git@example.com:group/project.git`, are not
supported at this time.
Entering the URL adds two features to the page - `Fingerprints` and
`SSH public key authentication`:
![Pull settings for SSH](repository_mirroring/repository_mirroring_pull_settings_for_ssh.png)
SSH authentication is mutual. You have to prove to the server that you're
allowed to access the repository, but 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.
Press the `Detect host keys` button. GitLab will fetch the host keys from the
server, and display the fingerprints to you:
![Detect SSH host keys](repository_mirroring/repository_mirroring_detect_host_keys.png)
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)
* [GitHub](https://help.github.com/articles/github-s-ssh-key-fingerprints/)
* [GitLab.com](https://about.gitlab.com/gitlab-com/settings/#ssh-host-keys-fingerprints)
* [Launchpad](https://help.launchpad.net/SSHFingerprints)
* [Savannah](http://savannah.gnu.org/maintenance/SshAccess/)
* [SourceForge](https://sourceforge.net/p/forge/documentation/SSH%20Key%20Fingerprints/)
Other providers will vary. If you're running on-premises GitLab, or otherwise
have access to the source server, you can securely gather the key fingerprints:
```
$ cat /etc/ssh/ssh_host*pub | ssh-keygen -E md5 -l -f -
256 MD5:f4:28:9f:23:99:15:21:1b:bf:ed:1f:8e:a0:76:b2:9d root@example.com (ECDSA)
256 MD5:e6:eb:45:8a:3c:59:35:5f:e9:5b:80:12:be:7e:22:73 root@example.com (ED25519)
2048 MD5:3f:72:be:3d:62:03:5c:62:83:e8:6e:14:34:3a:85:1d root@example.com (RSA)
```
(You may need to exclude `-E md5` for some older versions of SSH).
If you're an SSH expert and already have a `known_hosts` file you'd like to use
unaltered, then you can skip these steps. Just press the "Show advanced" button
and paste in the file contents:
![Advanced SSH host key management](repository_mirroring/repository_mirroring_pull_advanced_host_keys.png)
Once you've **carefully verified** that all the fingerprints match your trusted
source, you can press `Save changes`. This will record the host keys, along with
the person who verified them (you!) and the date:
![SSH host keys submitted](repository_mirroring/repository_mirroring_ssh_host_keys_verified.png)
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!
To use SSH public key authentication, you'll also need to choose that option
from the authentication methods dropdown. GitLab will generate a 4096-bit RSA
key and display the public component of that key to you:
![SSH public key authentication](repository_mirroring/repository_mirroring_ssh_public_key_authentication.png)
You then need to add the public SSH key to the source repository configuration.
If the source is hosted on GitLab, you should add it as a [Deploy Key][deploy-key].
Other sources may require you to add the key to your user's `authorized_keys`
file - just paste the entire `ssh-rsa AAA.... user@host` block into the file on
its own line and save it.
Once the public key is set up on the source repository, press `Save changes` and your
mirror will be begin working.
If you need to change the key at any time, you can press the `Regenerate key`
button to do so. You'll have to update the source repository with the new key
to keep the mirror running.
## How it works ## How it works
Once you activate the pull mirroring feature, the mirror will be inserted into a queue. Once you activate the pull mirroring feature, the mirror will be inserted into a queue.
...@@ -139,5 +230,7 @@ to resolve this issue. ...@@ -139,5 +230,7 @@ to resolve this issue.
[ee-51]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/51 [ee-51]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/51
[ee-2551]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/2551
[perms]: ../user/permissions.md [perms]: ../user/permissions.md
[hooks]: https://docs.gitlab.com/ee/administration/custom_hooks.html [hooks]: https://docs.gitlab.com/ee/administration/custom_hooks.html
[deploy-key]: ../ssh/README.md#deploy-keys
...@@ -381,6 +381,27 @@ module EE ...@@ -381,6 +381,27 @@ module EE
end end
end end
def username_only_import_url
bare_url = read_attribute(:import_url)
return bare_url unless ::Gitlab::UrlSanitizer.valid?(bare_url)
::Gitlab::UrlSanitizer.new(bare_url, credentials: { user: import_data&.user }).full_url
end
def username_only_import_url=(value)
unless ::Gitlab::UrlSanitizer.valid?(value)
self.import_url = value
return
end
url = ::Gitlab::UrlSanitizer.new(value)
creds = url.credentials.slice(:user) if url.credentials[:user].present?
write_attribute(:import_url, url.sanitized_url)
create_or_update_import_data(credentials: creds)
username_only_import_url
end
def mark_remote_mirrors_for_removal def mark_remote_mirrors_for_removal
remote_mirrors.each(&:mark_for_delete_if_blank_url) remote_mirrors.each(&:mark_for_delete_if_blank_url)
end end
......
module EE
module ProjectImportData
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: ->(data) do
regenerate_ssh_private_key || ( auth_method == 'ssh_public_key' && ssh_private_key.blank? )
end
end
attr_accessor :regenerate_ssh_private_key
def ssh_key_auth?
ssh_import? && auth_method == 'ssh_public_key'
end
def ssh_import?
project&.import_url&.start_with?('ssh://')
end
%i[auth_method user password ssh_private_key ssh_known_hosts ssh_known_hosts_verified_at ssh_known_hosts_verified_by_id].each do |name|
define_method(name) do
credentials[name] if credentials.present?
end
define_method("#{name}=") do |value|
self.credentials ||= {}
self.credentials[name] = value
end
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
end
# Detected SSH host keys are transiently stored in Redis
class SshHostKey
class Fingerprint < Gitlab::KeyFingerprint
attr_reader :index
def initialize(key, index: nil)
super(key)
@index = index
end
def as_json(*)
{ bits: bits, fingerprint: fingerprint, type: type, index: index }
end
end
include ReactiveCaching
self.reactive_cache_key = ->(key) { [key.class.to_s, key.id] }
# Do not refresh the data in the background - it is not expected to change
self.reactive_cache_refresh_interval = 15.minutes
self.reactive_cache_lifetime = 10.minutes
def self.find_by(opts = {})
id = opts.fetch(:id, "")
project_id, url = id.split(':', 2)
project = Project.where(id: project_id).includes(:import_data).first
project.presence && new(project: project, url: url)
end
def self.fingerprint_host_keys(data)
return [] unless data.is_a?(String)
data
.each_line
.each_with_index
.map { |line, index| Fingerprint.new(line, index: index) }
.select(&:valid?)
end
attr_reader :project, :url
def initialize(project:, url:)
@project = project
@url = normalize_url(url)
end
def id
[project.id, url].join(':')
end
def as_json(*)
{
changes_project_import_data: changes_project_import_data?,
fingerprints: fingerprints,
known_hosts: known_hosts
}
end
def known_hosts
with_reactive_cache { |data| data[:known_hosts] }
end
def fingerprints
@fingerprints ||= self.class.fingerprint_host_keys(known_hosts)
end
# Returns true if the known_hosts data differs from that currently set for
# `project.import_data.ssh_known_hosts`. Ordering is ignored.
#
# Ordering is ignored
def changes_project_import_data?
our_known_hosts = known_hosts
project_known_hosts = project.import_data&.ssh_known_hosts
cleanup(our_known_hosts.to_s) != cleanup(project_known_hosts.to_s)
end
def error
with_reactive_cache { |data| data[:error] }
end
def calculate_reactive_cache
known_hosts, errors, status =
Open3.popen3({}, *%W[ssh-keyscan -T 5 -p #{url.port} -f-]) do |stdin, stdout, stderr, wait_thr|
stdin.puts(url.host)
stdin.close
[
cleanup(stdout.read),
cleanup(stderr.read),
wait_thr.value
]
end
# ssh-keyscan returns an exit code 0 in several error conditions, such as an
# unknown hostname, so check both STDERR and the exit code
if !status.success? || errors.present?
Rails.logger.debug("Failed to detect SSH host keys for #{id}: #{errors}")
return { error: 'Failed to detect SSH host keys' }
end
{ known_hosts: known_hosts }
end
private
# Remove comments and duplicate entries
def cleanup(data)
data
.each_line
.map { |line| line unless line.start_with?('#') || line.chomp.empty? }
.compact
.uniq
.sort
.join
end
def normalize_url(url)
full_url = ::Addressable::URI.parse(url)
raise ArgumentError.new("Invalid URL") unless full_url&.scheme == 'ssh'
Addressable::URI.parse("ssh://#{full_url.host}:#{full_url.inferred_port}")
end
end
class ProjectMirrorEntity < Grape::Entity
expose :id
expose :mirror
expose :import_url
expose :username_only_import_url
expose :mirror_user_id
expose :mirror_trigger_builds
expose :import_data_attributes do |project|
import_data = project.import_data
next nil unless import_data.present?
data = import_data.as_json(
only: :id,
methods: %i[
auth_method
ssh_known_hosts
ssh_known_hosts_verified_at
ssh_known_hosts_verified_by_id
ssh_public_key
]
)
data[:ssh_known_hosts_fingerprints] = import_data.ssh_known_hosts_fingerprints.as_json
data
end
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
end
end
class ProjectMirrorSerializer < BaseSerializer
entity ProjectMirrorEntity
end
module Gitlab module Gitlab
class KeyFingerprint class KeyFingerprint
include Gitlab::Popen attr_reader :key, :ssh_key
attr_accessor :key # Unqualified MD5 fingerprint for compatibility
delegate :fingerprint, to: :ssh_key, allow_nil: true
def initialize(key) def initialize(key)
@key = key @key = key
end
def fingerprint
cmd_status = 0
cmd_output = ''
Tempfile.open('gitlab_key_file') do |file|
file.puts key
file.rewind
cmd = []
cmd.push('ssh-keygen')
cmd.push('-E', 'md5') if explicit_fingerprint_algorithm?
cmd.push('-lf', file.path)
cmd_output, cmd_status = popen(cmd, '/tmp')
end
return nil unless cmd_status.zero?
# 16 hex bytes separated by ':', optionally starting with "MD5:" @ssh_key =
fingerprint_matches = cmd_output.match(/(MD5:)?(?<fingerprint>(\h{2}:){15}\h{2})/) begin
return nil unless fingerprint_matches Net::SSH::KeyFactory.load_data_public_key(key)
rescue Net::SSH::Exception, NotImplementedError
fingerprint_matches[:fingerprint] end
end end
private def valid?
ssh_key.present?
def explicit_fingerprint_algorithm? end
# OpenSSH 6.8 introduces a new default output format for fingerprints.
# Check the version and decide which command to use.
version_output, version_status = popen(%w(ssh -V))
return false unless version_status.zero?
version_matches = version_output.match(/OpenSSH_(?<major>\d+)\.(?<minor>\d+)/) def type
return false unless version_matches return unless valid?
version_info = Gitlab::VersionInfo.new(version_matches[:major].to_i, version_matches[:minor].to_i) parts = ssh_key.ssh_type.split('-')
parts.shift if parts[0] == 'ssh'
required_version_info = Gitlab::VersionInfo.new(6, 8) parts[0].upcase
end
version_info >= required_version_info def bits
return unless valid?
case type
when 'RSA'
ssh_key.n.num_bits
when 'DSS', 'DSA'
ssh_key.p.num_bits
when 'ECDSA'
ssh_key.group.order.num_bits
when 'ED25519'
256
else
raise "Unsupported key type: #{type}"
end
end end
end end
end end
...@@ -126,6 +126,7 @@ module Gitlab ...@@ -126,6 +126,7 @@ module Gitlab
# #
# name - project path with namespace # name - project path with namespace
# remote - remote name # remote - remote name
# ssh_auth - SSH known_hosts data and a private key to use for public-key authentication
# forced - should we use --force flag? # forced - should we use --force flag?
# no_tags - should we use --no-tags flag? # no_tags - should we use --no-tags flag?
# #
...@@ -133,12 +134,24 @@ module Gitlab ...@@ -133,12 +134,24 @@ module Gitlab
# fetch_remote("gitlab/gitlab-ci", "upstream") # fetch_remote("gitlab/gitlab-ci", "upstream")
# #
# Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/387 # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/387
def fetch_remote(storage, name, remote, forced: false, no_tags: false) def fetch_remote(storage, name, remote, ssh_auth: nil, forced: false, no_tags: false)
args = [gitlab_shell_projects_path, 'fetch-remote', storage, "#{name}.git", remote, "#{Gitlab.config.gitlab_shell.git_timeout}"] args = [gitlab_shell_projects_path, 'fetch-remote', storage, "#{name}.git", remote, "#{Gitlab.config.gitlab_shell.git_timeout}"]
args << '--force' if forced args << '--force' if forced
args << '--no-tags' if no_tags args << '--no-tags' if no_tags
gitlab_shell_fast_execute_raise_error(args) vars = {}
if ssh_auth&.ssh_import?
if ssh_auth.ssh_key_auth? && ssh_auth.ssh_private_key.present?
vars['GITLAB_SHELL_SSH_KEY'] = ssh_auth.ssh_private_key
end
if ssh_auth.ssh_known_hosts.present?
vars['GITLAB_SHELL_KNOWN_HOSTS'] = ssh_auth.ssh_known_hosts
end
end
gitlab_shell_fast_execute_raise_error(args, vars)
end end
# Move repository # Move repository
...@@ -448,15 +461,15 @@ module Gitlab ...@@ -448,15 +461,15 @@ module Gitlab
false false
end end
def gitlab_shell_fast_execute_raise_error(cmd) def gitlab_shell_fast_execute_raise_error(cmd, vars = {})
output, status = gitlab_shell_fast_execute_helper(cmd) output, status = gitlab_shell_fast_execute_helper(cmd, vars)
raise Error, output unless status.zero? raise Error, output unless status.zero?
true true
end end
def gitlab_shell_fast_execute_helper(cmd) def gitlab_shell_fast_execute_helper(cmd, vars = {})
vars = ENV.to_h.slice(*GITLAB_SHELL_ENV_VARS) vars.merge!(ENV.to_h.slice(*GITLAB_SHELL_ENV_VARS))
# Don't pass along the entire parent environment to prevent gitlab-shell # Don't pass along the entire parent environment to prevent gitlab-shell
# from wasting I/O by searching through GEM_PATH # from wasting I/O by searching through GEM_PATH
......
require 'spec_helper' require 'spec_helper'
describe Projects::MirrorsController do describe Projects::MirrorsController do
include ReactiveCachingHelpers
describe 'setting up a remote mirror' do describe 'setting up a remote mirror' do
context 'when the current project is a mirror' do context 'when the current project is a mirror' do
let(:project) { create(:project, :repository, :mirror) } let(:project) { create(:project, :repository, :mirror) }
...@@ -126,8 +128,152 @@ describe Projects::MirrorsController do ...@@ -126,8 +128,152 @@ describe Projects::MirrorsController do
end end
end end
def do_put(project, options) describe '#update' do
attrs = { namespace_id: project.namespace.to_param, project_id: project.to_param } let(:project) { create(:project, :repository, :mirror, :remote_mirror) }
before do
sign_in(project.owner)
end
around(:each) do |example|
Sidekiq::Testing.fake! { example.run }
end
context 'JSON' do
it 'processes a successful update' do
do_put(project, { import_url: 'https://updated.example.com' }, format: :json)
expect(response).to have_http_status(200)
expect(json_response['import_url']).to eq('https://updated.example.com')
end
it 'processes an unsuccessful update' do
do_put(project, { import_url: 'ftp://invalid.invalid' }, format: :json)
expect(response).to have_http_status(422)
expect(json_response['import_url'].first).to match /valid URL/
end
it "preserves the import_data object when the ID isn't in the request" do
import_data_id = project.import_data.id
do_put(project, { import_data_attributes: { password: 'update' } }, format: :json)
expect(response).to have_http_status(200)
expect(project.import_data(true).id).to eq(import_data_id)
end
it 'sets ssh_known_hosts_verified_at and verified_by when the update sets known hosts' do
do_put(project, { import_data_attributes: { ssh_known_hosts: 'update' } }, format: :json)
expect(response).to have_http_status(200)
import_data = project.import_data(true)
expect(import_data.ssh_known_hosts_verified_at).to be_within(1.minute).of(Time.now)
expect(import_data.ssh_known_hosts_verified_by).to eq(project.owner)
end
it 'unsets ssh_known_hosts_verified_at and verified_by when the update unsets known hosts' do
project.import_data.update!(ssh_known_hosts: 'foo')
do_put(project, { import_data_attributes: { ssh_known_hosts: '' } }, format: :json)
expect(response).to have_http_status(200)
import_data = project.import_data(true)
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
it 'processes a successful update' do
do_put(project, import_url: 'https://updated.example.com')
expect(response).to redirect_to(project_settings_repository_path(project))
expect(flash[:notice]).to match(/successfully updated/)
end
it 'processes an unsuccessful update' do
do_put(project, import_url: 'ftp://invalid.invalid')
expect(response).to redirect_to(project_settings_repository_path(project))
expect(flash[:alert]).to match(/valid URL/)
end
end
end
describe '#ssh_host_keys', use_clean_rails_memory_store_caching: true do
let(:project) { create(:project) }
let(:cache) { SshHostKey.new(project: project, url: "ssh://example.com:22") }
before do
sign_in(project.owner)
end
context 'invalid URL' do
it 'returns an error with a 400 response' do
do_get(project, 'INVALID URL')
expect(response).to have_http_status(400)
expect(json_response).to eq('message' => 'Invalid URL')
end
end
context 'no data in cache' do
it 'requests the cache to be filled and returns a 204 response' do
expect(ReactiveCachingWorker).to receive(:perform_async).with(cache.class, cache.id).at_least(:once)
do_get(project)
expect(response).to have_http_status(204)
end
end
context 'error in the cache' do
it 'returns the error with a 400 response' do
stub_reactive_cache(cache, error: 'An error')
do_get(project)
expect(response).to have_http_status(400)
expect(json_response).to eq('message' => 'An error')
end
end
context 'data in the cache' do
let(:ssh_key) { 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf' }
let(:ssh_fp) { { type: 'ED25519', bits: 256, fingerprint: '2e:65:6a:c8:cf:bf:b2:8b:9a:bd:6d:9f:11:5c:12:16', index: 0 } }
it 'returns the data with a 200 response' do
stub_reactive_cache(cache, known_hosts: ssh_key)
do_get(project)
expect(response).to have_http_status(200)
expect(json_response).to eq('known_hosts' => ssh_key, 'fingerprints' => [ssh_fp.stringify_keys], 'changes_project_import_data' => true)
end
end
def do_get(project, url = 'ssh://example.com')
get :ssh_host_keys, namespace_id: project.namespace, project_id: project, ssh_url: url
end
end
def do_put(project, options, extra_attrs = {})
attrs = extra_attrs.merge(namespace_id: project.namespace.to_param, project_id: project.to_param)
attrs[:project] = options attrs[:project] = options
put :update, attrs put :update, attrs
......
...@@ -40,26 +40,9 @@ describe 'Project settings > [EE] repository' do ...@@ -40,26 +40,9 @@ describe 'Project settings > [EE] repository' do
visit project_settings_repository_path(project) visit project_settings_repository_path(project)
end end
it 'shows pull mirror settings' do
expect(page).to have_selector('#project_mirror')
expect(page).to have_selector('#project_import_url')
expect(page).to have_selector('#project_mirror_user_id', visible: false)
expect(page).to have_selector('#project_mirror_trigger_builds')
end
it 'shows push mirror settings' do it 'shows push mirror settings' do
expect(page).to have_selector('#project_remote_mirrors_attributes_0_enabled') expect(page).to have_selector('#project_remote_mirrors_attributes_0_enabled')
expect(page).to have_selector('#project_remote_mirrors_attributes_0_url') expect(page).to have_selector('#project_remote_mirrors_attributes_0_url')
end 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
end end
require 'spec_helper'
describe ProjectImportData do
let(:import_url) { 'ssh://example.com' }
let(:import_data_attrs) { { auth_method: 'ssh_public_key' } }
let(:project) { build(:project, :mirror, import_url: import_url, import_data_attributes: import_data_attrs) }
subject(:import_data) { project.import_data }
describe 'validations' do
it { is_expected.to validate_inclusion_of(:auth_method).in_array([nil, '', 'password', 'ssh_public_key']) }
end
describe '#ssh_key_auth?' do
subject { import_data.ssh_key_auth? }
[
{ import_url: 'ssh://example.com', auth_method: 'ssh_public_key', expected: true },
{ import_url: 'ssh://example.com', auth_method: 'password', expected: false },
{ import_url: 'http://example.com', auth_method: 'ssh_public_key', expected: false },
{ import_url: 'http://example.com', auth_method: 'password', expected: false }
].each do |spec|
context spec.inspect do
let(:import_url) { spec[:import_url] }
let(:import_data_attrs) { { auth_method: spec[:auth_method] } }
it { is_expected.to spec[:expected] ? be_truthy : be_falsy }
end
end
end
describe '#ssh_known_hosts_verified_by' do
let(:user) { project.owner }
subject { import_data.ssh_known_hosts_verified_by }
it 'is a user when ssh_known_hosts_verified_by_id is a valid id' do
import_data.ssh_known_hosts_verified_by_id = user.id
is_expected.to eq(user)
end
it 'is nil when ssh_known_hosts_verified_by_id is an invalid id' do
import_data.ssh_known_hosts_verified_by_id = -1
is_expected.to be_nil
end
it 'is nil when ssh_known_hosts_verified_by_id is nil' do
is_expected.to be_nil
end
end
describe 'auth_method' do
[nil, ''].each do |value|
it "returns 'password' when #{value.inspect}" do
import_data.auth_method = value
expect(import_data.auth_method).to eq('password')
end
end
end
describe '#ssh_import?' do
subject { import_data.ssh_import? }
[
{ import_url: nil, expected: false },
{ import_url: 'ssh://example.com', expected: true },
{ import_url: 'git://example.com', expected: false },
{ import_url: 'http://example.com', expected: false },
{ import_url: 'https://example.com', expected: false }
].each do |spec|
context spec.inspect do
let(:import_url) { spec[:import_url] }
it { is_expected.to spec[:expected] ? be_truthy : be_falsy }
end
end
end
describe '#ssh_known_hosts_fingerprints' do
subject { import_data.ssh_known_hosts_fingerprints }
it 'defers to SshHostKey#fingerprint_host_keys' do
import_data.ssh_known_hosts = 'known_hosts'
expect(SshHostKey).to receive(:fingerprint_host_keys).with('known_hosts').and_return(:result)
is_expected.to eq(:result)
end
end
describe '#ssh_public_key' do
subject { import_data.ssh_public_key }
context 'no SSH key' do
it { is_expected.to be_nil }
end
context 'with SSH key' do
before do
# The key should be generated regardless of the URL, as long as the
# auth method is correct
project.import_url = nil
# Triggers the `before_validation` callback
import_data.valid?
end
it 'returns the public counterpart of the SSH private key' do
comment = "git@#{::Gitlab.config.gitlab.host}"
expected = SSHKey.new(import_data.ssh_private_key, comment: comment)
is_expected.to eq(expected.ssh_public_key)
end
end
end
describe '#regenerate_ssh_private_key' do
%w[password ssh_public_key].each do |auth_method|
context "auth_method is #{auth_method}" do
let(:import_data_attrs) { { auth_method: auth_method } }
it 'regenerates the SSH private key' do
initial = import_data.ssh_private_key
import_data.regenerate_ssh_private_key = true
import_data.valid?
expect(import_data.ssh_private_key).not_to eq(initial)
end
end
end
end
end
...@@ -814,4 +814,48 @@ describe Project do ...@@ -814,4 +814,48 @@ describe Project do
end end
end end
end end
describe '#username_only_import_url' do
def build_project(username: 'user', password: 'password')
build(:project, import_url: 'http://example.com').tap do |project|
project.build_import_data(credentials: { user: username, password: password })
end
end
it 'shows the bare url when no username is present' do
project = build_project(username: nil)
expect(project.username_only_import_url).to eq('http://example.com')
end
it 'shows the URL with username when present' do
project = build_project(password: nil)
expect(project.username_only_import_url).to eq('http://user@example.com')
end
it 'excludes the pasword when present' do
project = build_project
expect(project.username_only_import_url).to eq('http://user@example.com')
end
end
describe '#username_only_import_url=' do
it 'sets the import url and username' do
project = build(:project, import_url: 'http://user@example.com')
expect(project.import_url).to eq('http://user@example.com')
expect(project.import_data.user).to eq('user')
end
it 'does not unset the password' do
project = build(:project, import_url: 'http://olduser:pass@old.example.com')
project.username_only_import_url = 'http://user@example.com'
expect(project.username_only_import_url).to eq('http://user@example.com')
expect(project.import_url).to eq('http://user:pass@example.com')
expect(project.import_data.password).to eq('pass')
end
end
end end
require 'spec_helper'
describe SshHostKey do
include ReactiveCachingHelpers
keys = [
SSHKeygen.generate,
SSHKeygen.generate
]
# Purposefully ordered so that `sort` will make changes
known_hosts = <<~EOF
example.com #{keys[0]} git@localhost
@revoked other.example.com #{keys[1]} git@localhost
EOF
def stub_ssh_keyscan(args, status: true, stdout: "", stderr: "")
stdin = StringIO.new
stdout = double(:stdout, read: stdout)
stderr = double(:stderr, read: stderr)
wait_thr = double(:wait_thr, value: double(success?: status))
expect(Open3).to receive(:popen3).with({}, 'ssh-keyscan', *args).and_yield(stdin, stdout, stderr, wait_thr)
stdin
end
let(:project) { build(:project, :mirror) }
subject(:ssh_host_key) { described_class.new(project: project, url: 'ssh://example.com:2222') }
describe '#fingerprints', use_clean_rails_memory_store_caching: true do
it 'returns an array of indexed fingerprints when the cache is filled' do
stub_reactive_cache(ssh_host_key, known_hosts: known_hosts)
expected = keys
.map { |data| Gitlab::KeyFingerprint.new(data) }
.each_with_index
.map { |key, i| { bits: key.bits, fingerprint: key.fingerprint, type: key.type, index: i } }
expect(ssh_host_key.fingerprints.as_json).to eq(expected)
end
it 'returns an empty array when the cache is empty' do
expect(ssh_host_key.fingerprints).to eq([])
end
end
describe '#fingerprints', use_clean_rails_memory_store_caching: true do
it 'returns an array of indexed fingerprints when the cache is filled' do
key1 = SSHKeygen.generate
key2 = SSHKeygen.generate
known_hosts = "example.com #{key1} git@localhost\n\n\n@revoked other.example.com #{key2} git@localhost\n"
stub_reactive_cache(ssh_host_key, known_hosts: known_hosts)
expect(ssh_host_key.fingerprints.as_json).to eq(
[
{ bits: 2048, fingerprint: Gitlab::KeyFingerprint.new(key1).fingerprint, type: 'RSA', index: 0 },
{ bits: 2048, fingerprint: Gitlab::KeyFingerprint.new(key2).fingerprint, type: 'RSA', index: 3 }
]
)
end
it 'returns an empty array when the cache is empty' do
expect(ssh_host_key.fingerprints).to eq([])
end
end
describe '#changes_project_import_data?' do
subject { ssh_host_key.changes_project_import_data? }
reversed = known_hosts.lines.reverse.join
extra = known_hosts + "foo\nbar\n"
[
{ a: known_hosts, b: extra, result: true },
{ a: known_hosts, b: "foo\n", result: true },
{ a: known_hosts, b: '', result: true },
{ a: known_hosts, b: nil, result: true },
{ a: known_hosts, b: known_hosts, result: false },
{ a: reversed, b: known_hosts, result: false },
{ a: extra, b: "foo\n", result: true },
{ a: '', b: '', result: false },
{ a: nil, b: nil, result: false },
{ a: '', b: nil, result: false }
].each_with_index do |spec, index|
it "is #{spec[:result]} for test case #{index}" do
expect(ssh_host_key).to receive(:known_hosts).and_return(spec[:a])
project.import_data.ssh_known_hosts = spec[:b]
is_expected.to eq(spec[:result])
end
# Comparisons should be symmetrical, so test the reverse too
it "is #{spec[:result]} for test case #{index} (reversed)" do
expect(ssh_host_key).to receive(:known_hosts).and_return(spec[:b])
project.import_data.ssh_known_hosts = spec[:a]
is_expected.to eq(spec[:result])
end
end
end
describe '#calculate_reactive_cache' do
subject(:cache) { ssh_host_key.calculate_reactive_cache }
it 'writes the hostname to STDIN' do
stdin = stub_ssh_keyscan(%w[-T 5 -p 2222 -f-])
cache
expect(stdin.string).to eq("example.com\n")
end
context 'successful key scan' do
it 'stores the cleaned known_hosts data' do
stub_ssh_keyscan(%w[-T 5 -p 2222 -f-], stdout: "KEY 1\nKEY 1\n\n# comment\nKEY 2\n")
is_expected.to eq(known_hosts: "KEY 1\nKEY 2\n")
end
end
context 'failed key scan (exit code 1)' do
it 'returns a generic error' do
stub_ssh_keyscan(%w[-T 5 -p 2222 -f-], stdout: 'blarg', status: false)
is_expected.to eq(error: 'Failed to detect SSH host keys')
end
end
context 'failed key scan (exit code 0)' do
it 'returns a generic error' do
stub_ssh_keyscan(%w[-T 5 -p 2222 -f-], stderr: 'Unknown host')
is_expected.to eq(error: 'Failed to detect SSH host keys')
end
end
end
end
require 'spec_helper'
describe ProjectMirrorEntity do
subject(:entity) { described_class.new(project).as_json.deep_symbolize_keys }
describe 'pull mirror' do
let(:project) { create(:project, :mirror) }
let(:import_data) { project.import_data }
context 'password authentication' do
before do
import_data.update!(auth_method: 'password', password: 'fake password')
end
it 'represents the pull mirror' do
is_expected.to eq(
id: project.id,
mirror: true,
import_url: project.import_url,
username_only_import_url: project.username_only_import_url,
mirror_user_id: project.mirror_user_id,
mirror_trigger_builds: project.mirror_trigger_builds,
import_data_attributes: {
id: import_data.id,
auth_method: 'password',
ssh_known_hosts: nil,
ssh_known_hosts_fingerprints: [],
ssh_known_hosts_verified_at: nil,
ssh_known_hosts_verified_by_id: nil,
ssh_public_key: nil
},
remote_mirrors_attributes: []
)
end
end
context 'SSH public-key authentication' do
before do
project.import_url = "ssh://example.com"
import_data.update!(auth_method: 'ssh_public_key', ssh_known_hosts: "example.com #{SSHKeygen.generate}")
end
it 'represents the pull mirror' do
is_expected.to eq(
id: project.id,
mirror: true,
import_url: project.import_url,
username_only_import_url: project.username_only_import_url,
mirror_user_id: project.mirror_user_id,
mirror_trigger_builds: project.mirror_trigger_builds,
import_data_attributes: {
id: import_data.id,
auth_method: 'ssh_public_key',
ssh_known_hosts: import_data.ssh_known_hosts,
ssh_known_hosts_fingerprints: import_data.ssh_known_hosts_fingerprints.as_json,
ssh_known_hosts_verified_at: import_data.ssh_known_hosts_verified_at,
ssh_known_hosts_verified_by_id: import_data.ssh_known_hosts_verified_by_id,
ssh_public_key: import_data.ssh_public_key
},
remote_mirrors_attributes: []
)
end
end
end
describe 'push mirror' do
let(:project) { create(:project, :repository, :remote_mirror) }
let(:remote_mirror) { project.remote_mirrors.first }
it 'represents the push mirror' do
is_expected.to eq(
id: project.id,
mirror: false,
import_url: nil,
username_only_import_url: nil,
mirror_user_id: nil,
mirror_trigger_builds: false,
import_data_attributes: nil,
remote_mirrors_attributes: [
{
id: remote_mirror.id,
url: remote_mirror.url,
enabled: true
}
]
)
end
end
end
require 'spec_helper'
describe ProjectMirrorSerializer do
it 'represents ProjectMirror entities' do
expect(described_class.entity_class).to eq(ProjectMirrorEntity)
end
end
require 'spec_helper' 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(:project) { create(:project, :mirror, :import_finished, :repository, creator: user, name: 'Victorialand') }
let(:user) { create(:user) } let(:user) { create(:user) }
describe 'On a project', js: true do describe 'On a project' do
before do before do
project.team << [user, :master] project.add_master(user)
sign_in user sign_in user
end end
...@@ -59,4 +61,157 @@ feature 'Project mirror' do ...@@ -59,4 +61,157 @@ feature 'Project mirror' do
end end
end 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 end
...@@ -54,7 +54,7 @@ describe Gitlab::BitbucketImport::Importer do ...@@ -54,7 +54,7 @@ describe Gitlab::BitbucketImport::Importer do
create( create(
:project, :project,
import_source: project_identifier, import_source: project_identifier,
import_data: ProjectImportData.new(credentials: data) import_data_attributes: { credentials: data }
) )
end end
......
require 'spec_helper'
describe Gitlab::InsecureKeyFingerprint do
let(:key) do
'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn' \
'1SJejgt4596k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qk' \
'r8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMg' \
'Jw0='
end
let(:fingerprint) { "3f:a2:ee:de:b5:de:53:c3:aa:2f:9c:45:24:4c:47:7b" }
describe "#fingerprint" do
it "generates the key's fingerprint" do
expect(described_class.new(key.split[1]).fingerprint).to eq(fingerprint)
end
end
end
require "spec_helper" require 'spec_helper'
describe "SSH Keys" do describe Gitlab::KeyFingerprint, lib: true do
let(:key) { "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt4596k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=" } KEYS = {
let(:fingerprint) { "3f:a2:ee:de:b5:de:53:c3:aa:2f:9c:45:24:4c:47:7b" } rsa:
'example.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC5z65PwQ1GE6foJgwk' \
'9rmQi/glaXbUeVa5uvQpnZ3Z5+forcI7aTngh3aZ/H2UDP2L70TGy7kKNyp0J3a8/OdG' \
'Z08y5yi3JlbjFARO1NyoFEjw2H1SJxeJ43L6zmvTlu+hlK1jSAlidl7enS0ufTlzEEj4' \
'iJcuTPKdVzKRgZuTRVm9woWNVKqIrdRC0rJiTinERnfSAp/vNYERMuaoN4oJt8p/NEek' \
'rmFoDsQOsyDW5RAnCnjWUU+jFBKDpfkJQ1U2n6BjJewC9dl6ODK639l3yN4WOLZEk4tN' \
'UysfbGeF3rmMeflaD6O1Jplpv3YhwVGFNKa7fMq6k3Z0tszTJPYh',
ecdsa:
'example.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAI' \
'bmlzdHAyNTYAAABBBKTJy43NZzJSfNxpv/e2E6Zy3qoHoTQbmOsU5FEfpWfWa1MdTeXQ' \
'YvKOi+qz/1AaNx6BK421jGu74JCDJtiZWT8=',
ed25519:
'@revoked example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjq' \
'uxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf',
dss:
'example.com ssh-dss AAAAB3NzaC1kc3MAAACBAP1/U4EddRIpUt9KnC7s5Of2EbdS' \
'PO9EAMMeP4C2USZpRV1AIlH7WT2NWPq/xfW6MPbLm1Vs14E7gB00b/JmYLdrmVClpJ+f' \
'6AR7ECLCT7up1/63xhv4O1fnxqimFQ8E+4P208UewwI1VBNaFpEy9nXzrith1yrv8iID' \
'GZ3RSAHHAAAAFQCXYFCPFSMLzLKSuYKi64QL8Fgc9QAAAIEA9+GghdabPd7LvKtcNrhX' \
'uXmUr7v6OuqC+VdMCz0HgmdRWVeOutRZT+ZxBxCBgLRJFnEj6EwoFhO3zwkyjMim4TwW' \
'eotUfI0o4KOuHiuzpnWRbqN/C/ohNWLx+2J6ASQ7zKTxvqhRkImog9/hWuWfBpKLZl6A' \
'e1UlZAFMO/7PSSoAAACBAJcQ4JODqhuGbXIEpqxetm7PWbdbCcr3y/GzIZ066pRovpL6' \
'qm3qCVIym4cyChxWwb8qlyCIi+YRUUWm1z/wiBYT2Vf3S4FXBnyymCkKEaV/EY7+jd4X' \
'1bXI58OD2u+bLCB/sInM4fGB8CZUIWT9nJH0Ve9jJUge2ms348/QOJ1+'
}.freeze
describe Gitlab::KeyFingerprint do MD5_FINGERPRINTS = {
describe "#fingerprint" do rsa: '06:b2:8a:92:df:0e:11:2c:ca:7b:8f:a4:ba:6e:4b:fd',
it "generates the key's fingerprint" do ecdsa: '45:ff:5b:98:9a:b6:8a:41:13:c1:30:8b:09:5e:7b:4e',
expect(described_class.new(key).fingerprint).to eq(fingerprint) ed25519: '2e:65:6a:c8:cf:bf:b2:8b:9a:bd:6d:9f:11:5c:12:16',
dss: '57:98:86:02:5f:9c:f4:9b:ad:5a:1e:51:92:0e:fd:2b'
}.freeze
BIT_COUNTS = {
rsa: 2048,
ecdsa: 256,
ed25519: 256,
dss: 1024
}.freeze
describe '#type' do
KEYS.each do |type, key|
it "calculates the type of #{type} keys" do
calculated_type = described_class.new(key).type
expect(calculated_type).to eq(type.to_s.upcase)
end end
end end
end end
describe Gitlab::InsecureKeyFingerprint do describe '#fingerprint' do
describe "#fingerprint" do KEYS.each do |type, key|
it "generates the key's fingerprint" do it "calculates the MD5 fingerprint for #{type} keys" do
expect(Gitlab::InsecureKeyFingerprint.new(key.split[1]).fingerprint).to eq(fingerprint) fp = described_class.new(key).fingerprint
expect(fp).to eq(MD5_FINGERPRINTS[type])
end end
end end
end end
describe '#bits' do
KEYS.each do |type, key|
it "calculates the number of bits in #{type} keys" do
bits = described_class.new(key).bits
expect(bits).to eq(BIT_COUNTS[type])
end
end
end
describe '#key' do
it 'carries the unmodified key data' do
key = described_class.new(KEYS[:rsa]).key
expect(key).to eq(KEYS[:rsa])
end
end
end end
...@@ -509,20 +509,94 @@ describe Gitlab::Shell do ...@@ -509,20 +509,94 @@ describe Gitlab::Shell do
end end
describe '#fetch_remote' do describe '#fetch_remote' do
def fetch_remote(ssh_auth = nil)
gitlab_shell.fetch_remote('current/storage', 'project/path', 'new/storage', ssh_auth: ssh_auth)
end
def expect_popen(vars = {})
popen_args = [
projects_path,
'fetch-remote',
'current/storage',
'project/path.git',
'new/storage',
Gitlab.config.gitlab_shell.git_timeout.to_s
]
expect(Gitlab::Popen).to receive(:popen).with(popen_args, nil, popen_vars.merge(vars))
end
def build_ssh_auth(opts = {})
defaults = {
ssh_import?: true,
ssh_key_auth?: false,
ssh_known_hosts: nil,
ssh_private_key: nil
}
double(:ssh_auth, defaults.merge(opts))
end
it 'returns true when the command succeeds' do it 'returns true when the command succeeds' do
expect(Gitlab::Popen).to receive(:popen) expect_popen.and_return([nil, 0])
.with([projects_path, 'fetch-remote', 'current/storage', 'project/path.git', 'new/storage', '800'],
nil, popen_vars).and_return([nil, 0])
expect(gitlab_shell.fetch_remote('current/storage', 'project/path', 'new/storage')).to be true expect(fetch_remote).to be_truthy
end end
it 'raises an exception when the command fails' do it 'raises an exception when the command fails' do
expect(Gitlab::Popen).to receive(:popen) expect_popen.and_return(["error", 1])
.with([projects_path, 'fetch-remote', 'current/storage', 'project/path.git', 'new/storage', '800'],
nil, popen_vars).and_return(["error", 1]) expect { fetch_remote }.to raise_error(Gitlab::Shell::Error, "error")
end
context 'SSH auth' do
it 'passes the SSH key if specified' do
expect_popen('GITLAB_SHELL_SSH_KEY' => 'foo').and_return([nil, 0])
ssh_auth = build_ssh_auth(ssh_key_auth?: true, ssh_private_key: 'foo')
expect(fetch_remote(ssh_auth)).to be_truthy
end
it 'does not pass an empty SSH key' do
expect_popen.and_return([nil, 0])
expect { gitlab_shell.fetch_remote('current/storage', 'project/path', 'new/storage') }.to raise_error(Gitlab::Shell::Error, "error") ssh_auth = build_ssh_auth(ssh_key_auth: true, ssh_private_key: '')
expect(fetch_remote(ssh_auth)).to be_truthy
end
it 'does not pass the key unless SSH key auth is to be used' do
expect_popen.and_return([nil, 0])
ssh_auth = build_ssh_auth(ssh_key_auth: false, ssh_private_key: 'foo')
expect(fetch_remote(ssh_auth)).to be_truthy
end
it 'passes the known_hosts data if specified' do
expect_popen('GITLAB_SHELL_KNOWN_HOSTS' => 'foo').and_return([nil, 0])
ssh_auth = build_ssh_auth(ssh_known_hosts: 'foo')
expect(fetch_remote(ssh_auth)).to be_truthy
end
it 'does not pass empty known_hosts data' do
expect_popen.and_return([nil, 0])
ssh_auth = build_ssh_auth(ssh_known_hosts: '')
expect(fetch_remote(ssh_auth)).to be_truthy
end
it 'does not pass known_hosts data unless SSH is to be used' do
expect_popen(popen_vars).and_return([nil, 0])
ssh_auth = build_ssh_auth(ssh_import?: false, ssh_known_hosts: 'foo')
expect(fetch_remote(ssh_auth)).to be_truthy
end
end end
end end
......
...@@ -83,15 +83,6 @@ describe Key, :mailer do ...@@ -83,15 +83,6 @@ describe Key, :mailer do
expect(build(:key)).to be_valid expect(build(:key)).to be_valid
end end
it 'rejects an unfingerprintable key that contains a space' do
key = build(:key)
# Not always the middle, but close enough
key.key = key.key[0..100] + ' ' + key.key[101..-1]
expect(key).not_to be_valid
end
it 'accepts a key with newline charecters after stripping them' do it 'accepts a key with newline charecters after stripping them' do
key = build(:key) key = build(:key)
key.key = key.key.insert(100, "\n") key.key = key.key.insert(100, "\n")
...@@ -102,7 +93,6 @@ describe Key, :mailer do ...@@ -102,7 +93,6 @@ describe Key, :mailer do
it 'rejects the unfingerprintable key (not a key)' do it 'rejects the unfingerprintable key (not a key)' do
expect(build(:key, key: 'ssh-rsa an-invalid-key==')).not_to be_valid expect(build(:key, key: 'ssh-rsa an-invalid-key==')).not_to be_valid
end end
end end
context 'callbacks' do context 'callbacks' do
......
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