diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | CHANGELOG | 6 | ||||
-rw-r--r-- | README.md | 3 | ||||
-rw-r--r-- | VERSION | 2 | ||||
-rwxr-xr-x | bin/gitlab-projects | 2 | ||||
-rw-r--r-- | config.yml.example | 15 | ||||
-rw-r--r-- | lib/gitlab_config.rb | 12 | ||||
-rw-r--r-- | lib/gitlab_keys.rb | 4 | ||||
-rw-r--r-- | lib/gitlab_logger.rb | 16 | ||||
-rw-r--r-- | lib/gitlab_net.rb | 36 | ||||
-rw-r--r-- | lib/gitlab_projects.rb | 70 | ||||
-rw-r--r-- | lib/gitlab_shell.rb | 38 | ||||
-rw-r--r-- | spec/gitlab_keys_spec.rb | 21 | ||||
-rw-r--r-- | spec/gitlab_projects_spec.rb | 98 | ||||
-rw-r--r-- | spec/gitlab_shell_spec.rb | 37 | ||||
-rwxr-xr-x | support/rewrite-hooks.sh | 5 |
16 files changed, 337 insertions, 29 deletions
@@ -1,2 +1,3 @@ config.yml tmp/* +*.log @@ -1,3 +1,9 @@ +v1.5.0 + - Logger + - Ability to specify ca_file/ca_path + - Update-head command for project + - Better regexp for key_id inside shell + v1.4.0 - Regex used in rm-key command was too lax @@ -8,6 +8,9 @@ * [](https://coveralls.io/r/gitlabhq/gitlab-shell) +__Requires ruby 1.9+__ + + ### Setup ./bin/install @@ -1 +1 @@ -1.4.0 +1.5.0 diff --git a/bin/gitlab-projects b/bin/gitlab-projects index 8803931..01de20b 100755 --- a/bin/gitlab-projects +++ b/bin/gitlab-projects @@ -17,6 +17,8 @@ require_relative '../lib/gitlab_init' # # /bin/gitlab-projects import-project randx/six.git https://github.com/randx/six.git # +# /bin/gitlab-projects update-head gitlab/gitlab-ci.git 5-2-stable +# require File.join(ROOT_PATH, 'lib', 'gitlab_projects') # Return non-zero if command execution was not successful diff --git a/config.yml.example b/config.yml.example index 4bffe14..6922b48 100644 --- a/config.yml.example +++ b/config.yml.example @@ -1,12 +1,14 @@ # GitLab user. git by default user: git -# Url to gitlab instance. Used for api calls. Should be ends with slash. +# Url to gitlab instance. Used for api calls. Should end with a slash. gitlab_url: "http://localhost/" http_settings: # user: someone # password: somepass +# ca_file: /etc/ssl/cert.pem +# ca_path: /etc/pki/tls/certs self_signed_cert: false # Repositories path @@ -24,3 +26,14 @@ redis: # socket: /tmp/redis.socket # Only define this if you want to use sockets namespace: resque:gitlab +# Log file. +# Default is gitlab-shell.log in the root directory. +# log_file: "/home/git/gitlab-shell/gitlab-shell.log" + +# Log level. INFO by default +log_level: INFO + +# Audit usernames. +# Set to true to see real usernames in the logs instead of key ids, which is easier to follow, but +# incurs an extra API call on every gitlab-shell command. +audit_usernames: false diff --git a/lib/gitlab_config.rb b/lib/gitlab_config.rb index ede554d..9dc5c66 100644 --- a/lib/gitlab_config.rb +++ b/lib/gitlab_config.rb @@ -31,6 +31,18 @@ class GitlabConfig redis['namespace'] || 'resque:gitlab' end + def log_file + @config['log_file'] ||= File.join(ROOT_PATH, 'gitlab-shell.log') + end + + def log_level + @config['log_level'] ||= 'INFO' + end + + def audit_usernames + @config['audit_usernames'] ||= false + end + # Build redis command to write update event in gitlab queue def redis_command if redis.empty? diff --git a/lib/gitlab_keys.rb b/lib/gitlab_keys.rb index 7e6362a..03026ed 100644 --- a/lib/gitlab_keys.rb +++ b/lib/gitlab_keys.rb @@ -1,6 +1,7 @@ require 'open3' require_relative 'gitlab_config' +require_relative 'gitlab_logger' class GitlabKeys attr_accessor :auth_file, :key @@ -17,6 +18,7 @@ class GitlabKeys when 'add-key'; add_key when 'rm-key'; rm_key else + $logger.warn "Attempt to execute invalid gitlab-keys command #{@command.inspect}." puts 'not allowed' false end @@ -25,12 +27,14 @@ class GitlabKeys protected def add_key + $logger.info "Adding key #{@key_id} => #{@key.inspect}" cmd = "command=\"#{ROOT_PATH}/bin/gitlab-shell #{@key_id}\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty #{@key}" cmd = "echo \'#{cmd}\' >> #{auth_file}" system(cmd) end def rm_key + $logger.info "Removing key #{@key_id}" cmd = "sed -i '/shell #{@key_id}\"/d' #{auth_file}" system(cmd) end diff --git a/lib/gitlab_logger.rb b/lib/gitlab_logger.rb new file mode 100644 index 0000000..4b87e27 --- /dev/null +++ b/lib/gitlab_logger.rb @@ -0,0 +1,16 @@ +require 'logger' + +require_relative 'gitlab_config' + +def convert_log_level log_level + Logger.const_get(log_level.upcase) +rescue NameError + $stderr.puts "WARNING: Unrecognized log level #{log_level.inspect}." + $stderr.puts "WARNING: Falling back to INFO." + Logger::INFO +end + +config = GitlabConfig.new + +$logger = Logger.new(config.log_file) +$logger.level = convert_log_level(config.log_level) diff --git a/lib/gitlab_net.rb b/lib/gitlab_net.rb index ae011b9..99d0044 100644 --- a/lib/gitlab_net.rb +++ b/lib/gitlab_net.rb @@ -3,6 +3,7 @@ require 'openssl' require 'json' require_relative 'gitlab_config' +require_relative 'gitlab_logger' class GitlabNet def allowed?(cmd, repo, key, ref) @@ -13,7 +14,6 @@ class GitlabNet key_id = key.gsub("key-", "") url = "#{host}/allowed?key_id=#{key_id}&action=#{cmd}&ref=#{ref}&project=#{project_name}" - resp = get(url) !!(resp.code == '200' && resp.body == 'true') @@ -40,12 +40,18 @@ class GitlabNet end def get(url) + $logger.debug "Performing GET #{url}" + url = URI.parse(url) http = Net::HTTP.new(url.host, url.port) - http.use_ssl = (url.scheme == 'https') - if config.http_settings['self_signed_cert'] && http.use_ssl? - http.verify_mode = OpenSSL::SSL::VERIFY_NONE + if URI::HTTPS === url + http.use_ssl = true + http.cert_store = cert_store + + if config.http_settings['self_signed_cert'] + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + end end request = Net::HTTP::Get.new(url.request_uri) @@ -53,6 +59,26 @@ class GitlabNet request.basic_auth config.http_settings['user'], config.http_settings['password'] end - http.start {|http| http.request(request) } + http.start {|http| http.request(request) }.tap do |resp| + if resp.code == "200" + $logger.debug { "Received response #{resp.code} => <#{resp.body}>." } + else + $logger.error { "API call <GET #{url}> failed: #{resp.code} => <#{resp.body}>." } + end + end + end + + def cert_store + @cert_store ||= OpenSSL::X509::Store.new.tap { |store| + store.set_default_paths + + if ca_file = config.http_settings['ca_file'] + store.add_file(ca_file) + end + + if ca_path = config.http_settings['ca_path'] + store.add_path(ca_path) + end + } end end diff --git a/lib/gitlab_projects.rb b/lib/gitlab_projects.rb index 1e9eda1..c933296 100644 --- a/lib/gitlab_projects.rb +++ b/lib/gitlab_projects.rb @@ -2,6 +2,7 @@ require 'open3' require 'fileutils' require_relative 'gitlab_config' +require_relative 'gitlab_logger' class GitlabProjects # Project name is a directory name for repository with .git at the end @@ -34,7 +35,9 @@ class GitlabProjects when 'mv-project'; mv_project when 'import-project'; import_project when 'fork-project'; fork_project + when 'update-head'; update_head else + $logger.warn "Attempt to execute invalid gitlab-projects command #{@command.inspect}." puts 'not allowed' false end @@ -69,6 +72,7 @@ class GitlabProjects end def add_project + $logger.info "Adding project #{@project_name} at <#{full_path}>." FileUtils.mkdir_p(full_path, mode: 0770) cmd = "cd #{full_path} && git init --bare && #{create_hooks_cmd}" system(cmd) @@ -79,6 +83,7 @@ class GitlabProjects end def rm_project + $logger.info "Removing project #{@project_name} from <#{full_path}>." FileUtils.rm_rf(full_path) end @@ -86,6 +91,7 @@ class GitlabProjects # URL must be publicly clonable def import_project @source = ARGV.shift + $logger.info "Importing project #{@project_name} from <#{@source}> to <#{full_path}>." cmd = "cd #{repos_path} && git clone --bare #{@source} #{project_name} && #{create_hooks_cmd}" system(cmd) end @@ -101,15 +107,26 @@ class GitlabProjects def mv_project new_path = ARGV.shift - return false unless new_path + unless new_path + $logger.error "mv-project failed: no destination path provided." + return false + end new_full_path = File.join(repos_path, new_path) - # check if source repo exists - # and target repo does not exist - return false unless File.exists?(full_path) - return false if File.exists?(new_full_path) + # verify that the source repo exists + unless File.exists?(full_path) + $logger.error "mv-project failed: source path <#{full_path}> does not exist." + return false + end + # ...and that the target repo does not exist + if File.exists?(new_full_path) + $logger.error "mv-project failed: destination path <#{new_full_path}> already exists." + return false + end + + $logger.info "Moving project #{@project_name} from <#{full_path}> to <#{new_full_path}>." FileUtils.mv(full_path, new_full_path) end @@ -117,20 +134,51 @@ class GitlabProjects new_namespace = ARGV.shift # destination namespace must be provided - return false unless new_namespace + unless new_namespace + $logger.error "fork-project failed: no destination namespace provided." + return false + end - #destination namespace must exist + # destination namespace must exist namespaced_path = File.join(repos_path, new_namespace) - return false unless File.exists?(namespaced_path) + unless File.exists?(namespaced_path) + $logger.error "fork-project failed: destination namespace <#{namespaced_path}> does not exist." + return false + end - #a project of the same name cannot already be within the destination namespace + # a project of the same name cannot already be within the destination namespace full_destination_path = File.join(namespaced_path, project_name.split('/')[-1]) - return false if File.exists?(full_destination_path) + if File.exists?(full_destination_path) + $logger.error "fork-project failed: destination repository <#{full_destination_path}> already exists." + return false + end + $logger.info "Forking project from <#{full_path}> to <#{full_destination_path}>." cmd = "cd #{namespaced_path} && git clone --bare #{full_path} && #{create_hooks_to(full_destination_path)}" system(cmd) end + def update_head + new_head = ARGV.shift + + unless new_head + $logger.error "update-head failed: no branch provided." + return false + end + + unless File.exists?(File.join(full_path, 'refs/heads', new_head)) + $logger.error "update-head failed: specified branch does not exist in ref/heads." + return false + end + + File.open(File.join(full_path, 'HEAD'), 'w') do |f| + f.write("ref: refs/heads/#{new_head}") + end + + $logger.info "Update head in project #{project_name} to <#{new_head}>." + true + end + private def create_hooks_to(dest_path) @@ -138,7 +186,5 @@ class GitlabProjects up_hook_path = File.join(ROOT_PATH, 'hooks', 'update') "ln -s #{pr_hook_path} #{dest_path}/hooks/post-receive && ln -s #{up_hook_path} #{dest_path}/hooks/update" - end - end diff --git a/lib/gitlab_shell.rb b/lib/gitlab_shell.rb index 2d49370..01ef4a1 100644 --- a/lib/gitlab_shell.rb +++ b/lib/gitlab_shell.rb @@ -6,9 +6,11 @@ class GitlabShell attr_accessor :key_id, :repo_name, :git_cmd, :repos_path, :repo_name def initialize - @key_id = ARGV.shift + @key_id = /key-[0-9]+/.match(ARGV.join).to_s @origin_cmd = ENV['SSH_ORIGINAL_COMMAND'] - @repos_path = GitlabConfig.new.repos_path + @config = GitlabConfig.new + @repos_path = @config.repos_path + @user_tried = false end def exec @@ -20,13 +22,18 @@ class GitlabShell if validate_access process_cmd + else + message = "gitlab-shell: Access denied for git command <#{@origin_cmd}> by #{log_username}." + $logger.warn message + $stderr.puts "Access denied." end else + message = "gitlab-shell: Attempt to execute disallowed command <#{@origin_cmd}> by #{log_username}." + $logger.warn message puts 'Not allowed command' end else - user = api.discover(@key_id) - puts "Welcome to GitLab, #{user && user['name'] || 'Anonymous'}!" + puts "Welcome to GitLab, #{username}!" end end @@ -44,7 +51,9 @@ class GitlabShell def process_cmd repo_full_path = File.join(repos_path, repo_name) - exec_cmd "#{@git_cmd} #{repo_full_path}" + cmd = "#{@git_cmd} #{repo_full_path}" + $logger.info "gitlab-shell: executing git command <#{cmd}> for #{log_username}." + exec_cmd(cmd) end def validate_access @@ -58,4 +67,23 @@ class GitlabShell def api GitlabNet.new end + + def user + # Can't use "@user ||=" because that will keep hitting the API when @user is really nil! + if @user_tried + @user + else + @user_tried = true + @user = api.discover(@key_id) + end + end + + def username + user && user['name'] || 'Anonymous' + end + + # User identifier to be used in log messages. + def log_username + @config.audit_usernames ? username : "user with key #{@key_id}" + end end diff --git a/spec/gitlab_keys_spec.rb b/spec/gitlab_keys_spec.rb index f04d506..09f5872 100644 --- a/spec/gitlab_keys_spec.rb +++ b/spec/gitlab_keys_spec.rb @@ -2,6 +2,10 @@ require_relative 'spec_helper' require_relative '../lib/gitlab_keys' describe GitlabKeys do + before do + $logger = double('logger').as_null_object + end + describe :initialize do let(:gitlab_keys) { build_gitlab_keys('add-key', 'key-741', 'ssh-rsa AAAAB3NzaDAxx2E') } @@ -18,6 +22,11 @@ describe GitlabKeys do gitlab_keys.should_receive(:system).with(valid_cmd) gitlab_keys.send :add_key end + + it "should log an add-key event" do + $logger.should_receive(:info).with('Adding key key-741 => "ssh-rsa AAAAB3NzaDAxx2E"') + gitlab_keys.send :add_key + end end describe :rm_key do @@ -28,6 +37,11 @@ describe GitlabKeys do gitlab_keys.should_receive(:system).with(valid_cmd) gitlab_keys.send :rm_key end + + it "should log an rm-key event" do + $logger.should_receive(:info).with('Removing key key-741') + gitlab_keys.send :rm_key + end end describe :exec do @@ -48,6 +62,13 @@ describe GitlabKeys do gitlab_keys.should_receive(:puts).with('not allowed') gitlab_keys.exec end + + it 'should log a warning on unknown commands' do + gitlab_keys = build_gitlab_keys('nooope') + gitlab_keys.stub(puts: nil) + $logger.should_receive(:warn).with('Attempt to execute invalid gitlab-keys command "nooope".') + gitlab_keys.exec + end end def build_gitlab_keys(*args) diff --git a/spec/gitlab_projects_spec.rb b/spec/gitlab_projects_spec.rb index ea16161..1c02430 100644 --- a/spec/gitlab_projects_spec.rb +++ b/spec/gitlab_projects_spec.rb @@ -4,6 +4,7 @@ require_relative '../lib/gitlab_projects' describe GitlabProjects do before do FileUtils.mkdir_p(tmp_repos_path) + $logger = double('logger').as_null_object end after do @@ -105,10 +106,16 @@ describe GitlabProjects do gl_projects.should_receive(:system).with(valid_cmd) gl_projects.exec end + + it "should log an add-project event" do + $logger.should_receive(:info).with("Adding project #{repo_name} at <#{tmp_repo_path}>.") + gl_projects.exec + end end describe :mv_project do let(:gl_projects) { build_gitlab_projects('mv-project', repo_name, 'repo.git') } + let(:new_repo_path) { File.join(tmp_repos_path, 'repo.git') } before do FileUtils.mkdir_p(tmp_repo_path) @@ -118,7 +125,33 @@ describe GitlabProjects do File.exists?(tmp_repo_path).should be_true gl_projects.exec File.exists?(tmp_repo_path).should be_false - File.exists?(File.join(tmp_repos_path, 'repo.git')).should be_true + File.exists?(new_repo_path).should be_true + end + + it "should fail if no destination path is provided" do + incomplete = build_gitlab_projects('mv-project', repo_name) + $logger.should_receive(:error).with("mv-project failed: no destination path provided.") + incomplete.exec.should be_false + end + + it "should fail if the source path doesn't exist" do + bad_source = build_gitlab_projects('mv-project', 'bad-src.git', 'dest.git') + $logger.should_receive(:error).with("mv-project failed: source path <#{tmp_repos_path}/bad-src.git> does not exist.") + bad_source.exec.should be_false + end + + it "should fail if the destination path already exists" do + FileUtils.mkdir_p(File.join(tmp_repos_path, 'already-exists.git')) + bad_dest = build_gitlab_projects('mv-project', repo_name, 'already-exists.git') + message = "mv-project failed: destination path <#{tmp_repos_path}/already-exists.git> already exists." + $logger.should_receive(:error).with(message) + bad_dest.exec.should be_false + end + + it "should log an mv-project event" do + message = "Moving project #{repo_name} from <#{tmp_repo_path}> to <#{new_repo_path}>." + $logger.should_receive(:info).with(message) + gl_projects.exec end end @@ -134,6 +167,32 @@ describe GitlabProjects do gl_projects.exec File.exists?(tmp_repo_path).should be_false end + + it "should log an rm-project event" do + $logger.should_receive(:info).with("Removing project #{repo_name} from <#{tmp_repo_path}>.") + gl_projects.exec + end + end + + describe :update_head do + let(:gl_projects) { build_gitlab_projects('update-head', repo_name, 'stable') } + + before do + FileUtils.mkdir_p(tmp_repo_path) + system("git init --bare #{tmp_repo_path}") + system("touch #{tmp_repo_path}/refs/heads/stable") + File.read(File.join(tmp_repo_path, 'HEAD')).strip.should == 'ref: refs/heads/master' + end + + it "should update head for repo" do + gl_projects.exec.should be_true + File.read(File.join(tmp_repo_path, 'HEAD')).strip.should == 'ref: refs/heads/stable' + end + + it "should log an update_head event" do + $logger.should_receive(:info).with("Update head in project #{repo_name} to <stable>.") + gl_projects.exec + end end describe :import_project do @@ -143,6 +202,12 @@ describe GitlabProjects do gl_projects.exec File.exists?(File.join(tmp_repo_path, 'HEAD')).should be_true end + + it "should log an import-project event" do + message = "Importing project #{repo_name} from <https://github.com/randx/six.git> to <#{tmp_repo_path}>." + $logger.should_receive(:info).with(message) + gl_projects.exec + end end describe :fork_project do @@ -155,7 +220,15 @@ describe GitlabProjects do gl_projects_import.exec end + it "should not fork without a destination namespace" do + missing_arg = build_gitlab_projects('fork-project', source_repo_name) + $logger.should_receive(:error).with("fork-project failed: no destination namespace provided.") + missing_arg.exec.should be_false + end + it "should not fork into a namespace that doesn't exist" do + message = "fork-project failed: destination namespace <#{tmp_repos_path}/forked-to-namespace> does not exist." + $logger.should_receive(:error).with(message) gl_projects_fork.exec.should be_false end @@ -169,9 +242,24 @@ describe GitlabProjects do end it "should not fork if a project of the same name already exists" do - #trying to fork again should fail as the repo already exists + # create a fake project at the intended destination + FileUtils.mkdir_p(File.join(tmp_repos_path, 'forked-to-namespace', repo_name)) + + # trying to fork again should fail as the repo already exists + message = "fork-project failed: destination repository <#{tmp_repos_path}/forked-to-namespace/#{repo_name}> " + message << "already exists." + $logger.should_receive(:error).with(message) gl_projects_fork.exec.should be_false end + + it "should log a fork-project event" do + message = "Forking project from <#{File.join(tmp_repos_path, source_repo_name)}> to <#{dest_repo}>." + $logger.should_receive(:info).with(message) + + # create destination namespace + FileUtils.mkdir_p(File.join(tmp_repos_path, 'forked-to-namespace')) + gl_projects_fork.exec.should be_true + end end describe :exec do @@ -180,6 +268,12 @@ describe GitlabProjects do gitlab_projects.should_receive(:puts).with('not allowed') gitlab_projects.exec end + + it 'should log a warning for unknown commands' do + gitlab_projects = build_gitlab_projects('hurf-durf', repo_name) + $logger.should_receive(:warn).with('Attempt to execute invalid gitlab-projects command "hurf-durf".') + gitlab_projects.exec + end end def build_gitlab_projects(*args) diff --git a/spec/gitlab_shell_spec.rb b/spec/gitlab_shell_spec.rb index da91c36..44dca6d 100644 --- a/spec/gitlab_shell_spec.rb +++ b/spec/gitlab_shell_spec.rb @@ -11,13 +11,15 @@ describe GitlabShell do end let(:api) do double(GitlabNet).tap do |api| - api.stub(discover: 'John Doe') + api.stub(discover: { 'name' => 'John Doe' }) api.stub(allowed?: true) end end let(:key_id) { "key-#{rand(100) + 100}" } let(:repository_path) { "/home/git#{rand(100)}/repos" } - before { GitlabConfig.any_instance.stub(:repos_path).and_return(repository_path) } + before do + GitlabConfig.any_instance.stub(repos_path: repository_path, audit_usernames: false) + end describe :initialize do before { ssh_cmd 'git-receive-pack' } @@ -64,6 +66,18 @@ describe GitlabShell do it "should set the GL_ID environment variable" do ENV.should_receive("[]=").with("GL_ID", key_id) end + + it "should log the command execution" do + message = "gitlab-shell: executing git command " + message << "<git-upload-pack #{File.join(repository_path, 'gitlab-ci.git')}> " + message << "for user with key #{key_id}." + $logger.should_receive(:info).with(message) + end + + it "should use usernames if configured to do so" do + GitlabConfig.any_instance.stub(audit_usernames: true) + $logger.should_receive(:info) { |msg| msg.should =~ /for John Doe/ } + end end context 'git-receive-pack' do @@ -77,6 +91,13 @@ describe GitlabShell do it "should execute the command" do subject.should_receive(:exec_cmd).with("git-receive-pack #{File.join(repository_path, 'gitlab-ci.git')}") end + + it "should log the command execution" do + message = "gitlab-shell: executing git command " + message << "<git-receive-pack #{File.join(repository_path, 'gitlab-ci.git')}> " + message << "for user with key #{key_id}." + $logger.should_receive(:info).with(message) + end end context 'arbitrary command' do @@ -90,6 +111,11 @@ describe GitlabShell do it "should not execute the command" do subject.should_not_receive(:exec_cmd) end + + it "should log the attempt" do + message = "gitlab-shell: Attempt to execute disallowed command <arbitrary command> by user with key #{key_id}." + $logger.should_receive(:warn).with(message) + end end context 'no command' do @@ -110,6 +136,13 @@ describe GitlabShell do api.should_receive(:allowed?). with('git-upload-pack', 'gitlab-ci.git', key_id, '_any') end + + it "should disallow access and log the attempt if allowed? returns false" do + api.stub(allowed?: false) + message = "gitlab-shell: Access denied for git command <git-upload-pack gitlab-ci.git> " + message << "by user with key #{key_id}." + $logger.should_receive(:warn).with(message) + end end def ssh_cmd(cmd) diff --git a/support/rewrite-hooks.sh b/support/rewrite-hooks.sh index 6de4dfc..1d0542e 100755 --- a/support/rewrite-hooks.sh +++ b/support/rewrite-hooks.sh @@ -1,7 +1,10 @@ #!/bin/bash +# $1 is an optional argument specifying the location of the repositories directory. +# Defaults to /home/git/repositories if not provided + home_dir="/home/git" -src="$home_dir/repositories" +src=${1:-"$home_dir/repositories"} for dir in `ls "$src/"` do |