summaryrefslogtreecommitdiff
path: root/lib/gitlab_shell.rb
diff options
context:
space:
mode:
Diffstat (limited to 'lib/gitlab_shell.rb')
-rw-r--r--lib/gitlab_shell.rb309
1 files changed, 245 insertions, 64 deletions
diff --git a/lib/gitlab_shell.rb b/lib/gitlab_shell.rb
index bd7b783..2057ea9 100644
--- a/lib/gitlab_shell.rb
+++ b/lib/gitlab_shell.rb
@@ -3,120 +3,301 @@ require 'pathname'
require_relative 'gitlab_net'
require_relative 'gitlab_metrics'
-require_relative 'actor'
-class GitlabShell
- API_2FA_RECOVERY_CODES_COMMAND = '2fa_recovery_codes'.freeze
+class GitlabShell # rubocop:disable Metrics/ClassLength
+ class AccessDeniedError < StandardError; end
+ class DisallowedCommandError < StandardError; end
+ class InvalidRepositoryPathError < StandardError; end
- GIT_UPLOAD_PACK_COMMAND = 'git-upload-pack'.freeze
- GIT_RECEIVE_PACK_COMMAND = 'git-receive-pack'.freeze
- GIT_UPLOAD_ARCHIVE_COMMAND = 'git-upload-archive'.freeze
- GIT_LFS_AUTHENTICATE_COMMAND = 'git-lfs-authenticate'.freeze
+ GIT_COMMANDS = %w(git-upload-pack git-receive-pack git-upload-archive git-lfs-authenticate).freeze
+ GITALY_MIGRATED_COMMANDS = {
+ 'git-upload-pack' => File.join(ROOT_PATH, 'bin', 'gitaly-upload-pack'),
+ 'git-upload-archive' => File.join(ROOT_PATH, 'bin', 'gitaly-upload-archive'),
+ 'git-receive-pack' => File.join(ROOT_PATH, 'bin', 'gitaly-receive-pack')
+ }.freeze
+ API_COMMANDS = %w(2fa_recovery_codes).freeze
+ GL_PROTOCOL = 'ssh'.freeze
- GIT_COMMANDS = [GIT_UPLOAD_PACK_COMMAND, GIT_RECEIVE_PACK_COMMAND,
- GIT_UPLOAD_ARCHIVE_COMMAND, GIT_LFS_AUTHENTICATE_COMMAND].freeze
-
- Struct.new('ParsedCommand', :command, :git_access_command, :repo_name, :args)
+ attr_accessor :gl_id, :gl_repository, :repo_name, :command, :git_access, :git_protocol
+ attr_reader :repo_path
def initialize(who)
+ who_sym, = GitlabNet.parse_who(who)
+ if who_sym == :username
+ @who = who
+ else
+ @gl_id = who
+ end
@config = GitlabConfig.new
- @actor = Actor.new_from(who, audit_usernames: @config.audit_usernames)
end
# The origin_cmd variable contains UNTRUSTED input. If the user ran
# ssh git@gitlab.example.com 'evil command', then origin_cmd contains
# 'evil command'.
def exec(origin_cmd)
- if !origin_cmd || origin_cmd.empty?
- puts "Welcome to GitLab, #{actor.username}!"
+ unless origin_cmd
+ puts "Welcome to GitLab, #{username}!"
return true
end
- parsed_command = parse_cmd(origin_cmd)
- action = determine_action(parsed_command)
- action.execute(parsed_command.command, parsed_command.args)
+ args = Shellwords.shellwords(origin_cmd)
+ args = parse_cmd(args)
+
+ if GIT_COMMANDS.include?(args.first)
+ GitlabMetrics.measure('verify-access') { verify_access }
+ elsif !defined?(@gl_id)
+ # We're processing an API command like 2fa_recovery_codes, but
+ # don't have a @gl_id yet, that means we're in the "username"
+ # mode and need to materialize it, calling the "user" method
+ # will do that and call the /discover method.
+ user
+ end
+
+ process_cmd(args)
+
+ true
rescue GitlabNet::ApiUnreachableError
$stderr.puts "GitLab: Failed to authorize your Git request: internal API unreachable"
false
- rescue AccessDeniedError, UnknownError => ex
- $logger.warn('Access denied', command: origin_cmd, user: actor.log_username)
+ rescue AccessDeniedError => ex
+ $logger.warn('Access denied', command: origin_cmd, user: log_username)
+
$stderr.puts "GitLab: #{ex.message}"
false
rescue DisallowedCommandError
- $logger.warn('Denied disallowed command', command: origin_cmd, user: actor.log_username)
- $stderr.puts 'GitLab: Disallowed command'
+ $logger.warn('Denied disallowed command', command: origin_cmd, user: log_username)
+
+ $stderr.puts "GitLab: Disallowed command"
false
rescue InvalidRepositoryPathError
- $stderr.puts 'GitLab: Invalid repository path'
+ $stderr.puts "GitLab: Invalid repository path"
false
end
- private
-
- attr_reader :config, :actor
-
- def parse_cmd(cmd)
- args = Shellwords.shellwords(cmd)
+ protected
+ def parse_cmd(args)
# Handle Git for Windows 2.14 using "git upload-pack" instead of git-upload-pack
if args.length == 3 && args.first == 'git'
- command = "git-#{args[1]}"
- args = [command, args.last]
+ @command = "git-#{args[1]}"
+ args = [@command, args.last]
else
- command = args.first
+ @command = args.first
end
- git_access_command = command
+ @git_access = @command
- if command == API_2FA_RECOVERY_CODES_COMMAND
- return Struct::ParsedCommand.new(command, git_access_command, nil, args)
- end
+ return args if API_COMMANDS.include?(@command)
- raise DisallowedCommandError unless GIT_COMMANDS.include?(command)
+ raise DisallowedCommandError unless GIT_COMMANDS.include?(@command)
- case command
+ case @command
when 'git-lfs-authenticate'
raise DisallowedCommandError unless args.count >= 2
- repo_name = args[1]
- git_access_command = case args[2]
- when 'download'
- GIT_UPLOAD_PACK_COMMAND
- when 'upload'
- GIT_RECEIVE_PACK_COMMAND
- else
- raise DisallowedCommandError
- end
+ @repo_name = args[1]
+ case args[2]
+ when 'download'
+ @git_access = 'git-upload-pack'
+ when 'upload'
+ @git_access = 'git-receive-pack'
+ else
+ raise DisallowedCommandError
+ end
else
raise DisallowedCommandError unless args.count == 2
- repo_name = args.last
+ @repo_name = args.last
end
- Struct::ParsedCommand.new(command, git_access_command, repo_name, args)
+ args
end
- def determine_action(parsed_command)
- return Action::API2FARecovery.new(actor) if parsed_command.command == API_2FA_RECOVERY_CODES_COMMAND
+ def verify_access
+ status = api.check_access(@git_access, nil, @repo_name, @who || @gl_id, '_any', GL_PROTOCOL)
+
+ raise AccessDeniedError, status.message unless status.allowed?
+
+ self.repo_path = status.repository_path
+ @gl_repository = status.gl_repository
+ @git_protocol = ENV['GIT_PROTOCOL']
+ @gitaly = status.gitaly
+ @username = status.gl_username
+ @git_config_options = status.git_config_options
+ if defined?(@who)
+ @gl_id = status.gl_id
+ end
+ end
- GitlabMetrics.measure('verify-access') do
- # GitlabNet#check_access will raise exception in the event of a problem
- initial_action = api.check_access(
- parsed_command.git_access_command,
- nil,
- parsed_command.repo_name,
- actor,
- '_any'
+ def process_cmd(args)
+ return send("api_#{@command}") if API_COMMANDS.include?(@command)
+
+ if @command == 'git-lfs-authenticate'
+ GitlabMetrics.measure('lfs-authenticate') do
+ $logger.info('Processing LFS authentication', user: log_username)
+ lfs_authenticate
+ end
+ return
+ end
+
+ executable = @command
+ args = [repo_path]
+
+ if GITALY_MIGRATED_COMMANDS.key?(executable) && @gitaly
+ executable = GITALY_MIGRATED_COMMANDS[executable]
+
+ gitaly_address = @gitaly['address']
+
+ # The entire gitaly_request hash should be built in gitlab-ce and passed
+ # on as-is. For now we build a fake one on the spot.
+ gitaly_request = {
+ 'repository' => @gitaly['repository'],
+ 'gl_repository' => @gl_repository,
+ 'gl_id' => @gl_id,
+ 'gl_username' => @username,
+ 'git_config_options' => @git_config_options,
+ 'git_protocol' => @git_protocol
+ }
+
+ args = [gitaly_address, JSON.dump(gitaly_request)]
+ end
+
+ args_string = [File.basename(executable), *args].join(' ')
+ $logger.info('executing git command', command: args_string, user: log_username)
+ exec_cmd(executable, *args)
+ end
+
+ # This method is not covered by Rspec because it ends the current Ruby process.
+ def exec_cmd(*args)
+ # If you want to call a command without arguments, use
+ # exec_cmd(['my_command', 'my_command']) . Otherwise use
+ # exec_cmd('my_command', 'my_argument', ...).
+ if args.count == 1 && !args.first.is_a?(Array)
+ raise DisallowedCommandError
+ end
+
+ env = {
+ 'HOME' => ENV['HOME'],
+ 'PATH' => ENV['PATH'],
+ 'LD_LIBRARY_PATH' => ENV['LD_LIBRARY_PATH'],
+ 'LANG' => ENV['LANG'],
+ 'GL_ID' => @gl_id,
+ 'GL_PROTOCOL' => GL_PROTOCOL,
+ 'GL_REPOSITORY' => @gl_repository,
+ 'GL_USERNAME' => @username
+ }
+ if @gitaly && @gitaly.include?('token')
+ env['GITALY_TOKEN'] = @gitaly['token']
+ end
+
+ if git_trace_available?
+ env.merge!(
+ 'GIT_TRACE' => @config.git_trace_log_file,
+ 'GIT_TRACE_PACKET' => @config.git_trace_log_file,
+ 'GIT_TRACE_PERFORMANCE' => @config.git_trace_log_file
)
+ end
- case parsed_command.command
- when GIT_LFS_AUTHENTICATE_COMMAND
- Action::GitLFSAuthenticate.new(actor, parsed_command.repo_name)
+ # We use 'chdir: ROOT_PATH' to let the next executable know where config.yml is.
+ Kernel.exec(env, *args, unsetenv_others: true, chdir: ROOT_PATH)
+ end
+
+ def api
+ GitlabNet.new
+ end
+
+ def user
+ return @user if defined?(@user)
+
+ begin
+ if defined?(@who)
+ @user = api.discover(@who)
+ @gl_id = "user-#{@user['id']}"
else
- initial_action
+ @user = api.discover(@gl_id)
end
+ rescue GitlabNet::ApiUnreachableError
+ @user = nil
end
end
- def api
- @api ||= GitlabNet.new
+ def username_from_discover
+ return nil unless user && user['username']
+
+ "@#{user['username']}"
+ end
+
+ def username
+ @username ||= username_from_discover || 'Anonymous'
+ end
+
+ # User identifier to be used in log messages.
+ def log_username
+ @config.audit_usernames ? username : "user with id #{@gl_id}"
+ end
+
+ def lfs_authenticate
+ lfs_access = api.lfs_authenticate(@gl_id, @repo_name)
+
+ return unless lfs_access
+
+ puts lfs_access.authentication_payload
+ end
+
+ private
+
+ def continue?(question)
+ puts "#{question} (yes/no)"
+ STDOUT.flush # Make sure the question gets output before we wait for input
+ continue = STDIN.gets.chomp
+ puts '' # Add a buffer in the output
+ continue == 'yes'
+ end
+
+ def api_2fa_recovery_codes
+ continue = continue?(
+ "Are you sure you want to generate new two-factor recovery codes?\n" \
+ "Any existing recovery codes you saved will be invalidated."
+ )
+
+ unless continue
+ puts 'New recovery codes have *not* been generated. Existing codes will remain valid.'
+ return
+ end
+
+ resp = api.two_factor_recovery_codes(@gl_id)
+ if resp['success']
+ codes = resp['recovery_codes'].join("\n")
+ puts "Your two-factor authentication recovery codes are:\n\n" \
+ "#{codes}\n\n" \
+ "During sign in, use one of the codes above when prompted for\n" \
+ "your two-factor code. Then, visit your Profile Settings and add\n" \
+ "a new device so you do not lose access to your account again."
+ else
+ puts "An error occurred while trying to generate new recovery codes.\n" \
+ "#{resp['message']}"
+ end
+ end
+
+ def git_trace_available?
+ return false unless @config.git_trace_log_file
+
+ if Pathname(@config.git_trace_log_file).relative?
+ $logger.warn('git trace log path must be absolute, ignoring', git_trace_log_file: @config.git_trace_log_file)
+ return false
+ end
+
+ begin
+ File.open(@config.git_trace_log_file, 'a') { nil }
+ return true
+ rescue => ex
+ $logger.warn('Failed to open git trace log file', git_trace_log_file: @config.git_trace_log_file, error: ex.to_s)
+ return false
+ end
+ end
+
+ def repo_path=(repo_path)
+ raise ArgumentError, "Repository path not provided. Please make sure you're using GitLab v8.10 or later." unless repo_path
+ raise InvalidRepositoryPathError if File.absolute_path(repo_path) != repo_path
+
+ @repo_path = repo_path
end
end