Skip to content

Commit 2a31b8d

Browse files
author
Nick Thomas
committed
Implement SSH authentication support in Ruby
1 parent cdea863 commit 2a31b8d

File tree

2 files changed

+139
-19
lines changed

2 files changed

+139
-19
lines changed

lib/gitlab_projects.rb

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -211,20 +211,23 @@ def fetch_remote
211211
cmd = %W(git --git-dir=#{full_path} fetch #{@name} --prune --quiet)
212212
cmd << '--force' if forced
213213
cmd << tags_option
214-
pid = Process.spawn(*cmd)
215214

216-
begin
217-
Timeout.timeout(timeout) do
218-
Process.wait(pid)
219-
end
215+
setup_ssh_auth do |env|
216+
pid = Process.spawn(env, *cmd)
220217

221-
$?.exitstatus.zero?
222-
rescue => exception
223-
$logger.error "Fetching remote #{@name} for project #{@project_name} failed due to: #{exception.message}."
218+
begin
219+
_, status = Timeout.timeout(timeout) do
220+
Process.wait2(pid)
221+
end
224222

225-
Process.kill('KILL', pid)
226-
Process.wait
227-
false
223+
status.success?
224+
rescue => exception
225+
$logger.error "Fetching remote #{@name} for project #{@project_name} failed due to: #{exception.message}."
226+
227+
Process.kill('KILL', pid)
228+
Process.wait
229+
false
230+
end
228231
end
229232
end
230233

@@ -406,6 +409,59 @@ def wait_for_pushes
406409
false
407410
end
408411

412+
# Builds a small shell script that can be used to execute SSH with a set of
413+
# custom options.
414+
#
415+
# Options are expanded as `'-oKey="Value"'`, so SSH will correctly interpret
416+
# paths with spaces in them. We trust the user not to embed single or double
417+
# quotes in the key or value.
418+
def custom_ssh_script(options = {})
419+
args = options.map { |k, v| "'-o#{k}=\"#{v}\"'" }.join(' ')
420+
421+
[
422+
"#!/bin/sh",
423+
"exec ssh #{args} \"$@\""
424+
].join("\n")
425+
end
426+
427+
# Known hosts data and private keys can be passed to gitlab-shell in the
428+
# environment. If present, this method puts them into temporary files, writes
429+
# a script that can substitute as `ssh`, setting the options to respect those
430+
# files, and yields: { "GIT_SSH" => "/tmp/myScript" }
431+
def setup_ssh_auth
432+
options = {}
433+
434+
if ENV.key?('GITLAB_SHELL_SSH_KEY')
435+
key_file = Tempfile.new('gitlab-shell-key-file', mode: 0o400)
436+
key_file.write(ENV['GITLAB_SHELL_SSH_KEY'])
437+
key_file.close
438+
439+
options['IdentityFile'] = key_file.path
440+
options['IdentitiesOnly'] = true
441+
end
442+
443+
if ENV.key?('GITLAB_SHELL_KNOWN_HOSTS')
444+
known_hosts_file = Tempfile.new('gitlab-shell-known-hosts', mode: 0o400)
445+
known_hosts_file.write(ENV['GITLAB_SHELL_KNOWN_HOSTS'])
446+
known_hosts_file.close
447+
448+
options['StrictHostKeyChecking'] = true
449+
options['UserKnownHostsFile'] = known_hosts_file.path
450+
end
451+
452+
return yield({}) if options.empty?
453+
454+
script = Tempfile.new('gitlab-shell-ssh-wrapper', mode: 0o755)
455+
script.write(custom_ssh_script(options))
456+
script.close
457+
458+
yield('GIT_SSH' => script.path)
459+
ensure
460+
key_file.close! unless key_file.nil?
461+
known_hosts_file.close! unless known_hosts_file.nil?
462+
script.close! unless script.nil?
463+
end
464+
409465
def gitlab_reference_counter
410466
@gitlab_reference_counter ||= begin
411467
# Defer loading because this pulls in gitlab_net, which takes 100-200 ms

spec/gitlab_projects_spec.rb

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
require_relative 'spec_helper'
22
require_relative '../lib/gitlab_projects'
3+
require_relative '../lib/gitlab_reference_counter'
34

45
describe GitlabProjects do
56
before do
@@ -322,33 +323,55 @@
322323
let(:pid) { 1234 }
323324
let(:branch_name) { 'master' }
324325

326+
def stub_spawn(*args, wait: true, success: true)
327+
expect(Process).to receive(:spawn).with(*args).and_return(pid)
328+
expect(Process).to receive(:wait2).with(pid).and_return([pid, double(success?: success)]) if wait
329+
end
330+
331+
def stub_env(args = {})
332+
original = ENV.to_h
333+
args.each { |k, v| ENV[k] = v }
334+
yield
335+
ensure
336+
ENV.replace(original)
337+
end
338+
339+
def stub_tempfile(name, *args)
340+
file = StringIO.new
341+
allow(file).to receive(:close!)
342+
allow(file).to receive(:path).and_return(name)
343+
344+
expect(Tempfile).to receive(:new).with(*args).and_return(file)
345+
346+
file
347+
end
348+
325349
describe 'with default args' do
326350
let(:gl_projects) { build_gitlab_projects('fetch-remote', repos_path, project_name, remote_name, '600') }
327351
let(:cmd) { %W(git --git-dir=#{full_path} fetch #{remote_name} --prune --quiet --tags) }
328352

329353
it 'executes the command' do
330-
expect(Process).to receive(:spawn).with(*cmd).and_return(pid)
331-
expect(Process).to receive(:wait).with(pid)
354+
stub_spawn({}, *cmd)
332355

333356
expect(gl_projects.exec).to be true
334357
end
335358

336359
it 'raises timeout' do
360+
stub_spawn({}, *cmd, wait: false)
337361
expect(Timeout).to receive(:timeout).with(600).and_raise(Timeout::Error)
338-
expect(Process).to receive(:spawn).with(*cmd).and_return(pid)
339-
expect(Process).to receive(:wait)
340362
expect(Process).to receive(:kill).with('KILL', pid)
363+
341364
expect(gl_projects.exec).to be false
342365
end
343366
end
344367

345368
describe 'with --force' do
346369
let(:gl_projects) { build_gitlab_projects('fetch-remote', repos_path, project_name, remote_name, '600', '--force') }
370+
let(:env) { {} }
347371
let(:cmd) { %W(git --git-dir=#{full_path} fetch #{remote_name} --prune --quiet --force --tags) }
348372

349373
it 'executes the command with forced option' do
350-
expect(Process).to receive(:spawn).with(*cmd).and_return(pid)
351-
expect(Process).to receive(:wait).with(pid)
374+
stub_spawn({}, *cmd)
352375

353376
expect(gl_projects.exec).to be true
354377
end
@@ -359,12 +382,53 @@
359382
let(:cmd) { %W(git --git-dir=#{full_path} fetch #{remote_name} --prune --quiet --no-tags) }
360383

361384
it 'executes the command' do
362-
expect(Process).to receive(:spawn).with(*cmd).and_return(pid)
363-
expect(Process).to receive(:wait).with(pid)
385+
stub_spawn({}, *cmd)
364386

365387
expect(gl_projects.exec).to be true
366388
end
367389
end
390+
391+
describe 'with GITLAB_SHELL_SSH_KEY' do
392+
let(:gl_projects) { build_gitlab_projects('fetch-remote', repos_path, project_name, remote_name, '600') }
393+
let(:cmd) { %W(git --git-dir=#{full_path} fetch #{remote_name} --prune --quiet --tags) }
394+
395+
around(:each) do |example|
396+
stub_env('GITLAB_SHELL_SSH_KEY' => 'SSH KEY') { example.run }
397+
end
398+
399+
it 'sets GIT_SSH to a custom script' do
400+
script = stub_tempfile('scriptFile', 'gitlab-shell-ssh-wrapper', mode: 0755)
401+
key = stub_tempfile('/tmp files/keyFile', 'gitlab-shell-key-file', mode: 0400)
402+
403+
stub_spawn({ 'GIT_SSH' => 'scriptFile' }, *cmd)
404+
405+
expect(gl_projects.exec).to be true
406+
407+
expect(script.string).to eq("#!/bin/sh\nexec ssh '-oIdentityFile=\"/tmp files/keyFile\"' '-oIdentitiesOnly=\"true\"' \"$@\"")
408+
expect(key.string).to eq('SSH KEY')
409+
end
410+
end
411+
412+
describe 'with GITLAB_SHELL_KNOWN_HOSTS' do
413+
let(:gl_projects) { build_gitlab_projects('fetch-remote', repos_path, project_name, remote_name, '600') }
414+
let(:cmd) { %W(git --git-dir=#{full_path} fetch #{remote_name} --prune --quiet --tags) }
415+
416+
around(:each) do |example|
417+
stub_env('GITLAB_SHELL_KNOWN_HOSTS' => 'KNOWN HOSTS') { example.run }
418+
end
419+
420+
it 'sets GIT_SSH to a custom script' do
421+
script = stub_tempfile('scriptFile', 'gitlab-shell-ssh-wrapper', mode: 0755)
422+
key = stub_tempfile('/tmp files/knownHosts', 'gitlab-shell-known-hosts', mode: 0400)
423+
424+
stub_spawn({ 'GIT_SSH' => 'scriptFile' }, *cmd)
425+
426+
expect(gl_projects.exec).to be true
427+
428+
expect(script.string).to eq("#!/bin/sh\nexec ssh '-oStrictHostKeyChecking=\"true\"' '-oUserKnownHostsFile=\"/tmp files/knownHosts\"' \"$@\"")
429+
expect(key.string).to eq('KNOWN HOSTS')
430+
end
431+
end
368432
end
369433

370434
describe :import_project do

0 commit comments

Comments
 (0)