diff options
author | Robert Speicher <robert@gitlab.com> | 2018-08-08 17:38:38 +0000 |
---|---|---|
committer | Robert Speicher <robert@gitlab.com> | 2018-08-08 17:38:38 +0000 |
commit | 3aaf4751e09262c53544a1987f59b1308af9b6c1 (patch) | |
tree | 019332604ac4853db5d80bca95f229c95d1fc298 /lib | |
parent | c6577e0d75f51b017f2f332838b97c3ca5b497c0 (diff) | |
parent | 014691e057537a803e22223ea072065cc91938a7 (diff) | |
download | gitlab-shell-3aaf4751e09262c53544a1987f59b1308af9b6c1.tar.gz |
Merge branch 'ash.mckenzie/srp-refactor' into 'master'
Refactor that focuses on SRP improvements
See merge request gitlab-org/gitlab-shell!214
Diffstat (limited to 'lib')
-rw-r--r-- | lib/action.rb | 7 | ||||
-rw-r--r-- | lib/action/api_2fa_recovery.rb | 54 | ||||
-rw-r--r-- | lib/action/base.rb | 27 | ||||
-rw-r--r-- | lib/action/git_lfs_authenticate.rb | 26 | ||||
-rw-r--r-- | lib/action/gitaly.rb | 126 | ||||
-rw-r--r-- | lib/actor.rb | 21 | ||||
-rw-r--r-- | lib/actor/base.rb | 56 | ||||
-rw-r--r-- | lib/actor/key.rb | 29 | ||||
-rw-r--r-- | lib/actor/user.rb | 19 | ||||
-rw-r--r-- | lib/actor/username.rb | 25 | ||||
-rw-r--r-- | lib/errors.rb | 4 | ||||
-rw-r--r-- | lib/gitlab_access.rb | 36 | ||||
-rw-r--r-- | lib/gitlab_access_status.rb | 32 | ||||
-rw-r--r-- | lib/gitlab_custom_hook.rb | 4 | ||||
-rw-r--r-- | lib/gitlab_keys.rb | 2 | ||||
-rw-r--r-- | lib/gitlab_net.rb | 149 | ||||
-rw-r--r-- | lib/gitlab_post_receive.rb | 18 | ||||
-rw-r--r-- | lib/gitlab_shell.rb | 307 | ||||
-rw-r--r-- | lib/http_helper.rb | 19 |
19 files changed, 564 insertions, 397 deletions
diff --git a/lib/action.rb b/lib/action.rb new file mode 100644 index 0000000..1f9cc6c --- /dev/null +++ b/lib/action.rb @@ -0,0 +1,7 @@ +require_relative 'action/base' +require_relative 'action/gitaly' +require_relative 'action/git_lfs_authenticate' +require_relative 'action/api_2fa_recovery' + +module Action +end diff --git a/lib/action/api_2fa_recovery.rb b/lib/action/api_2fa_recovery.rb new file mode 100644 index 0000000..06f8057 --- /dev/null +++ b/lib/action/api_2fa_recovery.rb @@ -0,0 +1,54 @@ +require_relative '../action' +require_relative '../gitlab_logger' + +module Action + class API2FARecovery < Base + def initialize(actor) + @actor = actor + end + + def execute(_, _) + recover + end + + private + + attr_reader :actor + + def continue?(question) + puts "#{question} (yes/no)" + STDOUT.flush # Make sure the question gets output before we wait for input + response = STDIN.gets.chomp + puts '' # Add a buffer in the output + response == 'yes' + end + + def recover + 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(actor) + if resp['success'] + codes = resp['recovery_codes'].join("\n") + $logger.info('API 2FA recovery success', user: actor.log_username) + 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." + true + else + $logger.info('API 2FA recovery error', user: actor.log_username) + puts "An error occurred while trying to generate new recovery codes.\n" \ + "#{resp['message']}" + end + end + end +end diff --git a/lib/action/base.rb b/lib/action/base.rb new file mode 100644 index 0000000..fe8c836 --- /dev/null +++ b/lib/action/base.rb @@ -0,0 +1,27 @@ +require 'json' + +require_relative '../gitlab_config' +require_relative '../gitlab_net' +require_relative '../gitlab_metrics' + +module Action + class Base + def initialize + raise NotImplementedError + end + + def self.create_from_json(_) + raise NotImplementedError + end + + private + + def config + @config ||= GitlabConfig.new + end + + def api + @api ||= GitlabNet.new + end + end +end diff --git a/lib/action/git_lfs_authenticate.rb b/lib/action/git_lfs_authenticate.rb new file mode 100644 index 0000000..8c5294d --- /dev/null +++ b/lib/action/git_lfs_authenticate.rb @@ -0,0 +1,26 @@ +require_relative '../action' +require_relative '../gitlab_logger' + +module Action + class GitLFSAuthenticate < Base + def initialize(actor, repo_name) + @actor = actor + @repo_name = repo_name + end + + def execute(_, _) + GitlabMetrics.measure('lfs-authenticate') do + $logger.info('Processing LFS authentication', user: actor.log_username) + lfs_access = api.lfs_authenticate(actor, repo_name) + return unless lfs_access + + puts lfs_access.authentication_payload + end + true + end + + private + + attr_reader :actor, :repo_name + end +end diff --git a/lib/action/gitaly.rb b/lib/action/gitaly.rb new file mode 100644 index 0000000..b95ff17 --- /dev/null +++ b/lib/action/gitaly.rb @@ -0,0 +1,126 @@ +require_relative '../action' +require_relative '../gitlab_logger' +require_relative '../gitlab_net' + +module Action + class Gitaly < Base + REPOSITORY_PATH_NOT_PROVIDED = "Repository path not provided. Please make sure you're using GitLab v8.10 or later.".freeze + 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 + + def initialize(actor, gl_repository, gl_username, git_protocol, repository_path, gitaly) + @actor = actor + @gl_repository = gl_repository + @gl_username = gl_username + @git_protocol = git_protocol + @repository_path = repository_path + @gitaly = gitaly + end + + def self.create_from_json(actor, json) + new(actor, + json['gl_repository'], + json['gl_username'], + json['git_protocol'], + json['repository_path'], + json['gitaly']) + end + + def execute(command, args) + raise ArgumentError, REPOSITORY_PATH_NOT_PROVIDED unless repository_path + raise InvalidRepositoryPathError unless valid_repository? + + $logger.info('Performing Gitaly command', user: actor.log_username) + process(command, args) + end + + private + + attr_reader :actor, :gl_repository, :gl_username, :repository_path, :gitaly + + def git_protocol + @git_protocol || ENV['GIT_PROTOCOL'] # TODO: tidy this up + end + + def process(command, args) + executable = command + args = [repository_path] + + if MIGRATED_COMMANDS.key?(executable) && gitaly + executable = MIGRATED_COMMANDS[executable] + gitaly_address = gitaly['address'] + args = [gitaly_address, JSON.dump(gitaly_request)] + end + + args_string = [File.basename(executable), *args].join(' ') + $logger.info('executing git command', command: args_string, user: actor.log_username) + + exec_cmd(executable, *args) + end + + def exec_cmd(*args) + env = exec_env + env['GITALY_TOKEN'] = gitaly['token'] if gitaly && gitaly.include?('token') + + 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 + + # 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 exec_env + { + 'HOME' => ENV['HOME'], + 'PATH' => ENV['PATH'], + 'LD_LIBRARY_PATH' => ENV['LD_LIBRARY_PATH'], + 'LANG' => ENV['LANG'], + 'GL_ID' => actor.identifier, + 'GL_PROTOCOL' => GitlabNet::GL_PROTOCOL, + 'GL_REPOSITORY' => gl_repository, + 'GL_USERNAME' => gl_username + } + end + + def gitaly_request + # 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. + { + 'repository' => gitaly['repository'], + 'gl_repository' => gl_repository, + 'gl_id' => actor.identifier, + 'gl_username' => gl_username, + 'git_protocol' => git_protocol + } + end + + def valid_repository? + File.absolute_path(repository_path) == repository_path + 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 + end +end diff --git a/lib/actor.rb b/lib/actor.rb new file mode 100644 index 0000000..4e8b3b8 --- /dev/null +++ b/lib/actor.rb @@ -0,0 +1,21 @@ +require_relative 'actor/base' +require_relative 'actor/key' +require_relative 'actor/user' +require_relative 'actor/username' + +module Actor + class UnsupportedActorError < StandardError; end + + def self.new_from(str, audit_usernames: false) + case str + when Key.id_regex + Key.from(str, audit_usernames: audit_usernames) + when User.id_regex + User.from(str, audit_usernames: audit_usernames) + when Username.id_regex + Username.from(str, audit_usernames: audit_usernames) + else + raise UnsupportedActorError + end + end +end diff --git a/lib/actor/base.rb b/lib/actor/base.rb new file mode 100644 index 0000000..0510c60 --- /dev/null +++ b/lib/actor/base.rb @@ -0,0 +1,56 @@ +module Actor + class Base + attr_reader :id + + def initialize(id, audit_usernames: false) + @id = id + @audit_usernames = audit_usernames + end + + def self.from(str, audit_usernames: false) + new(str.gsub(/#{identifier_prefix}-/, ''), audit_usernames: audit_usernames) + end + + def self.identifier_key + raise NotImplementedError + end + + def self.identifier_prefix + raise NotImplementedError + end + + def self.id_regex + raise NotImplementedError + end + + def username + raise NotImplementedError + end + + def identifier + "#{self.class.identifier_prefix}-#{id}" + end + + def identifier_key + self.class.identifier_key + end + + def log_username + audit_usernames? ? username : "#{label} with identifier #{identifier}" + end + + private + + attr_reader :audit_usernames + + alias audit_usernames? audit_usernames + + def klass_name + self.class.to_s.split('::')[-1] + end + + def label + klass_name.downcase + end + end +end diff --git a/lib/actor/key.rb b/lib/actor/key.rb new file mode 100644 index 0000000..46f013a --- /dev/null +++ b/lib/actor/key.rb @@ -0,0 +1,29 @@ +require_relative 'base' +require_relative '../gitlab_net' + +module Actor + class Key < Base + ANONYMOUS_USER = 'Anonymous'.freeze + + alias key_id id + + def self.identifier_prefix + 'key'.freeze + end + + def self.identifier_key + 'key_id'.freeze + end + + def self.id_regex + /\Akey\-\d+\Z/ + end + + def username + @username ||= begin + user = GitlabNet.new.discover(self) + user ? "@#{user['username']}" : ANONYMOUS_USER + end + end + end +end diff --git a/lib/actor/user.rb b/lib/actor/user.rb new file mode 100644 index 0000000..55ba7f1 --- /dev/null +++ b/lib/actor/user.rb @@ -0,0 +1,19 @@ +require_relative 'base' + +module Actor + class User < Base + alias username identifier + + def self.identifier_prefix + 'user'.freeze + end + + def self.identifier_key + 'user_id'.freeze + end + + def self.id_regex + /\Auser\-\d+\Z/ + end + end +end diff --git a/lib/actor/username.rb b/lib/actor/username.rb new file mode 100644 index 0000000..cd9d6e5 --- /dev/null +++ b/lib/actor/username.rb @@ -0,0 +1,25 @@ +require_relative 'base' +require_relative 'key' + +module Actor + class Username < Key + def self.identifier_prefix + 'username'.freeze + end + + def self.identifier_key + 'username'.freeze + end + + def self.id_regex + /\Ausername\-[a-z0-9-]+\z/ + end + + private + + # Override Base#label + def label + 'user' + end + end +end diff --git a/lib/errors.rb b/lib/errors.rb new file mode 100644 index 0000000..7bef3b4 --- /dev/null +++ b/lib/errors.rb @@ -0,0 +1,4 @@ +class UnknownError < StandardError; end +class AccessDeniedError < StandardError; end +class InvalidRepositoryPathError < StandardError; end +class DisallowedCommandError < StandardError; end diff --git a/lib/gitlab_access.rb b/lib/gitlab_access.rb index e1a5e35..6683ee7 100644 --- a/lib/gitlab_access.rb +++ b/lib/gitlab_access.rb @@ -1,34 +1,28 @@ +require 'json' + +require_relative 'errors' +require_relative 'actor' require_relative 'gitlab_init' require_relative 'gitlab_net' -require_relative 'gitlab_access_status' require_relative 'names_helper' require_relative 'gitlab_metrics' require_relative 'object_dirs_helper' -require 'json' class GitlabAccess - class AccessDeniedError < StandardError; end - include NamesHelper - attr_reader :config, :gl_repository, :repo_path, :changes, :protocol - - def initialize(gl_repository, repo_path, actor, changes, protocol) - @config = GitlabConfig.new + def initialize(gl_repository, repo_path, gl_id, changes, protocol) @gl_repository = gl_repository @repo_path = repo_path.strip - @actor = actor + @gl_id = gl_id @changes = changes.lines @protocol = protocol end def exec - status = GitlabMetrics.measure('check-access:git-receive-pack') do - api.check_access('git-receive-pack', @gl_repository, @repo_path, @actor, @changes, @protocol, env: ObjectDirsHelper.all_attributes.to_json) + GitlabMetrics.measure('check-access:git-receive-pack') do + api.check_access('git-receive-pack', gl_repository, repo_path, actor, changes, protocol, env: ObjectDirsHelper.all_attributes.to_json) end - - raise AccessDeniedError, status.message unless status.allowed? - true rescue GitlabNet::ApiUnreachableError $stderr.puts "GitLab: Failed to authorize your Git request: internal API unreachable" @@ -38,9 +32,19 @@ class GitlabAccess false end - protected + private + + attr_reader :gl_repository, :repo_path, :gl_id, :changes, :protocol def api - GitlabNet.new + @api ||= GitlabNet.new + end + + def config + @config ||= GitlabConfig.new + end + + def actor + @actor ||= Actor.new_from(gl_id, audit_usernames: config.audit_usernames) end end diff --git a/lib/gitlab_access_status.rb b/lib/gitlab_access_status.rb deleted file mode 100644 index c639462..0000000 --- a/lib/gitlab_access_status.rb +++ /dev/null @@ -1,32 +0,0 @@ -require 'json' - -class GitAccessStatus - attr_reader :message, :gl_repository, :gl_id, :gl_username, :repository_path, :gitaly, :git_protocol - - def initialize(status, message, gl_repository:, gl_id:, gl_username:, repository_path:, gitaly:, git_protocol:) - @status = status - @message = message - @gl_repository = gl_repository - @gl_id = gl_id - @gl_username = gl_username - @repository_path = repository_path - @gitaly = gitaly - @git_protocol = git_protocol - end - - def self.create_from_json(json) - values = JSON.parse(json) - new(values["status"], - values["message"], - gl_repository: values["gl_repository"], - gl_id: values["gl_id"], - gl_username: values["gl_username"], - repository_path: values["repository_path"], - gitaly: values["gitaly"], - git_protocol: values["git_protocol"]) - end - - def allowed? - @status - end -end diff --git a/lib/gitlab_custom_hook.rb b/lib/gitlab_custom_hook.rb index 67096df..de6eb38 100644 --- a/lib/gitlab_custom_hook.rb +++ b/lib/gitlab_custom_hook.rb @@ -5,9 +5,9 @@ require_relative 'gitlab_metrics' class GitlabCustomHook attr_reader :vars, :config - def initialize(repo_path, key_id) + def initialize(repo_path, gl_id) @repo_path = repo_path - @vars = { 'GL_ID' => key_id } + @vars = { 'GL_ID' => gl_id } @config = GitlabConfig.new end diff --git a/lib/gitlab_keys.rb b/lib/gitlab_keys.rb index 3ee2882..d0463e2 100644 --- a/lib/gitlab_keys.rb +++ b/lib/gitlab_keys.rb @@ -9,6 +9,7 @@ class GitlabKeys # rubocop:disable Metrics/ClassLength attr_accessor :auth_file, :key + # TODO: whatever is not a great name def self.command(whatever) "#{ROOT_PATH}/bin/gitlab-shell #{whatever}" end @@ -21,6 +22,7 @@ class GitlabKeys # rubocop:disable Metrics/ClassLength command(key_id) end + # TODO: whatever is not a great name def self.whatever_line(command, trailer) "command=\"#{command}\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty #{trailer}" end diff --git a/lib/gitlab_net.rb b/lib/gitlab_net.rb index 9cb7e56..9ea18aa 100644 --- a/lib/gitlab_net.rb +++ b/lib/gitlab_net.rb @@ -1,23 +1,20 @@ -require 'net/http' -require 'openssl' require 'json' -require_relative 'gitlab_config' +require_relative 'errors' require_relative 'gitlab_logger' require_relative 'gitlab_access' require_relative 'gitlab_lfs_authentication' -require_relative 'httpunix' require_relative 'http_helper' +require_relative 'action' -class GitlabNet # rubocop:disable Metrics/ClassLength +class GitlabNet include HTTPHelper - class ApiUnreachableError < StandardError; end - class NotFound < StandardError; end - CHECK_TIMEOUT = 5 + GL_PROTOCOL = 'ssh'.freeze + API_INACCESSIBLE_ERROR = 'API is not accessible'.freeze - def check_access(cmd, gl_repository, repo, who, changes, protocol, env: {}) + def check_access(cmd, gl_repository, repo, actor, changes, protocol = GL_PROTOCOL, env: {}) changes = changes.join("\n") unless changes.is_a?(String) params = { @@ -29,56 +26,27 @@ class GitlabNet # rubocop:disable Metrics/ClassLength env: env } - who_sym, _, who_v = self.class.parse_who(who) - params[who_sym] = who_v + params[actor.identifier_key.to_sym] = actor.id - url = "#{internal_api_endpoint}/allowed" - resp = post(url, params) + resp = post("#{internal_api_endpoint}/allowed", params) - if resp.code == '200' - GitAccessStatus.create_from_json(resp.body) - else - GitAccessStatus.new(false, - 'API is not accessible', - gl_repository: nil, - gl_id: nil, - gl_username: nil, - repository_path: nil, - gitaly: nil, - git_protocol: nil) - end + determine_action(actor, resp) end - def discover(who) - _, who_k, who_v = self.class.parse_who(who) - - resp = get("#{internal_api_endpoint}/discover?#{who_k}=#{who_v}") - - JSON.parse(resp.body) rescue nil + def discover(actor) + resp = get("#{internal_api_endpoint}/discover?#{actor.identifier_key}=#{actor.id}") + JSON.parse(resp.body) if resp.code == HTTP_SUCCESS + rescue JSON::ParserError, ApiUnreachableError + nil end - def lfs_authenticate(gl_id, repo) - id_sym, _, id = self.class.parse_who(gl_id) - - if id_sym == :key_id - params = { - project: sanitize_path(repo), - key_id: id - } - elsif id_sym == :user_id - params = { - project: sanitize_path(repo), - user_id: id - } - else - raise ArgumentError, "lfs_authenticate() got unsupported GL_ID='#{gl_id}'!" - end + def lfs_authenticate(actor, repo) + params = { project: sanitize_path(repo) } + params[actor.identifier_key.to_sym] = actor.id resp = post("#{internal_api_endpoint}/lfs_authenticate", params) - if resp.code == '200' - GitlabLfsAuthentication.build_from_json(resp.body) - end + GitlabLfsAuthentication.build_from_json(resp.body) if resp.code == HTTP_SUCCESS end def broadcast_message @@ -93,11 +61,7 @@ class GitlabNet # rubocop:disable Metrics/ClassLength url += "&gl_repository=#{URI.escape(gl_repository)}" if gl_repository resp = get(url) - if resp.code == '200' - JSON.parse(resp.body) - else - [] - end + resp.code == HTTP_SUCCESS ? JSON.parse(resp.body) : [] rescue [] end @@ -106,19 +70,17 @@ class GitlabNet # rubocop:disable Metrics/ClassLength get("#{internal_api_endpoint}/check", options: { read_timeout: CHECK_TIMEOUT }) end - def authorized_key(key) - resp = get("#{internal_api_endpoint}/authorized_keys?key=#{URI.escape(key, '+/=')}") - JSON.parse(resp.body) if resp.code == "200" + def authorized_key(full_key) + resp = get("#{internal_api_endpoint}/authorized_keys?key=#{URI.escape(full_key, '+/=')}") + JSON.parse(resp.body) if resp.code == HTTP_SUCCESS rescue nil end - def two_factor_recovery_codes(gl_id) - id_sym, _, id = self.class.parse_who(gl_id) - - resp = post("#{internal_api_endpoint}/two_factor_recovery_codes", id_sym => id) - - JSON.parse(resp.body) if resp.code == '200' + def two_factor_recovery_codes(actor) + params = { actor.identifier_key.to_sym => actor.id } + resp = post("#{internal_api_endpoint}/two_factor_recovery_codes", params) + JSON.parse(resp.body) if resp.code == HTTP_SUCCESS rescue {} end @@ -127,51 +89,50 @@ class GitlabNet # rubocop:disable Metrics/ClassLength params = { gl_repository: gl_repository, project: repo_path } resp = post("#{internal_api_endpoint}/notify_post_receive", params) - resp.code == '200' + resp.code == HTTP_SUCCESS rescue false end - def post_receive(gl_repository, identifier, changes) - params = { - gl_repository: gl_repository, - identifier: identifier, - changes: changes - } + def post_receive(gl_repository, actor, changes) + params = { gl_repository: gl_repository, identifier: actor.identifier, changes: changes } resp = post("#{internal_api_endpoint}/post_receive", params) + raise NotFoundError if resp.code == HTTP_NOT_FOUND - raise NotFound if resp.code == '404' - - JSON.parse(resp.body) if resp.code == '200' + JSON.parse(resp.body) if resp.code == HTTP_SUCCESS end def pre_receive(gl_repository) resp = post("#{internal_api_endpoint}/pre_receive", gl_repository: gl_repository) + raise NotFoundError if resp.code == HTTP_NOT_FOUND - raise NotFound if resp.code == '404' - - JSON.parse(resp.body) if resp.code == '200' - end - - def self.parse_who(who) - if who.start_with?("key-") - value = who.gsub("key-", "") - raise ArgumentError, "who='#{who}' is invalid!" unless value =~ /\A[0-9]+\z/ - [:key_id, 'key_id', value] - elsif who.start_with?("user-") - value = who.gsub("user-", "") - raise ArgumentError, "who='#{who}' is invalid!" unless value =~ /\A[0-9]+\z/ - [:user_id, 'user_id', value] - elsif who.start_with?("username-") - [:username, 'username', who.gsub("username-", "")] - else - raise ArgumentError, "who='#{who}' is invalid!" - end + JSON.parse(resp.body) if resp.code == HTTP_SUCCESS end - protected + private def sanitize_path(repo) repo.delete("'") end + + def determine_action(actor, resp) + json = JSON.parse(resp.body) + message = json['message'] + + case resp.code + when HTTP_SUCCESS + # TODO: This raise can be removed once internal API can respond with correct + # HTTP status codes, instead of relying upon parsing the body and + # accessing the 'status' key. + raise AccessDeniedError, message unless json['status'] + + Action::Gitaly.create_from_json(actor, json) + when HTTP_UNAUTHORIZED, HTTP_NOT_FOUND + raise AccessDeniedError, message + else + raise UnknownError, "#{API_INACCESSIBLE_ERROR}: #{message}" + end + rescue JSON::ParserError + raise UnknownError, API_INACCESSIBLE_ERROR + end end diff --git a/lib/gitlab_post_receive.rb b/lib/gitlab_post_receive.rb index cb9931d..9248582 100644 --- a/lib/gitlab_post_receive.rb +++ b/lib/gitlab_post_receive.rb @@ -8,20 +8,18 @@ require 'securerandom' class GitlabPostReceive include NamesHelper - attr_reader :config, :gl_repository, :repo_path, :changes, :jid - - def initialize(gl_repository, repo_path, actor, changes) + def initialize(gl_repository, repo_path, gl_id, changes) @config = GitlabConfig.new @gl_repository = gl_repository @repo_path = repo_path.strip - @actor = actor + @gl_id = gl_id @changes = changes @jid = SecureRandom.hex(12) end def exec response = GitlabMetrics.measure("post-receive") do - api.post_receive(gl_repository, @actor, changes) + api.post_receive(gl_repository, actor, changes) end return false unless response @@ -35,12 +33,18 @@ class GitlabPostReceive false end - protected + private + + attr_reader :config, :gl_repository, :repo_path, :gl_id, :changes, :jid def api @api ||= GitlabNet.new end + def actor + @actor ||= Actor.new_from(gl_id, audit_usernames: config.audit_usernames) + end + def print_merge_request_links(merge_request_urls) return if merge_request_urls.empty? puts @@ -100,8 +104,6 @@ class GitlabPostReceive puts "=" * total_width end - private - def parse_broadcast_msg(msg, text_length) msg ||= "" # just return msg if shorter than or equal to text length diff --git a/lib/gitlab_shell.rb b/lib/gitlab_shell.rb index 78fdfe8..bd7b783 100644 --- a/lib/gitlab_shell.rb +++ b/lib/gitlab_shell.rb @@ -3,299 +3,120 @@ require 'pathname' require_relative 'gitlab_net' require_relative 'gitlab_metrics' +require_relative 'actor' -class GitlabShell # rubocop:disable Metrics/ClassLength - class AccessDeniedError < StandardError; end - class DisallowedCommandError < StandardError; end - class InvalidRepositoryPathError < StandardError; end +class GitlabShell + API_2FA_RECOVERY_CODES_COMMAND = '2fa_recovery_codes'.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_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 - attr_accessor :gl_id, :gl_repository, :repo_name, :command, :git_access, :git_protocol - attr_reader :repo_path + 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) 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) - unless origin_cmd - puts "Welcome to GitLab, #{username}!" + if !origin_cmd || origin_cmd.empty? + puts "Welcome to GitLab, #{actor.username}!" return true end - 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 + parsed_command = parse_cmd(origin_cmd) + action = determine_action(parsed_command) + action.execute(parsed_command.command, parsed_command.args) rescue GitlabNet::ApiUnreachableError $stderr.puts "GitLab: Failed to authorize your Git request: internal API unreachable" false - rescue AccessDeniedError => ex - $logger.warn('Access denied', command: origin_cmd, user: log_username) - + rescue AccessDeniedError, UnknownError => ex + $logger.warn('Access denied', command: origin_cmd, user: actor.log_username) $stderr.puts "GitLab: #{ex.message}" false rescue DisallowedCommandError - $logger.warn('Denied disallowed command', command: origin_cmd, user: log_username) - - $stderr.puts "GitLab: Disallowed command" + $logger.warn('Denied disallowed command', command: origin_cmd, user: actor.log_username) + $stderr.puts 'GitLab: Disallowed command' false rescue InvalidRepositoryPathError - $stderr.puts "GitLab: Invalid repository path" + $stderr.puts 'GitLab: Invalid repository path' false end - protected + private + + attr_reader :config, :actor + + def parse_cmd(cmd) + args = Shellwords.shellwords(cmd) - 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 + git_access_command = command - return args if API_COMMANDS.include?(@command) + if command == API_2FA_RECOVERY_CODES_COMMAND + return Struct::ParsedCommand.new(command, git_access_command, nil, args) + end - 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] - case args[2] - when 'download' - @git_access = 'git-upload-pack' - when 'upload' - @git_access = 'git-receive-pack' - else - raise DisallowedCommandError - end + 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 else raise DisallowedCommandError unless args.count == 2 - @repo_name = args.last + repo_name = args.last end - args + Struct::ParsedCommand.new(command, git_access_command, repo_name, args) end - 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 - if defined?(@who) - @gl_id = status.gl_id - end - end + def determine_action(parsed_command) + return Action::API2FARecovery.new(actor) if parsed_command.command == API_2FA_RECOVERY_CODES_COMMAND - 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_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 + 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' ) - end - # 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']}" + case parsed_command.command + when GIT_LFS_AUTHENTICATE_COMMAND + Action::GitLFSAuthenticate.new(actor, parsed_command.repo_name) else - @user = api.discover(@gl_id) + initial_action end - rescue GitlabNet::ApiUnreachableError - @user = nil - end - end - - 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 + def api + @api ||= GitlabNet.new end end diff --git a/lib/http_helper.rb b/lib/http_helper.rb index 62d0c51..9c7e564 100644 --- a/lib/http_helper.rb +++ b/lib/http_helper.rb @@ -1,5 +1,20 @@ +require 'net/http' +require 'openssl' + +require_relative 'gitlab_config' +require_relative 'httpunix' + module HTTPHelper READ_TIMEOUT = 300 + HTTP_SUCCESS = '200'.freeze + HTTP_MULTIPLE_CHOICES = '300'.freeze + HTTP_UNAUTHORIZED = '401'.freeze + HTTP_NOT_FOUND = '404'.freeze + + HTTP_SUCCESS_LIKE = [HTTP_SUCCESS, HTTP_MULTIPLE_CHOICES].freeze + + class ApiUnreachableError < StandardError; end + class NotFoundError < StandardError; end protected @@ -76,7 +91,7 @@ module HTTPHelper $logger.info('finished HTTP request', method: method.to_s.upcase, url: url, duration: Time.new - start_time) end - if response.code == "200" + if HTTP_SUCCESS_LIKE.include?(response.code) $logger.debug('Received response', code: response.code, body: response.body) else $logger.error('Call failed', method: method.to_s.upcase, url: url, code: response.code, body: response.body) @@ -109,7 +124,7 @@ module HTTPHelper end def secret_token - @secret_token ||= File.read config.secret_file + @secret_token ||= File.read(config.secret_file) end def read_timeout |