├── VERSION ├── .ruby-version ├── internal ├── testhelper │ ├── testdata │ │ └── testroot │ │ │ ├── gitlab-shell.log │ │ │ ├── certs │ │ │ ├── valid │ │ │ │ ├── dir │ │ │ │ │ └── .gitkeep │ │ │ │ ├── server.pub │ │ │ │ ├── server_authorized_key │ │ │ │ ├── server2.pub │ │ │ │ ├── ca.pub │ │ │ │ ├── server.crt │ │ │ │ ├── server-cert.pub │ │ │ │ ├── server.key │ │ │ │ ├── server2-cert.pub │ │ │ │ ├── ca │ │ │ │ └── server2.key │ │ │ ├── invalid │ │ │ │ ├── server-cert.pub │ │ │ │ └── server.crt │ │ │ └── client │ │ │ │ ├── server.crt │ │ │ │ └── key.pem │ │ │ ├── .gitlab_shell_secret │ │ │ ├── custom │ │ │ └── my-contents-is-secret │ │ │ ├── config.yml │ │ │ └── responses │ │ │ ├── allowed_without_console_messages.json │ │ │ ├── allowed.json │ │ │ ├── allowed_with_geo_push_payload.json │ │ │ ├── allowed_with_push_payload.json │ │ │ └── allowed_with_pull_payload.json │ └── testhelper.go ├── command │ ├── shared │ │ ├── disallowedcommand │ │ │ └── disallowedcommand.go │ │ └── accessverifier │ │ │ ├── accessverifier.go │ │ │ └── accessverifier_test.go │ ├── commandargs │ │ ├── command_args.go │ │ ├── authorized_keys.go │ │ └── authorized_principals.go │ ├── readwriter │ │ ├── readwriter.go │ │ └── readwriter_test.go │ ├── uploadarchive │ │ ├── gitalycall.go │ │ ├── uploadarchive.go │ │ ├── uploadarchive_test.go │ │ └── gitalycall_test.go │ ├── receivepack │ │ ├── gitalycall.go │ │ ├── receivepack_test.go │ │ ├── receivepack.go │ │ └── gitalycall_test.go │ ├── githttp │ │ └── util.go │ ├── lfstransfer │ │ └── gitlab_logger.go │ ├── healthcheck │ │ ├── healthcheck.go │ │ └── healthcheck_test.go │ ├── gitauditevent │ │ ├── audit.go │ │ └── audit_test.go │ ├── authorizedprincipals │ │ ├── authorized_principals.go │ │ └── authorized_principals_test.go │ ├── uploadpack │ │ ├── gitalycall.go │ │ ├── uploadpack_test.go │ │ ├── uploadpack.go │ │ └── gitalycall_test.go │ ├── discover │ │ └── discover.go │ ├── README.md │ ├── authorizedkeys │ │ ├── authorized_keys.go │ │ └── authorized_keys_test.go │ ├── twofactorverify │ │ └── twofactorverify.go │ ├── twofactorrecover │ │ └── twofactorrecover.go │ ├── command.go │ └── personalaccesstoken │ │ └── personalaccesstoken.go ├── sshd │ ├── gssapi_test.go │ └── gssapi_unsupported.go ├── gitlabnet │ ├── healthcheck │ │ ├── client_test.go │ │ └── client.go │ ├── client.go │ ├── authorizedkeys │ │ ├── client.go │ │ └── client_test.go │ ├── authorizedcerts │ │ ├── client.go │ │ └── client_test.go │ ├── gitauditevent │ │ ├── client.go │ │ └── client_test.go │ ├── lfsauthenticate │ │ └── client.go │ ├── discover │ │ └── client.go │ ├── twofactorrecover │ │ └── client.go │ ├── personalaccesstoken │ │ └── client.go │ ├── twofactorverify │ │ └── client.go │ └── git │ │ └── client.go ├── sshenv │ ├── sshenv_test.go │ └── sshenv.go ├── executable │ ├── executable.go │ └── executable_test.go ├── gitaly │ └── gitaly_test.go ├── console │ └── console.go ├── keyline │ └── key_line.go ├── logger │ ├── logger_test.go │ └── logger.go ├── pktline │ ├── pktline.go │ └── pktline_test.go └── config │ └── config_test.go ├── .tool-versions ├── cmd ├── gitlab-sshd │ └── Dockerfile ├── gitlab-shell-check │ ├── command │ │ ├── command.go │ │ └── command_test.go │ └── main.go ├── gitlab-shell-authorized-keys-check │ ├── command │ │ └── command.go │ └── main.go ├── gitlab-shell-authorized-principals-check │ ├── command │ │ └── command.go │ └── main.go └── gitlab-shell │ └── main.go ├── PROCESS.md ├── doc ├── features.md ├── gitlab-sshd.md ├── architecture.md └── beginners_guide.md ├── lefthook.yml ├── .gitlab └── CODEOWNERS ├── .codeclimate.yml ├── spec ├── spec_helper.rb ├── support │ ├── http_unix_server.rb │ └── gitlab_shell_setup.rb ├── gitlab_shell_authorized_principals_check_spec.rb ├── gitlab_shell_authorized_keys_check_spec.rb └── gitlab_shell_personal_access_token_spec.rb ├── Gemfile ├── Dangerfile ├── .gitignore ├── support ├── gitlab_config.rb ├── make_necessary_dirs ├── truncate_repositories.sh └── lint.sh ├── docker-compose.yml ├── LICENSE ├── client └── transport.go ├── README.md ├── CONTRIBUTING.md └── Gemfile.lock /VERSION: -------------------------------------------------------------------------------- 1 | 14.45.5 -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.3.10 2 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/gitlab-shell.log: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/certs/valid/dir/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.3.10 2 | golang 1.24.5 3 | golangci-lint 2.3.1 4 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/.gitlab_shell_secret: -------------------------------------------------------------------------------- 1 | default-secret-content -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/custom/my-contents-is-secret: -------------------------------------------------------------------------------- 1 | custom-secret-content -------------------------------------------------------------------------------- /cmd/gitlab-sshd/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gcr.io/distroless/static-debian10 2 | COPY gitlab-sshd /gitlab-sshd 3 | CMD ["/gitlab-sshd"] -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/config.yml: -------------------------------------------------------------------------------- 1 | sshd: 2 | grace_period: 10 3 | client_alive_interval: 1m 4 | proxy_header_timeout: 500ms 5 | -------------------------------------------------------------------------------- /PROCESS.md: -------------------------------------------------------------------------------- 1 | # GitLab Shell process 2 | 3 | This page [has moved into the `gitlab` repository](https://docs.gitlab.com/ee/development/gitlab_shell/process.html). 4 | -------------------------------------------------------------------------------- /doc/features.md: -------------------------------------------------------------------------------- 1 | # Feature list 2 | 3 | This page 4 | [has moved into the `gitlab` repository](https://docs.gitlab.com/ee/development/gitlab_shell/features.html). 5 | -------------------------------------------------------------------------------- /doc/gitlab-sshd.md: -------------------------------------------------------------------------------- 1 | # gitlab-sshd 2 | 3 | This page 4 | [has moved into the `gitlab` repository](https://docs.gitlab.com/ee/development/gitlab_shell/gitlab_sshd.html). 5 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-push: 2 | commands: 3 | test: 4 | run: make test 5 | # Disabled for now as there's _lots_ of linter errors 6 | #lint: 7 | # run: make lint 8 | -------------------------------------------------------------------------------- /doc/architecture.md: -------------------------------------------------------------------------------- 1 | # GitLab Shell architecture 2 | 3 | The diagram on this page 4 | [has moved into the `gitlab` repository](https://docs.gitlab.com/ee/development/gitlab_shell/#gitlab-shell-architecture). 5 | -------------------------------------------------------------------------------- /.gitlab/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # https://gitlab.com/groups/gitlab-org/maintainers/gitlab-shell/-/group_members?with_inherited_permissions=exclude 2 | * @gitlab-org/maintainers/gitlab-shell 3 | 4 | [Documentation] @gl-docsteam 5 | *.md 6 | /doc/ 7 | -------------------------------------------------------------------------------- /doc/beginners_guide.md: -------------------------------------------------------------------------------- 1 | # Beginner's guide to GitLab Shell contributions 2 | 3 | The information on this page 4 | [has moved into the `gitlab` repository](https://docs.gitlab.com/ee/development/gitlab_shell/#contribute-to-gitlab-shell). 5 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | bundler-audit: 4 | enabled: true 5 | duplication: 6 | enabled: true 7 | config: 8 | languages: 9 | - ruby 10 | fixme: 11 | enabled: true 12 | exclude_paths: 13 | - tmp/ 14 | - coverage/ 15 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | ROOT_PATH = File.expand_path('..', __dir__) 2 | 3 | Dir[File.expand_path('support/**/*.rb', __dir__)].each { |f| require f } 4 | 5 | RSpec.configure do |config| 6 | config.run_all_when_everything_filtered = true 7 | config.filter_run :focus 8 | end 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | group :development, :test do 4 | gem 'base64', '~> 0.3.0' 5 | gem 'rspec', '~> 3.13.1' 6 | gem 'webrick', '~> 1.9', '>= 1.9.1' 7 | end 8 | 9 | group :development, :danger do 10 | gem 'gitlab-dangerfiles', '~> 4.9.2' 11 | end 12 | -------------------------------------------------------------------------------- /Dangerfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'gitlab-dangerfiles' 4 | 5 | Gitlab::Dangerfiles.for_project(self) do |gitlab_dangerfiles| 6 | gitlab_dangerfiles.import_plugins 7 | 8 | gitlab_dangerfiles.import_dangerfiles(except: %w[changelog commit_messages]) 9 | end 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.swp 3 | .DS_Store 4 | .GOPATH 5 | .bundle 6 | .bundle/ 7 | .gitlab_shell_secret 8 | .idea 9 | /*.log* 10 | /bin/* 11 | /gl-code-quality-report.json 12 | /go_build 13 | /support/bin/golangci-* 14 | /support/bin/gotestsum-* 15 | authorized_keys.lock 16 | config.yml 17 | cover.out 18 | cover.xml 19 | custom_hooks 20 | hooks/*.d 21 | tags 22 | tmp/* 23 | vendor 24 | -------------------------------------------------------------------------------- /internal/command/shared/disallowedcommand/disallowedcommand.go: -------------------------------------------------------------------------------- 1 | // Package disallowedcommand provides an error for handling disallowed commands. 2 | package disallowedcommand 3 | 4 | import "errors" 5 | 6 | var ( 7 | // Error is returned when a disallowed command is encountered. 8 | Error = errors.New("Disallowed command") //nolint:stylecheck // Used to display the error message to the user. 9 | ) 10 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/certs/valid/server.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCa17cb94P6q5qbDIWX7aMSjyeBIBPQVZ5jlkDBG90XgWC1MEu9sB1OfKLukcx6wJJSTLFccc9rMzhINXq6K7ks0oXSLP81jvqsu0WipIZSDKBNkdVtno1FcI1RnQ+yUP3nA4Ja9L233GA1evLrqTz6Z9k2ET5wVB+s7+k3lak24bJZN8qVRDDk1UveahuPe1KMj7DNKls8y9tNCgGJn9UeTLJzXlh2tt4/AUHZ0lvET9eCzKT9PBZJQWcCzqLXHa37jbc0ib2sgNN1bZhgkle/cxRx0MjEmdjRt4Z48wjKaf1khFQm0r9lebAxvna/vT5hNywbru5KbfUJHyM23yql 2 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/certs/valid/server_authorized_key: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCa17cb94P6q5qbDIWX7aMSjyeBIBPQVZ5jlkDBG90XgWC1MEu9sB1OfKLukcx6wJJSTLFccc9rMzhINXq6K7ks0oXSLP81jvqsu0WipIZSDKBNkdVtno1FcI1RnQ+yUP3nA4Ja9L233GA1evLrqTz6Z9k2ET5wVB+s7+k3lak24bJZN8qVRDDk1UveahuPe1KMj7DNKls8y9tNCgGJn9UeTLJzXlh2tt4/AUHZ0lvET9eCzKT9PBZJQWcCzqLXHa37jbc0ib2sgNN1bZhgkle/cxRx0MjEmdjRt4Z48wjKaf1khFQm0r9lebAxvna/vT5hNywbru5KbfUJHyM23yql 2 | -------------------------------------------------------------------------------- /support/gitlab_config.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | 3 | # Determine the root of the gitlab-shell directory 4 | ROOT_PATH = ENV.fetch('GITLAB_SHELL_DIR', File.expand_path('..', __dir__)) 5 | 6 | class GitlabConfig 7 | attr_reader :config 8 | 9 | def initialize 10 | @config = YAML.load_file(File.join(ROOT_PATH, 'config.yml')) 11 | end 12 | 13 | def auth_file 14 | @config['auth_file'] ||= File.join(Dir.home, '.ssh/authorized_keys') 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | services: 3 | gitaly: 4 | environment: 5 | - GITALY_TESTING_NO_GIT_HOOKS=1 6 | image: registry.gitlab.com/gitlab-org/build/cng/gitaly:master 7 | command: 8 | ["bash", "-c", "mkdir -p /home/git/repositories && rm -rf /srv/gitlab-shell/hooks/* && touch /srv/gitlab-shell/.gitlab_shell_secret && exec /usr/bin/env GITALY_TESTING_NO_GIT_HOOKS=1 /scripts/process-wrapper"] 9 | ports: 10 | - '8075:8075' 11 | -------------------------------------------------------------------------------- /internal/command/commandargs/command_args.go: -------------------------------------------------------------------------------- 1 | // Package commandargs defines types and interfaces for handling command-line arguments 2 | // in GitLab shell commands. 3 | package commandargs 4 | 5 | // CommandType represents a type of command identified by a string. 6 | type CommandType string 7 | 8 | // CommandArgs is an interface for parsing and accessing command-line arguments. 9 | type CommandArgs interface { 10 | Parse() error 11 | GetArguments() []string 12 | } 13 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/certs/invalid/server-cert.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa-cert-v01@openssh.com AAAAB3NzaC1yc2EAAAADAQABAAABAQCa17cb94P6q5qbDIWX7aMSjyeBIBPQVZ5jlkDBG90XgWC1MEu9sB1OfKLukcx6wJJSTLFccc9rMzhINXq6K7ks0oXSLP81jvqsu0WipIZSDKBNkdVtno1FcI1RnQ+yUP3nA4Ja9L233GA1evLrqTz6Z9k2ET5wVB+s7+k3lak24bJZN8qVRDDk1UveahuPe1KMj7DNKls8y9tNCgGJn9UeTLJzXlh2tt4/AUHZ0lvET9eCzKT9PBZJQWcCzqLXHa37jbc0ib2sgNN1bZhgkle/cxRx0MjEmdjRt4Z48wjKaf1khFQm0r9lebAxvna/vT5hNywbru5KbfUJHyM23yql not_a_valid_cert.pub 2 | -------------------------------------------------------------------------------- /internal/command/readwriter/readwriter.go: -------------------------------------------------------------------------------- 1 | package readwriter 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | type ReadWriter struct { 8 | Out io.Writer 9 | In io.Reader 10 | ErrOut io.Writer 11 | } 12 | 13 | // CountingWriter wraps an io.Writer and counts all the writes. Accessing 14 | // the count N is not thread-safe. 15 | type CountingWriter struct { 16 | W io.Writer 17 | N int64 18 | } 19 | 20 | func (cw *CountingWriter) Write(p []byte) (int, error) { 21 | n, err := cw.W.Write(p) 22 | cw.N += int64(n) 23 | return n, err 24 | } 25 | -------------------------------------------------------------------------------- /support/make_necessary_dirs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # Load ROOT_PATH and access the minimum necessary config file 4 | require_relative 'gitlab_config' 5 | 6 | config = GitlabConfig.new 7 | key_dir = File.dirname("#{config.auth_file}") 8 | 9 | commands = [ 10 | %W(mkdir -p #{key_dir}), 11 | %W(chmod 700 #{key_dir}), 12 | ] 13 | 14 | commands.each do |cmd| 15 | print "#{cmd.join(' ')}: " 16 | if system(*cmd) 17 | puts 'OK' 18 | else 19 | puts 'Failed' 20 | abort "#{$PROGRAM_NAME} failed" 21 | end 22 | end 23 | 24 | exit 25 | -------------------------------------------------------------------------------- /internal/command/readwriter/readwriter_test.go: -------------------------------------------------------------------------------- 1 | package readwriter 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestCountingWriter_Write(t *testing.T) { 11 | testString := []byte("test string") 12 | buffer := &bytes.Buffer{} 13 | 14 | cw := &CountingWriter{ 15 | W: buffer, 16 | } 17 | 18 | n, err := cw.Write(testString) 19 | 20 | require.NoError(t, err) 21 | require.Equal(t, 11, n) 22 | require.Equal(t, int64(11), cw.N) 23 | 24 | cw.Write(testString) 25 | require.Equal(t, int64(22), cw.N) 26 | } 27 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/certs/valid/server2.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDK4MvpmiZ0cLS4+p3YEwcCwo4kVbJPTEeiIMuoEI7KJ3yqAjKJuns6VpnC6aRgu3LmuM4uBHcimQi415ClBOm4+tFiVsVNcAksA+QuU8rTEC6xPs96y1Y/qC+/WQn6+uKp1+fAspWsLpig3VXSTfq+YAcxZfTdO/6ck0kHVvxX096Ye7D0mdq2lWbGwSBlAbzU1wX/Znv5hLZD4DSG1cIjA9vQ/fJ9pwclZiS0qQ1VXdoIUAvL9tTAKj295VGT2NMGGYZQAQ0vXtM1YHOMAZ4XoPL1oVFjVEZoLRD58a6Dpe4hY8QKe6X1w7zU1rr5vVYrz7MTUa5pzsUSOeUTc3kyKJrExVkoLyBgYQq8qW9kYk/Ox9zHwTAKw9vwiIs2Mwe6nlxJ7cw4zykMsxPK4z9HkbZoTFWy3phuPFqs/WQoCvHTKNjWPab7UAameM6Dn0N83BF21tCTMWjkRCvtZVGIGZ7Y4cAiiN0OPzQWDasJ/IKVDRguZJRn3kgHCMYCgok= 2 | -------------------------------------------------------------------------------- /support/truncate_repositories.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # $1 is an optional argument specifying the location of the repositories directory. 4 | # Defaults to /home/git/repositories if not provided 5 | 6 | 7 | home_dir="/home/git/repositories" 8 | src=${1:-"$home_dir"} 9 | 10 | echo "Danger!!! Data Loss" 11 | while true; do 12 | read -p "Do you wish to delete all directories from $home_dir/ (y/n) ?: " yn 13 | case $yn in 14 | [Yy]* ) sh -c "find $home_dir/. -maxdepth 1 -not -name '.' | xargs rm -rf"; break;; 15 | [Nn]* ) exit;; 16 | * ) echo "Please answer yes or no.";; 17 | esac 18 | done 19 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/certs/invalid/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MinvalidcertAOvHjs6cs1R9MAoGCCqGSM49BAMCMBQxEjAQBgNVBAMMCWxvY2Fs 3 | ainvalidcertOTA0MjQxNjM4NTBaFw0yOTA0MjExNjM4NTBaMBQxEjAQBgNVBAMM 4 | CinvalidcertdDB2MBAGByqGSM49AgEGBSuBBAAiA2IABJ5m7oW9OuL7aTAC04sL 5 | 3invalidcertdB2L0GsVCImav4PEpx6UAjkoiNGW9j0zPdNgxTYDjiCaGmr1aY2X 6 | kinvalidcert7MNq7H8v7Ce/vrKkcDMOX8Gd/ddT3dEVqzAKBggqhkjOPQQDAgNp 7 | AinvalidcertswcyjiB+A+ZjMSfaOsA2hAP0I3fkTcry386DePViMfnaIjm7rcuu 8 | Jinvalidcert5V5CHypOxio1tOtGjaDkSH2FCdoatMyIe02+F6TIo44i4J/zjN52 9 | Jinvalidcert 10 | -----END CERTIFICATE----- 11 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/certs/valid/ca.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDchQE3zT8Ekbm631TbfwXAgYB8xlCg5dhsW2JBmj5T5x1sYmUQaBX4hOgY3vxgnKmRsrw2Km0ww6BZnx67oMIkw3GM9vt7ddgauYc96grln2KgRIPDRBKTEMh/AWgtKfHT56JdLXfzKkQ2AtP+E4yX5+augDvhmm+S8ikr5GgCjWqVYJa0Ds3KCRCRZGCecCPiOQMqr1pPWIcZr2dBgu5O1+JfeXfn+g44wkWsODttvkQecGwp7AOgDF2o1vGnhb+xDJQXg1s4UsbNFq8Dp41F87H1Rdx3LS/35SAKa73aduI+Y0Gj3BjcP2mvBcBZ61MzUHdkIOTWGohxdGDqk0E0L17YW5tyGz1NystpmmviSAW9N6QZyh/WbajMlNWVwgmUmnL3manW5fDFM9t9coY+8q7s2uhHZq+QAh/BGPkT/wfCuAMfav/i5E9p1ELH2GbfwcjWfpLhiuuyYv198tbpIJ8Qbpzv5RENCvXxWr1jLEya7T0QrtWUMNfQkzRRqfk= test@test.example.org 2 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/responses/allowed_without_console_messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": true, 3 | "gl_repository": "project-26", 4 | "gl_project_path": "group/private", 5 | "gl_id": "user-1", 6 | "gl_username": "root", 7 | "git_config_options": [], 8 | "gitaly": { 9 | "repository": { 10 | "storage_name": "GITALY_STORAGE", 11 | "relative_path": "GITALY_REPOSITORY", 12 | "git_object_directory": "objects/", 13 | "git_alternate_object_directories": ["objects/"], 14 | "gl_repository": "project-26", 15 | "gl_project_path": "group/private" 16 | }, 17 | "address": "GITALY_ADDRESS", 18 | "token": "token" 19 | }, 20 | "git_protocol": "protocol", 21 | "gl_console_messages": [] 22 | } 23 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/responses/allowed.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": true, 3 | "gl_repository": "project-26", 4 | "gl_project_path": "group/private", 5 | "gl_id": "user-1", 6 | "gl_username": "root", 7 | "git_config_options": ["option"], 8 | "gitaly": { 9 | "repository": { 10 | "storage_name": "default", 11 | "relative_path": "@hashed/5f/9c/5f9c4ab08cac7457e9111a30e4664920607ea2c115a1433d7be98e97e64244ca.git", 12 | "git_object_directory": "path/to/git_object_directory", 13 | "git_alternate_object_directories": ["path/to/git_alternate_object_directory"], 14 | "gl_repository": "project-26", 15 | "gl_project_path": "group/private" 16 | }, 17 | "address": "unix:gitaly.socket", 18 | "token": "token" 19 | }, 20 | "git_protocol": "protocol", 21 | "gl_console_messages": ["console", "message"] 22 | } 23 | -------------------------------------------------------------------------------- /internal/sshd/gssapi_test.go: -------------------------------------------------------------------------------- 1 | //go:build gssapi 2 | 3 | package sshd 4 | 5 | import ( 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 11 | ) 12 | 13 | func NewGSSAPIServerSuccess(t *testing.T) { 14 | config := &config.GSSAPIConfig{Enabled: true, ServicePrincipalName: "host/test@TEST.TEST"} 15 | s, err := NewGSSAPIServer(config) 16 | 17 | require.NotNil(t, s) 18 | require.NotNil(t, s.lib) 19 | require.Nil(t, err) 20 | require.True(t, config.Enabled) 21 | } 22 | 23 | func NewGSSAPIServerFailure(t *testing.T) { 24 | config := &config.GSSAPIConfig{Enabled: true, LibPath: "/invalid", ServicePrincipalName: "host/test@TEST.TEST"} 25 | s, err := NewGSSAPIServer(config) 26 | 27 | require.Nil(t, s) 28 | require.NotNil(t, err) 29 | require.False(t, config.Enabled) 30 | } 31 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/responses/allowed_with_geo_push_payload.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": true, 3 | "gl_repository": "project-26", 4 | "gl_project_path": "group/private", 5 | "gl_id": "user-1", 6 | "gl_username": "root", 7 | "git_config_options": [], 8 | "gitaly": { 9 | "repository": { 10 | "storage_name": "default", 11 | "relative_path": "GITALY_REPOSITORY", 12 | "git_object_directory": "objects/", 13 | "git_alternate_object_directories": ["objects/"], 14 | "gl_repository": "project-26", 15 | "gl_project_path": "group/private" 16 | }, 17 | "address": "GITALY_ADDRESS", 18 | "token": "token" 19 | }, 20 | "payload" : { 21 | "data": { 22 | "request_headers": { "Authorization": "Bearer token" }, 23 | "primary_repo": "PRIMARY_REPO" 24 | } 25 | }, 26 | "git_protocol": "protocol", 27 | "gl_console_messages": ["console", "message"] 28 | } 29 | -------------------------------------------------------------------------------- /cmd/gitlab-shell-check/command/command.go: -------------------------------------------------------------------------------- 1 | // Package command provides functionality for executing commands 2 | package command 3 | 4 | import ( 5 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command" 6 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/healthcheck" 7 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 8 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/shared/disallowedcommand" 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 10 | ) 11 | 12 | // New creates a new command based on the provided configuration and readWriter 13 | func New(config *config.Config, readWriter *readwriter.ReadWriter) (command.Command, error) { 14 | if cmd := build(config, readWriter); cmd != nil { 15 | return cmd, nil 16 | } 17 | 18 | return nil, disallowedcommand.Error 19 | } 20 | 21 | func build(config *config.Config, readWriter *readwriter.ReadWriter) command.Command { 22 | return &healthcheck.Command{Config: config, ReadWriter: readWriter} 23 | } 24 | -------------------------------------------------------------------------------- /support/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Unfortunately, gitlab-shell fails many lint checks which we currently ignore 4 | # 5 | LINT_KNOWN_ACCEPTABLE_FILE="support/lint_last_known_acceptable.txt" 6 | LINT_KNOWN_ACCEPTABLE="$(cat ${LINT_KNOWN_ACCEPTABLE_FILE})" 7 | 8 | LINT_RESULT_FILE=$(mktemp /tmp/lint_result.XXXXXX) 9 | LINT_RESULT=$(make --no-print-directory golangci | tee "${LINT_RESULT_FILE}") 10 | 11 | if [[ "${LINT_RESULT}x" == "${LINT_KNOWN_ACCEPTABLE}x" ]]; then 12 | exit 0 13 | else 14 | echo 15 | 16 | diff -u "${LINT_KNOWN_ACCEPTABLE_FILE}" "${LINT_RESULT_FILE}" 17 | 18 | echo 19 | echo "INFO: The above diff could be caused by a lint error being fixed _or_ newly added." 20 | echo " If you believe the diff is as a result of a lint error being fixed, please run" 21 | echo " the following and try re-running 'make lint' again:" 22 | echo 23 | echo " make golangci > ${LINT_KNOWN_ACCEPTABLE_FILE}" 24 | echo 25 | echo "INFO: Take specific note of the go version used as the diffs can vary." 26 | echo 27 | 28 | exit 1 29 | fi 30 | -------------------------------------------------------------------------------- /internal/command/uploadarchive/gitalycall.go: -------------------------------------------------------------------------------- 1 | package uploadarchive 2 | 3 | import ( 4 | "context" 5 | 6 | "google.golang.org/grpc" 7 | 8 | "gitlab.com/gitlab-org/gitaly/v16/client" 9 | pb "gitlab.com/gitlab-org/gitaly/v16/proto/go/gitalypb" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/accessverifier" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/handler" 13 | ) 14 | 15 | func (c *Command) performGitalyCall(ctx context.Context, response *accessverifier.Response) error { 16 | gc := handler.NewGitalyCommand(c.Config, string(commandargs.UploadArchive), response) 17 | 18 | request := &pb.SSHUploadArchiveRequest{Repository: &response.Gitaly.Repo} 19 | 20 | return gc.RunGitalyCommand(ctx, func(ctx context.Context, conn *grpc.ClientConn) (int32, error) { 21 | ctx, cancel := gc.PrepareContext(ctx, request.Repository, c.Args.Env) 22 | defer cancel() 23 | 24 | rw := c.ReadWriter 25 | return client.UploadArchive(ctx, conn, rw.In, rw.Out, rw.ErrOut, request) 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /spec/support/http_unix_server.rb: -------------------------------------------------------------------------------- 1 | require 'webrick' 2 | 3 | # like WEBrick::HTTPServer, but listens on UNIX socket 4 | class HTTPUNIXServer < WEBrick::HTTPServer 5 | def initialize(config = {}) 6 | null_log = WEBrick::Log.new(IO::NULL, 7) 7 | 8 | super(config.merge(Logger: null_log, AccessLog: null_log)) 9 | end 10 | 11 | def listen(address, port) 12 | socket = Socket.unix_server_socket(address) 13 | socket.autoclose = false 14 | server = UNIXServer.for_fd(socket.fileno) 15 | socket.close 16 | @listeners << server 17 | end 18 | 19 | # Workaround: 20 | # https://bugs.ruby-lang.org/issues/10956 21 | # Affecting Ruby 2.2 22 | # Fix for 2.2 is at https://github.com/ruby/ruby/commit/ab0a64e1 23 | # However, this doesn't work with 2.1. The following should work for both: 24 | def start(&block) 25 | @shutdown_pipe = IO.pipe 26 | shutdown_pipe = @shutdown_pipe 27 | super(&block) 28 | end 29 | 30 | def cleanup_shutdown_pipe(shutdown_pipe) 31 | @shutdown_pipe = nil 32 | return if !shutdown_pipe 33 | super(shutdown_pipe) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2011-present GitLab Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/responses/allowed_with_push_payload.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": true, 3 | "gl_repository": "project-26", 4 | "gl_project_path": "group/private", 5 | "gl_id": "user-1", 6 | "gl_username": "root", 7 | "git_config_options": ["option"], 8 | "gitaly": { 9 | "repository": { 10 | "storage_name": "default", 11 | "relative_path": "@hashed/5f/9c/5f9c4ab08cac7457e9111a30e4664920607ea2c115a1433d7be98e97e64244ca.git", 12 | "git_object_directory": "path/to/git_object_directory", 13 | "git_alternate_object_directories": ["path/to/git_alternate_object_directory"], 14 | "gl_repository": "project-26", 15 | "gl_project_path": "group/private" 16 | }, 17 | "address": "unix:gitaly.socket", 18 | "token": "token" 19 | }, 20 | "payload" : { 21 | "action": "geo_proxy_to_primary", 22 | "data": { 23 | "api_endpoints": ["geo/proxy_git_ssh/info_refs_receive_pack", "geo/proxy_git_ssh/receive_pack"], 24 | "request_headers": { "Authorization": "Bearer token" }, 25 | "gl_username": "custom", 26 | "primary_repo": "https://repo/path" 27 | } 28 | }, 29 | "git_protocol": "protocol", 30 | "gl_console_messages": ["console", "message"] 31 | } 32 | -------------------------------------------------------------------------------- /internal/sshd/gssapi_unsupported.go: -------------------------------------------------------------------------------- 1 | //go:build !gssapi 2 | 3 | package sshd 4 | 5 | import ( 6 | "errors" 7 | 8 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 9 | ) 10 | 11 | // NewGSSAPIServer initializes and returns a new OSGSSAPIServer. 12 | func NewGSSAPIServer(c *config.GSSAPIConfig) (*OSGSSAPIServer, error) { 13 | s := &OSGSSAPIServer{ 14 | ServicePrincipalName: c.ServicePrincipalName, 15 | } 16 | 17 | return s, nil 18 | } 19 | 20 | // OSGSSAPIServer represents a server that handles GSSAPI requests. 21 | type OSGSSAPIServer struct { 22 | ServicePrincipalName string 23 | } 24 | 25 | // AcceptSecContext returns an error indicating that GSSAPI is unsupported. 26 | func (*OSGSSAPIServer) AcceptSecContext([]byte) ([]byte, string, bool, error) { 27 | return []byte{}, "", false, errors.New("gssapi is unsupported") 28 | } 29 | 30 | // VerifyMIC returns an error indicating that GSSAPI is unsupported. 31 | func (*OSGSSAPIServer) VerifyMIC([]byte, []byte) error { 32 | return errors.New("gssapi is unsupported") 33 | } 34 | 35 | // DeleteSecContext returns an error indicating that GSSAPI is unsupported. 36 | func (*OSGSSAPIServer) DeleteSecContext() error { 37 | return errors.New("gssapi is unsupported") 38 | } 39 | -------------------------------------------------------------------------------- /internal/gitlabnet/healthcheck/client_test.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "testing" 8 | 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/client/testserver" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 11 | 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | var ( 16 | requests = []testserver.TestRequestHandler{ 17 | { 18 | Path: "/api/v4/internal/check", 19 | Handler: func(w http.ResponseWriter, r *http.Request) { 20 | json.NewEncoder(w).Encode(testResponse) 21 | }, 22 | }, 23 | } 24 | 25 | testResponse = &Response{ 26 | APIVersion: "v4", 27 | GitlabVersion: "v12.0.0-ee", 28 | GitlabRevision: "3b13818e8330f68625d80d9bf5d8049c41fbe197", 29 | Redis: true, 30 | } 31 | ) 32 | 33 | func TestCheck(t *testing.T) { 34 | client := setup(t) 35 | 36 | result, err := client.Check(context.Background()) 37 | require.NoError(t, err) 38 | require.Equal(t, testResponse, result) 39 | } 40 | 41 | func setup(t *testing.T) *Client { 42 | url := testserver.StartSocketHTTPServer(t, requests) 43 | 44 | client, err := NewClient(&config.Config{GitlabUrl: url}) 45 | require.NoError(t, err) 46 | 47 | return client 48 | } 49 | -------------------------------------------------------------------------------- /cmd/gitlab-shell-check/command/command_test.go: -------------------------------------------------------------------------------- 1 | package command_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "gitlab.com/gitlab-org/gitlab-shell/v14/cmd/gitlab-shell-check/command" 8 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/healthcheck" 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/executable" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/sshenv" 12 | ) 13 | 14 | var ( 15 | basicConfig = &config.Config{GitlabUrl: "http+unix://gitlab.socket"} 16 | ) 17 | 18 | func TestNew(t *testing.T) { 19 | testCases := []struct { 20 | desc string 21 | executable *executable.Executable 22 | env sshenv.Env 23 | arguments []string 24 | config *config.Config 25 | expectedType interface{} 26 | }{ 27 | { 28 | desc: "it returns a Healthcheck command", 29 | config: basicConfig, 30 | expectedType: &healthcheck.Command{}, 31 | }, 32 | } 33 | 34 | for _, tc := range testCases { 35 | t.Run(tc.desc, func(t *testing.T) { 36 | command, err := command.New(tc.config, nil) 37 | 38 | require.NoError(t, err) 39 | require.IsType(t, tc.expectedType, command) 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/command/receivepack/gitalycall.go: -------------------------------------------------------------------------------- 1 | package receivepack 2 | 3 | import ( 4 | "context" 5 | 6 | "google.golang.org/grpc" 7 | 8 | "gitlab.com/gitlab-org/gitaly/v16/client" 9 | pb "gitlab.com/gitlab-org/gitaly/v16/proto/go/gitalypb" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/shared/accessverifier" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/handler" 13 | ) 14 | 15 | func (c *Command) performGitalyCall(ctx context.Context, response *accessverifier.Response) error { 16 | gc := handler.NewGitalyCommand(c.Config, string(commandargs.ReceivePack), response) 17 | 18 | request := &pb.SSHReceivePackRequest{ 19 | Repository: &response.Gitaly.Repo, 20 | GlId: response.Who, 21 | GlRepository: response.Repo, 22 | GlUsername: response.Username, 23 | GitProtocol: c.Args.Env.GitProtocolVersion, 24 | GitConfigOptions: response.GitConfigOptions, 25 | } 26 | 27 | return gc.RunGitalyCommand(ctx, func(ctx context.Context, conn *grpc.ClientConn) (int32, error) { 28 | ctx, cancel := gc.PrepareContext(ctx, request.Repository, c.Args.Env) 29 | defer cancel() 30 | 31 | rw := c.ReadWriter 32 | return client.ReceivePack(ctx, conn, rw.In, rw.Out, rw.ErrOut, request) 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/responses/allowed_with_pull_payload.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": true, 3 | "gl_repository": "project-26", 4 | "gl_project_path": "group/private", 5 | "gl_id": "user-1", 6 | "gl_username": "root", 7 | "git_config_options": [ 8 | "option" 9 | ], 10 | "gitaly": { 11 | "repository": { 12 | "storage_name": "default", 13 | "relative_path": "@hashed/5f/9c/5f9c4ab08cac7457e9111a30e4664920607ea2c115a1433d7be98e97e64244ca.git", 14 | "git_object_directory": "path/to/git_object_directory", 15 | "git_alternate_object_directories": [ 16 | "path/to/git_alternate_object_directory" 17 | ], 18 | "gl_repository": "project-26", 19 | "gl_project_path": "group/private" 20 | }, 21 | "address": "unix:gitaly.socket", 22 | "token": "token" 23 | }, 24 | "payload": { 25 | "action": "geo_proxy_to_primary", 26 | "data": { 27 | "api_endpoints": [ 28 | "geo/proxy_git_ssh/info_refs_upload_pack", 29 | "geo/proxy_git_ssh/upload_pack" 30 | ], 31 | "gl_username": "custom", 32 | "primary_repo": "https://repo/path", 33 | "request_headers": { "Authorization": "Bearer token" } 34 | } 35 | }, 36 | "git_protocol": "protocol", 37 | "gl_console_messages": [ 38 | "console", 39 | "message" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /internal/gitlabnet/client.go: -------------------------------------------------------------------------------- 1 | package gitlabnet 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/client" 10 | 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 12 | ) 13 | 14 | func GetClient(config *config.Config) (*client.GitlabNetClient, error) { 15 | httpClient, err := config.HTTPClient() 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | if httpClient == nil { 21 | return nil, fmt.Errorf("Unsupported protocol") 22 | } 23 | 24 | return client.NewGitlabNetClient(config.HTTPSettings.User, config.HTTPSettings.Password, config.Secret, httpClient) 25 | } 26 | 27 | func ParseJSON(hr *http.Response, response interface{}) error { 28 | if err := json.NewDecoder(hr.Body).Decode(response); err != nil { 29 | return fmt.Errorf("parsing failed") 30 | } 31 | 32 | return nil 33 | } 34 | 35 | func ParseIP(remoteAddr string) string { 36 | // The remoteAddr field can be filled by: 37 | // 1. An IP address via the SSH_CONNECTION environment variable 38 | // 2. A host:port combination via the PROXY protocol 39 | ip, _, err := net.SplitHostPort(remoteAddr) 40 | 41 | // If we don't have a port or can't parse this address for some reason, 42 | // just return the original string. 43 | if err != nil { 44 | return remoteAddr 45 | } 46 | 47 | return ip 48 | } 49 | -------------------------------------------------------------------------------- /internal/command/githttp/util.go: -------------------------------------------------------------------------------- 1 | package githttp 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/git" 11 | ) 12 | 13 | type gitHTTPCommand interface { 14 | ForInfoRefs() (*readwriter.ReadWriter, string, []byte) 15 | } 16 | 17 | // requestInfoRefs performs an HTTP request to the /info/refs endpoint for the specified Git service, 18 | // verifies the response prefix, and writes the result to the output stream. 19 | func requestInfoRefs(ctx context.Context, client *git.Client, command gitHTTPCommand) error { 20 | readWriter, serviceName, httpPrefix := command.ForInfoRefs() 21 | 22 | response, err := client.InfoRefs(ctx, serviceName) 23 | if err != nil { 24 | return err 25 | } 26 | defer response.Body.Close() //nolint:errcheck 27 | 28 | // Read the first bytes that contain for 29 | // push - 001f# service=git-receive-pack\n0000 string 30 | // pull - 001e# service=git-upload-pack\n0000 string 31 | // to convert HTTP(S) Git response to the one expected by SSH 32 | p := make([]byte, len(httpPrefix)) 33 | _, err = response.Body.Read(p) 34 | if err != nil || !bytes.Equal(p, httpPrefix) { 35 | return fmt.Errorf("unexpected %s response", serviceName) 36 | } 37 | 38 | _, err = io.Copy(readWriter.Out, response.Body) 39 | 40 | return err 41 | } 42 | -------------------------------------------------------------------------------- /internal/command/lfstransfer/gitlab_logger.go: -------------------------------------------------------------------------------- 1 | // Package lfstransfer wraps https://github.com/charmbracelet/git-lfs-transfer logic 2 | package lfstransfer 3 | 4 | import ( 5 | "context" 6 | 7 | "gitlab.com/gitlab-org/labkit/log" 8 | ) 9 | 10 | // WrappedLoggerForGitLFSTransfer is responsible for creating a compatible logger 11 | // for github.com/charmbracelet/git-lfs-transfer 12 | type WrappedLoggerForGitLFSTransfer struct { 13 | ctx context.Context 14 | } 15 | 16 | // NewWrappedLoggerForGitLFSTransfer returns a new WrappedLoggerForGitLFSTransfer 17 | // passing through context.Context 18 | func NewWrappedLoggerForGitLFSTransfer(ctx context.Context) *WrappedLoggerForGitLFSTransfer { 19 | return &WrappedLoggerForGitLFSTransfer{ctx: ctx} 20 | } 21 | 22 | // Log allows logging in github.com/charmbracelet/git-lfs-transfer to take place 23 | // using gitlab.com/gitlab-org/labkit/log 24 | func (l *WrappedLoggerForGitLFSTransfer) Log(msg string, args ...interface{}) { 25 | fields := make(map[string]interface{}) 26 | fieldsFallback := map[string]interface{}{"args": args} 27 | 28 | for i := 0; i < len(args); i += 2 { 29 | if i >= len(args)-1 { 30 | fields = fieldsFallback 31 | break 32 | } 33 | 34 | if arg, ok := args[i].(string); ok { 35 | fields[arg] = args[i+1] 36 | } else { 37 | fields = fieldsFallback 38 | break 39 | } 40 | } 41 | 42 | log.WithContextFields(l.ctx, fields).Info(msg) 43 | } 44 | -------------------------------------------------------------------------------- /internal/sshenv/sshenv_test.go: -------------------------------------------------------------------------------- 1 | package sshenv 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/testhelper" 8 | ) 9 | 10 | func TestNewFromEnv(t *testing.T) { 11 | tests := []struct { 12 | desc string 13 | environment map[string]string 14 | want Env 15 | }{ 16 | { 17 | desc: "It parses GIT_PROTOCOL", 18 | environment: map[string]string{GitProtocolEnv: "2"}, 19 | want: Env{GitProtocolVersion: "2"}, 20 | }, 21 | { 22 | desc: "It parses SSH_CONNECTION", 23 | environment: map[string]string{SSHConnectionEnv: "127.0.0.1 0 127.0.0.2 65535"}, 24 | want: Env{IsSSHConnection: true, RemoteAddr: "127.0.0.1"}, 25 | }, 26 | { 27 | desc: "It parses SSH_ORIGINAL_COMMAND", 28 | environment: map[string]string{SSHOriginalCommandEnv: "git-receive-pack"}, 29 | want: Env{OriginalCommand: "git-receive-pack"}, 30 | }, 31 | } 32 | 33 | for _, tc := range tests { 34 | t.Run(tc.desc, func(t *testing.T) { 35 | testhelper.TempEnv(t, tc.environment) 36 | 37 | require.Equal(t, tc.want, NewFromEnv()) 38 | }) 39 | } 40 | } 41 | 42 | func TestRemoteAddrFromEnv(t *testing.T) { 43 | t.Setenv(SSHConnectionEnv, "127.0.0.1 0") 44 | 45 | require.Equal(t, "127.0.0.1", remoteAddrFromEnv()) 46 | } 47 | 48 | func TestEmptyRemoteAddrFromEnv(t *testing.T) { 49 | require.Equal(t, "", remoteAddrFromEnv()) 50 | } 51 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/certs/valid/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDrjCCApagAwIBAgIUHVNTmyz3p+7xSEMkSfhPz4BZfqwwDQYJKoZIhvcNAQEL 3 | BQAwTjELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcM 4 | CVRoZSBDbG91ZDEWMBQGA1UECgwNTXkgQ29tcGFueSBDQTAeFw0xOTA5MjAxMDQ3 5 | NTlaFw0yOTA5MTcxMDQ3NTlaMF4xCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxp 6 | Zm9ybmlhMRIwEAYDVQQHDAlUaGUgQ2xvdWQxDTALBgNVBAoMBERlbW8xFzAVBgNV 7 | BAMMDk15IENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC 8 | AQEAmte3G/eD+quamwyFl+2jEo8ngSAT0FWeY5ZAwRvdF4FgtTBLvbAdTnyi7pHM 9 | esCSUkyxXHHPazM4SDV6uiu5LNKF0iz/NY76rLtFoqSGUgygTZHVbZ6NRXCNUZ0P 10 | slD95wOCWvS9t9xgNXry66k8+mfZNhE+cFQfrO/pN5WpNuGyWTfKlUQw5NVL3mob 11 | j3tSjI+wzSpbPMvbTQoBiZ/VHkyyc15YdrbePwFB2dJbxE/Xgsyk/TwWSUFnAs6i 12 | 1x2t+423NIm9rIDTdW2YYJJXv3MUcdDIxJnY0beGePMIymn9ZIRUJtK/ZXmwMb52 13 | v70+YTcsG67uSm31CR8jNt8qpQIDAQABo3QwcjAJBgNVHRMEAjAAMB0GA1UdDgQW 14 | BBTxZ9SORmIwDs90TW8UXIVhDst4kjALBgNVHQ8EBAMCBaAwHQYDVR0lBBYwFAYI 15 | KwYBBQUHAwIGCCsGAQUFBwMBMBoGA1UdEQQTMBGHBH8AAAGCCWxvY2FsaG9zdDAN 16 | BgkqhkiG9w0BAQsFAAOCAQEAf4Iq94Su9TlkReMS4x2N5xZru9YoKQtrrxqWSRbp 17 | oh5Lwtk9rJPy6q4IEPXzDsRI1YWCZe1Fw7zdiNfmoFRxjs59MBJ9YVrcFeyeAILg 18 | LiAiRcGth2THpikCnLxmniGHUUX1WfjmcDEYMIs6BZ98N64VWwtuZqcJnJPmQs64 19 | lDrgW9oz6/8hPMeW58ok8PjkiG+E+srBaURoKwNe7vfPRVyq45N67/juH+4o6QBd 20 | WP6ACjDM3RnxyWyW0S+sl3i3EAGgtwM6RIDhOG238HOIiA/I/+CCmITsvujz6jMN 21 | bLdoPfnatZ7f5m9DuoOsGlYAZbLfOl2NywgO0jAlnHJGEQ== 22 | -----END CERTIFICATE----- 23 | -------------------------------------------------------------------------------- /internal/sshenv/sshenv.go: -------------------------------------------------------------------------------- 1 | // Package sshenv provides functionality for handling SSH environment variables 2 | package sshenv 3 | 4 | import ( 5 | "os" 6 | "strings" 7 | ) 8 | 9 | const ( 10 | // GitProtocolEnv defines the ENV name holding the git protocol used 11 | GitProtocolEnv = "GIT_PROTOCOL" 12 | // SSHConnectionEnv defines the ENV holding the SSH connection 13 | SSHConnectionEnv = "SSH_CONNECTION" 14 | // SSHOriginalCommandEnv defines the ENV containing the original SSH command 15 | SSHOriginalCommandEnv = "SSH_ORIGINAL_COMMAND" 16 | ) 17 | 18 | // Env represents the SSH environment variables 19 | type Env struct { 20 | GitProtocolVersion string 21 | IsSSHConnection bool 22 | OriginalCommand string 23 | RemoteAddr string 24 | NamespacePath string 25 | } 26 | 27 | // NewFromEnv creates a new Env instance based on the current environment variables 28 | func NewFromEnv() Env { 29 | isSSHConnection := false 30 | if ok := os.Getenv(SSHConnectionEnv); ok != "" { 31 | isSSHConnection = true 32 | } 33 | 34 | return Env{ 35 | GitProtocolVersion: os.Getenv(GitProtocolEnv), 36 | IsSSHConnection: isSSHConnection, 37 | RemoteAddr: remoteAddrFromEnv(), 38 | OriginalCommand: os.Getenv(SSHOriginalCommandEnv), 39 | } 40 | } 41 | 42 | // remoteAddrFromEnv returns the connection address from ENV string 43 | func remoteAddrFromEnv() string { 44 | address := os.Getenv(SSHConnectionEnv) 45 | 46 | if address != "" { 47 | return strings.Fields(address)[0] 48 | } 49 | return "" 50 | } 51 | -------------------------------------------------------------------------------- /internal/command/healthcheck/healthcheck.go: -------------------------------------------------------------------------------- 1 | // Package healthcheck provides functionality to perform health checks. 2 | package healthcheck 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | 8 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/healthcheck" 11 | ) 12 | 13 | var ( 14 | apiMessage = "Internal API available" 15 | redisMessage = "Redis available via internal API" 16 | ) 17 | 18 | // Command handles the execution of health checks. 19 | type Command struct { 20 | Config *config.Config 21 | ReadWriter *readwriter.ReadWriter 22 | } 23 | 24 | // Execute performs the health check and outputs the result. 25 | func (c *Command) Execute(ctx context.Context) (context.Context, error) { 26 | response, err := c.runCheck(ctx) 27 | if err != nil { 28 | return ctx, fmt.Errorf("%v: FAILED - %v", apiMessage, err) 29 | } 30 | 31 | _, _ = fmt.Fprintf(c.ReadWriter.Out, "%v: OK\n", apiMessage) 32 | 33 | if !response.Redis { 34 | return ctx, fmt.Errorf("%v: FAILED", redisMessage) 35 | } 36 | 37 | _, _ = fmt.Fprintf(c.ReadWriter.Out, "%v: OK\n", redisMessage) 38 | return ctx, nil 39 | } 40 | 41 | func (c *Command) runCheck(ctx context.Context) (*healthcheck.Response, error) { 42 | client, err := healthcheck.NewClient(c.Config) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | response, err := client.Check(ctx) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | return response, nil 53 | } 54 | -------------------------------------------------------------------------------- /internal/testhelper/testhelper.go: -------------------------------------------------------------------------------- 1 | // Package testhelper consists of various helper functions to setup the test environment. 2 | package testhelper 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | "path" 8 | "runtime" 9 | "testing" 10 | 11 | "github.com/otiai10/copy" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | // TempEnv sets environment variables for the test 16 | func TempEnv(t *testing.T, env map[string]string) { 17 | for key, value := range env { 18 | t.Setenv(key, value) 19 | } 20 | } 21 | 22 | // PrepareTestRootDir prepares the test root directory with the test data 23 | func PrepareTestRootDir(t *testing.T) string { 24 | t.Helper() 25 | 26 | testRoot := t.TempDir() 27 | t.Cleanup(func() { require.NoError(t, os.RemoveAll(testRoot)) }) 28 | 29 | require.NoError(t, copyTestData(testRoot)) 30 | 31 | oldWd, err := os.Getwd() 32 | require.NoError(t, err) 33 | 34 | t.Cleanup(func() { 35 | err = os.Chdir(oldWd) 36 | require.NoError(t, err) 37 | }) 38 | 39 | require.NoError(t, os.Chdir(testRoot)) 40 | 41 | return testRoot 42 | } 43 | 44 | func copyTestData(testRoot string) error { 45 | testDataDir, err := getTestDataDir() 46 | if err != nil { 47 | return err 48 | } 49 | 50 | testdata := path.Join(testDataDir, "testroot") 51 | 52 | return copy.Copy(testdata, testRoot) 53 | } 54 | 55 | func getTestDataDir() (string, error) { 56 | _, currentFile, _, ok := runtime.Caller(0) 57 | if !ok { 58 | return "", fmt.Errorf("could not get caller info") 59 | } 60 | 61 | return path.Join(path.Dir(currentFile), "testdata"), nil 62 | } 63 | -------------------------------------------------------------------------------- /internal/command/gitauditevent/audit.go: -------------------------------------------------------------------------------- 1 | // Package gitauditevent handles Git audit events for GitLab. 2 | package gitauditevent 3 | 4 | import ( 5 | "context" 6 | 7 | pb "gitlab.com/gitlab-org/gitaly/v16/proto/go/gitalypb" 8 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/shared/accessverifier" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/gitauditevent" 12 | "gitlab.com/gitlab-org/labkit/log" 13 | ) 14 | 15 | // Audit is called conditionally during `git-receive-pack` and `git-upload-pack` to generate streaming audit events. 16 | // Errors are not propagated since this is more a logging process. 17 | func Audit(ctx context.Context, args *commandargs.Shell, c *config.Config, response *accessverifier.Response, packfileStats *pb.PackfileNegotiationStatistics) { 18 | ctxlog := log.WithContextFields(ctx, log.Fields{ 19 | "gl_repository": response.Repo, 20 | "command": args.CommandType, 21 | "username": response.Username, 22 | }) 23 | 24 | ctxlog.Debug("sending git audit event") 25 | 26 | gitAuditClient, errOnlyLog := gitauditevent.NewClient(c) 27 | if errOnlyLog != nil { 28 | ctxlog.Errorf("failed to create gitauditevent client: %v", errOnlyLog) 29 | return 30 | } 31 | 32 | errOnlyLog = gitAuditClient.Audit(ctx, response.Username, args, response.Repo, packfileStats) 33 | if errOnlyLog != nil { 34 | ctxlog.Errorf("failed to audit git event: %v", errOnlyLog) 35 | return 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /internal/command/authorizedprincipals/authorized_principals.go: -------------------------------------------------------------------------------- 1 | // Package authorizedprincipals handles printing authorized principals in GitLab Shell. 2 | package authorizedprincipals 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | 8 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/keyline" 12 | ) 13 | 14 | // Command contains the configuration, arguments, and I/O interfaces. 15 | type Command struct { 16 | Config *config.Config 17 | Args *commandargs.AuthorizedPrincipals 18 | ReadWriter *readwriter.ReadWriter 19 | } 20 | 21 | // Execute runs the command to print authorized principals. 22 | func (c *Command) Execute(ctx context.Context) (context.Context, error) { 23 | if err := c.printPrincipalLines(); err != nil { 24 | return ctx, err 25 | } 26 | 27 | return ctx, nil 28 | } 29 | 30 | func (c *Command) printPrincipalLines() error { 31 | principals := c.Args.Principals 32 | 33 | for _, principal := range principals { 34 | if err := c.printPrincipalLine(principal); err != nil { 35 | return err 36 | } 37 | } 38 | 39 | return nil 40 | } 41 | 42 | func (c *Command) printPrincipalLine(principal string) error { 43 | principalKeyLine, err := keyline.NewPrincipalKeyLine(c.Args.KeyID, principal, c.Config) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | _, _ = fmt.Fprintln(c.ReadWriter.Out, principalKeyLine.ToString()) 49 | 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /cmd/gitlab-shell-authorized-keys-check/command/command.go: -------------------------------------------------------------------------------- 1 | // Package command provides functionality for handling gitlab-shell authorized keys commands 2 | package command 3 | 4 | import ( 5 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command" 6 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/authorizedkeys" 7 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 8 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/shared/disallowedcommand" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 11 | ) 12 | 13 | // New creates a new command based on the provided arguments, configuration, and readwriter 14 | func New(arguments []string, config *config.Config, readWriter *readwriter.ReadWriter) (command.Command, error) { 15 | args, err := Parse(arguments) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | if cmd := build(args, config, readWriter); cmd != nil { 21 | return cmd, nil 22 | } 23 | 24 | return nil, disallowedcommand.Error 25 | } 26 | 27 | // Parse parses the provided arguments and returns an AuthorizedKeys command 28 | func Parse(arguments []string) (*commandargs.AuthorizedKeys, error) { 29 | args := &commandargs.AuthorizedKeys{Arguments: arguments} 30 | 31 | if err := args.Parse(); err != nil { 32 | return nil, err 33 | } 34 | 35 | return args, nil 36 | } 37 | 38 | func build(args *commandargs.AuthorizedKeys, config *config.Config, readWriter *readwriter.ReadWriter) command.Command { 39 | return &authorizedkeys.Command{Config: config, Args: args, ReadWriter: readWriter} 40 | } 41 | -------------------------------------------------------------------------------- /cmd/gitlab-shell-authorized-principals-check/command/command.go: -------------------------------------------------------------------------------- 1 | // Package command handles command creation and initialization in GitLab Shell. 2 | package command 3 | 4 | import ( 5 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command" 6 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/authorizedprincipals" 7 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 8 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/shared/disallowedcommand" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 11 | ) 12 | 13 | // New creates a new command based on provided arguments, config, and I/O. 14 | func New(arguments []string, config *config.Config, readWriter *readwriter.ReadWriter) (command.Command, error) { 15 | args, err := Parse(arguments) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | if cmd := build(args, config, readWriter); cmd != nil { 21 | return cmd, nil 22 | } 23 | 24 | return nil, disallowedcommand.Error 25 | } 26 | 27 | // Parse parses command-line arguments into a CommandArgs structure. 28 | func Parse(arguments []string) (*commandargs.AuthorizedPrincipals, error) { 29 | args := &commandargs.AuthorizedPrincipals{Arguments: arguments} 30 | 31 | if err := args.Parse(); err != nil { 32 | return nil, err 33 | } 34 | 35 | return args, nil 36 | } 37 | 38 | func build(args *commandargs.AuthorizedPrincipals, config *config.Config, readWriter *readwriter.ReadWriter) command.Command { 39 | return &authorizedprincipals.Command{Config: config, Args: args, ReadWriter: readWriter} 40 | } 41 | -------------------------------------------------------------------------------- /internal/command/commandargs/authorized_keys.go: -------------------------------------------------------------------------------- 1 | // Package commandargs defines structures and methods for handling command-line arguments 2 | // related to authorized key checks in the GitLab shell. 3 | package commandargs 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | ) 9 | 10 | // AuthorizedKeys holds the arguments and user information for key authorization checks. 11 | type AuthorizedKeys struct { 12 | Arguments []string 13 | ExpectedUser string 14 | ActualUser string 15 | Key string 16 | } 17 | 18 | // Parse parses and validates the arguments, setting ExpectedUser, ActualUser, and Key. 19 | func (ak *AuthorizedKeys) Parse() error { 20 | if err := ak.validate(); err != nil { 21 | return err 22 | } 23 | 24 | ak.ExpectedUser = ak.Arguments[0] 25 | ak.ActualUser = ak.Arguments[1] 26 | ak.Key = ak.Arguments[2] 27 | 28 | return nil 29 | } 30 | 31 | // GetArguments returns the list of command-line arguments. 32 | func (ak *AuthorizedKeys) GetArguments() []string { 33 | return ak.Arguments 34 | } 35 | 36 | func (ak *AuthorizedKeys) validate() error { 37 | argsSize := len(ak.Arguments) 38 | 39 | if argsSize != 3 { 40 | return fmt.Errorf("# Insufficient arguments. %d. Usage\n#\tgitlab-shell-authorized-keys-check ", argsSize) 41 | } 42 | 43 | expectedUsername := ak.Arguments[0] 44 | actualUsername := ak.Arguments[1] 45 | key := ak.Arguments[2] 46 | 47 | if expectedUsername == "" || actualUsername == "" { 48 | return errors.New("# No username provided") 49 | } 50 | 51 | if key == "" { 52 | return errors.New("# No key provided") 53 | } 54 | 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /internal/command/shared/accessverifier/accessverifier.go: -------------------------------------------------------------------------------- 1 | // Package accessverifier handles the verification of access permission. 2 | package accessverifier 3 | 4 | import ( 5 | "context" 6 | "errors" 7 | 8 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/console" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/accessverifier" 13 | ) 14 | 15 | // Response is an alias for accessverifier.Response, representing the result of an access verification. 16 | type Response = accessverifier.Response 17 | 18 | // Command handles access verification commands. 19 | type Command struct { 20 | Config *config.Config 21 | Args *commandargs.Shell 22 | ReadWriter *readwriter.ReadWriter 23 | } 24 | 25 | // Verify checks access permissions and returns a response. 26 | func (c *Command) Verify(ctx context.Context, action commandargs.CommandType, repo string) (*Response, error) { 27 | client, err := accessverifier.NewClient(c.Config) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | response, err := client.Verify(ctx, c.Args, action, repo) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | c.displayConsoleMessages(response.ConsoleMessages) 38 | 39 | if !response.Success { 40 | return nil, errors.New(response.Message) 41 | } 42 | 43 | return response, nil 44 | } 45 | 46 | func (c *Command) displayConsoleMessages(messages []string) { 47 | console.DisplayInfoMessages(messages, c.ReadWriter.ErrOut) 48 | } 49 | -------------------------------------------------------------------------------- /internal/command/commandargs/authorized_principals.go: -------------------------------------------------------------------------------- 1 | // Package commandargs provides functionality for handling and parsing command-line arguments 2 | // related to authorized principals for GitLab shell commands. 3 | package commandargs 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | ) 9 | 10 | // AuthorizedPrincipals holds the arguments for checking authorized principals and the key ID. 11 | type AuthorizedPrincipals struct { 12 | Arguments []string 13 | KeyID string 14 | Principals []string 15 | } 16 | 17 | // Parse validates and extracts the key ID and principals from the Arguments slice. 18 | // Returns an error if validation fails. 19 | func (ap *AuthorizedPrincipals) Parse() error { 20 | if err := ap.validate(); err != nil { 21 | return err 22 | } 23 | 24 | ap.KeyID = ap.Arguments[0] 25 | ap.Principals = ap.Arguments[1:] 26 | 27 | return nil 28 | } 29 | 30 | // GetArguments returns the list of command-line arguments provided. 31 | func (ap *AuthorizedPrincipals) GetArguments() []string { 32 | return ap.Arguments 33 | } 34 | 35 | func (ap *AuthorizedPrincipals) validate() error { 36 | argsSize := len(ap.Arguments) 37 | 38 | if argsSize < 2 { 39 | return fmt.Errorf("# Insufficient arguments. %d. Usage\n#\tgitlab-shell-authorized-principals-check [...]", argsSize) 40 | } 41 | 42 | keyID := ap.Arguments[0] 43 | principals := ap.Arguments[1:] 44 | 45 | if keyID == "" { 46 | return errors.New("# No key_id provided") 47 | } 48 | 49 | for _, principal := range principals { 50 | if principal == "" { 51 | return errors.New("# An invalid principal was provided") 52 | } 53 | } 54 | 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/certs/valid/server-cert.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgNGUtD+qw0Xj5NU2uj4+4LoCWPcvXP54F9Adw/hWN5LAAAAADAQABAAABAQCa17cb94P6q5qbDIWX7aMSjyeBIBPQVZ5jlkDBG90XgWC1MEu9sB1OfKLukcx6wJJSTLFccc9rMzhINXq6K7ks0oXSLP81jvqsu0WipIZSDKBNkdVtno1FcI1RnQ+yUP3nA4Ja9L233GA1evLrqTz6Z9k2ET5wVB+s7+k3lak24bJZN8qVRDDk1UveahuPe1KMj7DNKls8y9tNCgGJn9UeTLJzXlh2tt4/AUHZ0lvET9eCzKT9PBZJQWcCzqLXHa37jbc0ib2sgNN1bZhgkle/cxRx0MjEmdjRt4Z48wjKaf1khFQm0r9lebAxvna/vT5hNywbru5KbfUJHyM23yqlAAAAAAAAAAAAAAACAAAABnNlcnZlcgAAAAAAAAAAAAAAAP//////////AAAAAAAAAAAAAAAAAAABlwAAAAdzc2gtcnNhAAAAAwEAAQAAAYEA3IUBN80/BJG5ut9U238FwIGAfMZQoOXYbFtiQZo+U+cdbGJlEGgV+IToGN78YJypkbK8NiptMMOgWZ8eu6DCJMNxjPb7e3XYGrmHPeoK5Z9ioESDw0QSkxDIfwFoLSnx0+eiXS138ypENgLT/hOMl+fmroA74ZpvkvIpK+RoAo1qlWCWtA7NygkQkWRgnnAj4jkDKq9aT1iHGa9nQYLuTtfiX3l35/oOOMJFrDg7bb5EHnBsKewDoAxdqNbxp4W/sQyUF4NbOFLGzRavA6eNRfOx9UXcdy0v9+UgCmu92nbiPmNBo9wY3D9prwXAWetTM1B3ZCDk1hqIcXRg6pNBNC9e2Fubchs9TcrLaZpr4kgFvTekGcof1m2ozJTVlcIJlJpy95mp1uXwxTPbfXKGPvKu7NroR2avkAIfwRj5E/8HwrgDH2r/4uRPadRCx9hm38HI1n6S4YrrsmL9ffLW6SCfEG6c7+URDQr18Vq9YyxMmu09EK7VlDDX0JM0Uan5AAABlAAAAAxyc2Etc2hhMi01MTIAAAGAapK5VzLDoRe88tnLORrH8VxLegTWPKGdn0k5Ye8tS5XUgd0N98gU669y4ErDNf0kPxlz40bjsfisEEtJ/N7m14IskCepScfZRh8w6QgPxbTOhmrc89xooqBUE50y5FU9hIIbzUrnEP+Dfu4IiFPToAguCa+KoAKiOX7lBQqEugV6uOWVZ2erPopEv+OiMLD8hXsuKKjQ+TomHZ/IjuXsFdXH7Vcl0VsPaAcxyCtDU7nCTJSTUoUZtMtwwpulp0e/zNmFrEn2Binz4jRaUlk3FM2fdbotviDQYeOY3npmxaWUvvQ/eKn0/DzUTAKAGr2LDa2XWnPhj51BS1XkNaUlnupdYmZ2Sok0R4U3bfVwokteREvAltGbXQSDtZwLS5NEY6vIdDrxpn5QRf9vGjqnc7piXxye9gcLne4YDUi24IhGyHrnWKCC0HjF7tuUhCOVKrqRdmHxRGWX3PlS8Xn6HHEPWU+YZnfT1V2W7LAFcDMozQbs4GPGzZdR3f3vCOJ8 server.pub 2 | -------------------------------------------------------------------------------- /internal/command/uploadpack/gitalycall.go: -------------------------------------------------------------------------------- 1 | // Package uploadpack provides functionality for handling upload-pack command 2 | package uploadpack 3 | 4 | import ( 5 | "context" 6 | 7 | "google.golang.org/grpc" 8 | 9 | "gitlab.com/gitlab-org/gitaly/v16/client" 10 | pb "gitlab.com/gitlab-org/gitaly/v16/proto/go/gitalypb" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/accessverifier" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/handler" 14 | ) 15 | 16 | func (c *Command) performGitalyCall(ctx context.Context, response *accessverifier.Response) (*pb.PackfileNegotiationStatistics, error) { 17 | gc := handler.NewGitalyCommand(c.Config, string(commandargs.UploadPack), response) 18 | 19 | request := &pb.SSHUploadPackWithSidechannelRequest{ 20 | Repository: &response.Gitaly.Repo, 21 | GitProtocol: c.Args.Env.GitProtocolVersion, 22 | GitConfigOptions: response.GitConfigOptions, 23 | } 24 | 25 | var stats *pb.PackfileNegotiationStatistics 26 | err := gc.RunGitalyCommand(ctx, func(ctx context.Context, conn *grpc.ClientConn) (int32, error) { 27 | ctx, cancel := gc.PrepareContext(ctx, request.Repository, c.Args.Env) 28 | defer cancel() 29 | 30 | registry := c.Config.GitalyClient.SidechannelRegistry 31 | rw := c.ReadWriter 32 | 33 | var ( 34 | result client.UploadPackResult 35 | err error 36 | ) 37 | result, err = client.UploadPackWithSidechannelWithResult(ctx, conn, registry, rw.In, rw.Out, rw.ErrOut, request) 38 | if err == nil { 39 | stats = result.PackfileNegotiationStatistics 40 | } 41 | return result.ExitCode, err 42 | }) 43 | 44 | return stats, err 45 | } 46 | -------------------------------------------------------------------------------- /spec/gitlab_shell_authorized_principals_check_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | describe 'bin/gitlab-shell-authorized-principals-check' do 4 | include_context 'gitlab shell' 5 | 6 | before(:all) do 7 | write_config({}) 8 | end 9 | 10 | def mock_server(server) 11 | # Do nothing as we're not connecting to a server in this check. 12 | end 13 | 14 | let(:authorized_principals_check_path) { File.join(tmp_root_path, 'bin', 'gitlab-shell-authorized-principals-check') } 15 | 16 | describe 'authorized principals check' do 17 | it 'succeeds when a valid principal is given' do 18 | output, status = run! 19 | 20 | expect(output).to eq("command=\"#{gitlab_shell_path} username-key\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty principal\n") 21 | expect(status).to be_success 22 | end 23 | 24 | it 'fails when not enough arguments are given' do 25 | output, status = run!(key_id: nil, principals: []) 26 | 27 | expect(output).to eq('') 28 | expect(status).not_to be_success 29 | end 30 | 31 | it 'fails when key_id is blank' do 32 | output, status = run!(key_id: '') 33 | 34 | expect(output).to eq('') 35 | expect(status).not_to be_success 36 | end 37 | 38 | it 'fails when principals include an empty item' do 39 | output, status = run!(principals: ['principal', '']) 40 | 41 | expect(output).to eq('') 42 | expect(status).not_to be_success 43 | end 44 | end 45 | 46 | def run!(key_id: 'key', principals: ['principal']) 47 | cmd = [ 48 | authorized_principals_check_path, 49 | key_id, 50 | principals, 51 | ].flatten.compact 52 | 53 | output = IO.popen(cmd, &:read) 54 | 55 | [output, $?] 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /internal/gitlabnet/healthcheck/client.go: -------------------------------------------------------------------------------- 1 | // Package healthcheck implements a HTTP client to request healthcheck endpoint 2 | package healthcheck 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "net/http" 8 | 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/client" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet" 12 | ) 13 | 14 | const ( 15 | checkPath = "/check" 16 | ) 17 | 18 | // Client defines configuration for healthcheck client 19 | type Client struct { 20 | config *config.Config 21 | client *client.GitlabNetClient 22 | } 23 | 24 | // Response contains healthcheck endpoint response data 25 | type Response struct { 26 | APIVersion string `json:"api_version"` 27 | GitlabVersion string `json:"gitlab_version"` 28 | GitlabRevision string `json:"gitlab_rev"` 29 | Redis bool `json:"redis"` 30 | } 31 | 32 | // NewClient initializes a client's struct 33 | func NewClient(config *config.Config) (*Client, error) { 34 | client, err := gitlabnet.GetClient(config) 35 | if err != nil { 36 | return nil, fmt.Errorf("error creating http client: %v", err) 37 | } 38 | 39 | return &Client{config: config, client: client}, nil 40 | } 41 | 42 | // Check makes a GET request to healthcheck endpoint 43 | func (c *Client) Check(ctx context.Context) (response *Response, err error) { 44 | resp, err := c.client.Get(ctx, checkPath) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | defer func() { 50 | err = resp.Body.Close() 51 | }() 52 | 53 | return parse(resp) 54 | } 55 | 56 | func parse(hr *http.Response) (*Response, error) { 57 | response := &Response{} 58 | if err := gitlabnet.ParseJSON(hr, response); err != nil { 59 | return nil, err 60 | } 61 | 62 | return response, nil 63 | } 64 | -------------------------------------------------------------------------------- /internal/executable/executable.go: -------------------------------------------------------------------------------- 1 | // Package executable provides utilities for managing and locating executables related to GitLab Shell. 2 | package executable 3 | 4 | import ( 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | // Predefined constants representing various executable names and directories. 10 | const ( 11 | BinDir = "bin" 12 | Healthcheck = "check" 13 | GitlabShell = "gitlab-shell" 14 | AuthorizedKeysCheck = "gitlab-shell-authorized-keys-check" 15 | AuthorizedPrincipalsCheck = "gitlab-shell-authorized-principals-check" 16 | ) 17 | 18 | // Executable represents an executable binary, including its name and root directory. 19 | type Executable struct { 20 | Name string 21 | RootDir string 22 | } 23 | 24 | var ( 25 | // osExecutable is overridden in tests 26 | osExecutable = os.Executable 27 | ) 28 | 29 | // New creates a new Executable instance by determining its root directory based on the current executable path. 30 | func New(name string) (*Executable, error) { 31 | path, err := osExecutable() 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | rootDir, err := findRootDir(path) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | executable := &Executable{ 42 | Name: name, 43 | RootDir: rootDir, 44 | } 45 | 46 | return executable, nil 47 | } 48 | 49 | func findRootDir(path string) (string, error) { 50 | // Start: /opt/.../gitlab-shell/bin/gitlab-shell 51 | // Ends: /opt/.../gitlab-shell 52 | rootDir := filepath.Dir(filepath.Dir(path)) 53 | pathFromEnv := os.Getenv("GITLAB_SHELL_DIR") 54 | 55 | if pathFromEnv != "" { 56 | if _, err := os.Stat(pathFromEnv); os.IsNotExist(err) { 57 | return "", err 58 | } 59 | 60 | rootDir = pathFromEnv 61 | } 62 | 63 | return rootDir, nil 64 | } 65 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/certs/valid/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEogIBAAKCAQEAmte3G/eD+quamwyFl+2jEo8ngSAT0FWeY5ZAwRvdF4FgtTBL 3 | vbAdTnyi7pHMesCSUkyxXHHPazM4SDV6uiu5LNKF0iz/NY76rLtFoqSGUgygTZHV 4 | bZ6NRXCNUZ0PslD95wOCWvS9t9xgNXry66k8+mfZNhE+cFQfrO/pN5WpNuGyWTfK 5 | lUQw5NVL3mobj3tSjI+wzSpbPMvbTQoBiZ/VHkyyc15YdrbePwFB2dJbxE/Xgsyk 6 | /TwWSUFnAs6i1x2t+423NIm9rIDTdW2YYJJXv3MUcdDIxJnY0beGePMIymn9ZIRU 7 | JtK/ZXmwMb52v70+YTcsG67uSm31CR8jNt8qpQIDAQABAoIBAEJQyNdtdlTRUfG9 8 | tymOWR0FuoGO322GfcNhAnKyIEqE2oo/GPEwkByhPJa4Ur7v4rrkpcFV7OOYmC40 9 | 2U8KktAjibSuGM8zYSDBQ92YYP6a8bzHDIVaNl7bCWs+vQ49qcBavGWAFBC+jWXa 10 | Nle/r6H/AAQr9nXdUYObbGKl8kbSUBNAqQHILsNyxQsAo12oqRnUWhIbfzUFBr1m 11 | us93OsvpOYWgkbaBWk0brjp2X0eNGHctTboFxRknJcU6MQVL5degbgXhnCm4ir4O 12 | E2KMubEwxePr5fPotWNQXCVin85OQv1eb70anfwoA2b5/ykb57jo5EDoiUoFsjLz 13 | KLAaRQECgYEAzZNP/CpwCh5s31SDr7ajYfNIu8ie370g2Qbf4jrqVrOJ8Sj1LRYB 14 | lS5+QbSRu4W6Ani3AQwZA09lS608G8w5rD7YGRVDCFuwJt+Yz5GcsSkso9B8DR4h 15 | vCe2WuDutz7M5ikP1DAc/9x5HIzjQijxM1JJCNU2nR6QoFvV6wpVcpECgYEAwNK9 16 | oTqyb7UjNinAo9PFrFpnbX+DoGokGPsRyUwi9UkyRR0Uf7Kxjoq2C8zsCvnGdrE7 17 | kwUiWjyfAgMDF8+iWHYO1vD7m6NL31h/AAmo0NEQIBs0LFj0lF0xORzvXdTjhvuG 18 | LxXhm927z4WBOCLTn8FAsBUjVBpmB6ffyZCVWNUCgYA3P4j2fz0/KvAdkSwW9CGy 19 | uFxqwz8XaE/Eo9lVhnnmNTg0TMqfhFOGkUkzRWEJIaZc9a5RJLwwLI1Pqk4GNnul 20 | c/pFu3YZb/LGb780wbB32FX77JL6P4fXdmDGyb6+Fq2giZaMcyXICauu5ZpJ9JDm 21 | Nw4TxqF31ngN8MBr+4n9UQKBgAkxAoEQ/zh79fW6/8fPbHjOxmdd0LRw2s+mCC8E 22 | RhZTKuZIgJWluvkEe7EMT6QmS+OUhzZ25DBQ+3NpGVilOSPmXMa6LgQ5QIChA0zJ 23 | KRbrIE2nflEu3FnGJ3aFfpOGdmIU00yjSmHXrAA0aPh4EIZo++Bo4Yo8x+hNhElj 24 | bvsRAoGADYZTUchbiVndk5QtnbwlDjrF5PmgjoDboBfv9/6FU+DzQRyOpl3kr0hs 25 | OcZGE6xPZJidv1Bcv60L1VzTMj7spvMRTeumn2zEQGjkl6i/fSZzawjmKaKXKNkC 26 | YfoV0RepB4TlNYGICaTcV+aKRIXivcpBGfduZEb39iUKCjh9Afg= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/certs/valid/server2-cert.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAg3XO30wn4+lFxc5fLsanH4Pvu0OuKGHoR94zZjxx7qAEAAAADAQABAAABgQDK4MvpmiZ0cLS4+p3YEwcCwo4kVbJPTEeiIMuoEI7KJ3yqAjKJuns6VpnC6aRgu3LmuM4uBHcimQi415ClBOm4+tFiVsVNcAksA+QuU8rTEC6xPs96y1Y/qC+/WQn6+uKp1+fAspWsLpig3VXSTfq+YAcxZfTdO/6ck0kHVvxX096Ye7D0mdq2lWbGwSBlAbzU1wX/Znv5hLZD4DSG1cIjA9vQ/fJ9pwclZiS0qQ1VXdoIUAvL9tTAKj295VGT2NMGGYZQAQ0vXtM1YHOMAZ4XoPL1oVFjVEZoLRD58a6Dpe4hY8QKe6X1w7zU1rr5vVYrz7MTUa5pzsUSOeUTc3kyKJrExVkoLyBgYQq8qW9kYk/Ox9zHwTAKw9vwiIs2Mwe6nlxJ7cw4zykMsxPK4z9HkbZoTFWy3phuPFqs/WQoCvHTKNjWPab7UAameM6Dn0N83BF21tCTMWjkRCvtZVGIGZ7Y4cAiiN0OPzQWDasJ/IKVDRguZJRn3kgHCMYCgokAAAAAAAAAAAAAAAIAAAAHc2VydmVyMgAAAAAAAAAAAAAAAP//////////AAAAAAAAAAAAAAAAAAABlwAAAAdzc2gtcnNhAAAAAwEAAQAAAYEA3IUBN80/BJG5ut9U238FwIGAfMZQoOXYbFtiQZo+U+cdbGJlEGgV+IToGN78YJypkbK8NiptMMOgWZ8eu6DCJMNxjPb7e3XYGrmHPeoK5Z9ioESDw0QSkxDIfwFoLSnx0+eiXS138ypENgLT/hOMl+fmroA74ZpvkvIpK+RoAo1qlWCWtA7NygkQkWRgnnAj4jkDKq9aT1iHGa9nQYLuTtfiX3l35/oOOMJFrDg7bb5EHnBsKewDoAxdqNbxp4W/sQyUF4NbOFLGzRavA6eNRfOx9UXcdy0v9+UgCmu92nbiPmNBo9wY3D9prwXAWetTM1B3ZCDk1hqIcXRg6pNBNC9e2Fubchs9TcrLaZpr4kgFvTekGcof1m2ozJTVlcIJlJpy95mp1uXwxTPbfXKGPvKu7NroR2avkAIfwRj5E/8HwrgDH2r/4uRPadRCx9hm38HI1n6S4YrrsmL9ffLW6SCfEG6c7+URDQr18Vq9YyxMmu09EK7VlDDX0JM0Uan5AAABlAAAAAxyc2Etc2hhMi01MTIAAAGANOKG8Tq7kp9B5+CQEyb+mEatJOoQRV+4rpemWlfEw6TuVwQN2wSXc6XKBHzSG4NRnFkwk6GgiPLQEf4lBBKA8VYQnDKuhrHJlU4DFCRPw/aceHfCwNOruyJmuf91W3yEO/kYAd6EhkQiW/K3ky7BuXCqR34T2fBZSCeYhNcXWxhEMLoAuj0kEdX+YMNBmiPtinPE13KMFGyIVBm/ojgSZa8j4WnhDcK0cWv0OSGTgJF6q3hENCWRz2E1HroKUiABOy5Nca6gPVAi4OTd7gwER8eh9MngVHYorAJ3N9HjUh640SbL3zCC8f/lqIztqsHY0u3olsQ0gLXpFain+430HeyJlmVlsDZgQKRb90Mm1viSCKvHGpmVDYMimE9y0DCQS1i0yRGF1uSIPtuQ0NCbhS/HPKsT3nYgGCEuoB8aGOu3aGB/tmUkYXW+pwXRKqw0f/zX088XWYWvA+AR4hmmr6DDMnf/4EHgJp3xHTEwOBHCVj69xvlOawBNlL2X0b2p server2.pub 2 | -------------------------------------------------------------------------------- /internal/command/uploadarchive/uploadarchive.go: -------------------------------------------------------------------------------- 1 | // Package uploadarchive provides functionality for uploading archives 2 | package uploadarchive 3 | 4 | import ( 5 | "context" 6 | 7 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command" 8 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/shared/accessverifier" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/shared/disallowedcommand" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 13 | ) 14 | 15 | // Command represents the upload archive command 16 | type Command struct { 17 | Config *config.Config 18 | Args *commandargs.Shell 19 | ReadWriter *readwriter.ReadWriter 20 | } 21 | 22 | type logInfo struct{} 23 | 24 | // Execute executes the upload archive command 25 | func (c *Command) Execute(ctx context.Context) (context.Context, error) { 26 | args := c.Args.SSHArgs 27 | if len(args) != 2 { 28 | return ctx, disallowedcommand.Error 29 | } 30 | 31 | repo := args[1] 32 | response, err := c.verifyAccess(ctx, repo) 33 | if err != nil { 34 | return ctx, err 35 | } 36 | 37 | logData := command.NewLogData( 38 | response.Gitaly.Repo.GlProjectPath, 39 | response.Username, 40 | response.ProjectID, 41 | response.RootNamespaceID, 42 | ) 43 | ctxWithLogData := context.WithValue(ctx, logInfo{}, logData) 44 | 45 | return ctxWithLogData, c.performGitalyCall(ctx, response) 46 | } 47 | 48 | func (c *Command) verifyAccess(ctx context.Context, repo string) (*accessverifier.Response, error) { 49 | cmd := accessverifier.Command{ 50 | Config: c.Config, 51 | Args: c.Args, 52 | ReadWriter: c.ReadWriter, 53 | } 54 | 55 | return cmd.Verify(ctx, c.Args.CommandType, repo) 56 | } 57 | -------------------------------------------------------------------------------- /internal/command/gitauditevent/audit_test.go: -------------------------------------------------------------------------------- 1 | package gitauditevent 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/client/testserver" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 14 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 15 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/accessverifier" 16 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/gitauditevent" 17 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/sshenv" 18 | ) 19 | 20 | var ( 21 | testUsername = "gitlab-shell" 22 | testRepo = "project-1" 23 | ) 24 | 25 | func TestGitAudit(t *testing.T) { 26 | called := false 27 | 28 | requests := []testserver.TestRequestHandler{ 29 | { 30 | Path: "/api/v4/internal/shellhorse/git_audit_event", 31 | Handler: func(w http.ResponseWriter, r *http.Request) { 32 | called = true 33 | 34 | body, err := io.ReadAll(r.Body) 35 | assert.NoError(t, err) 36 | defer r.Body.Close() 37 | 38 | var request *gitauditevent.Request 39 | assert.NoError(t, json.Unmarshal(body, &request)) 40 | assert.Equal(t, testUsername, request.Username) 41 | assert.Equal(t, testRepo, request.Repo) 42 | 43 | w.WriteHeader(http.StatusOK) 44 | }, 45 | }, 46 | } 47 | 48 | args := &commandargs.Shell{ 49 | CommandType: commandargs.UploadArchive, 50 | Env: sshenv.Env{RemoteAddr: "18.245.0.42"}, 51 | } 52 | 53 | url := testserver.StartSocketHTTPServer(t, requests) 54 | Audit(context.Background(), args, &config.Config{GitlabUrl: url}, &accessverifier.Response{ 55 | Username: testUsername, 56 | Repo: testRepo, 57 | }, nil) 58 | 59 | require.True(t, called) 60 | } 61 | -------------------------------------------------------------------------------- /spec/support/gitlab_shell_setup.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | require 'tempfile' 3 | 4 | RSpec.shared_context 'gitlab shell', shared_context: :metadata do 5 | def original_root_path 6 | ROOT_PATH 7 | end 8 | 9 | def config_path 10 | File.join(tmp_root_path, 'config.yml') 11 | end 12 | 13 | def write_config(config) 14 | config['log_file'] ||= Tempfile.new.path 15 | 16 | File.open(config_path, 'w') do |f| 17 | f.write(config.to_yaml) 18 | end 19 | end 20 | 21 | def tmp_root_path 22 | @tmp_root_path ||= File.realpath(Dir.mktmpdir) 23 | end 24 | 25 | def mock_server(server) 26 | raise NotImplementedError.new( 27 | 'mock_server method must be implemented in order to include gitlab shell context' 28 | ) 29 | end 30 | 31 | # This has to be a relative path shorter than 100 bytes due to 32 | # limitations in how Unix sockets work. 33 | def tmp_socket_path 34 | 'tmp/gitlab-shell-socket' 35 | end 36 | 37 | let(:gitlab_shell_path) { File.join(tmp_root_path, 'bin', 'gitlab-shell') } 38 | 39 | before(:all) do 40 | FileUtils.mkdir_p(File.dirname(tmp_socket_path)) 41 | FileUtils.touch(File.join(tmp_root_path, '.gitlab_shell_secret')) 42 | 43 | @server = HTTPUNIXServer.new(BindAddress: tmp_socket_path) 44 | 45 | mock_server(@server) 46 | 47 | @webrick_thread = Thread.new { @server.start } 48 | 49 | sleep(0.1) while @webrick_thread.alive? && @server.status != :Running 50 | raise "Couldn't start stub GitlabNet server" unless @server.status == :Running 51 | system(original_root_path, 'bin/compile') 52 | 53 | FileUtils.rm_rf(File.join(tmp_root_path, 'bin')) 54 | FileUtils.cp_r('bin', tmp_root_path) 55 | end 56 | 57 | after(:all) do 58 | @server.shutdown if @server 59 | @webrick_thread.join if @webrick_thread 60 | FileUtils.rm_rf(tmp_root_path) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /internal/command/discover/discover.go: -------------------------------------------------------------------------------- 1 | // Package discover implements the "discover" command for fetching user info and displaying a welcome message. 2 | package discover 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | 8 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command" 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/discover" 13 | ) 14 | 15 | type logDataKey struct{} 16 | 17 | // Command struct encapsulates the necessary components for executing the Discover command. 18 | type Command struct { 19 | Config *config.Config 20 | Args *commandargs.Shell 21 | ReadWriter *readwriter.ReadWriter 22 | } 23 | 24 | // Execute runs the discover command, fetching and displaying user information. 25 | func (c *Command) Execute(ctx context.Context) (context.Context, error) { 26 | response, err := c.getUserInfo(ctx) 27 | if err != nil { 28 | return ctx, fmt.Errorf("Failed to get username: %v", err) //nolint:stylecheck // This is customer facing message 29 | } 30 | 31 | logData := command.LogData{} 32 | if response.IsAnonymous() { 33 | logData.Username = "Anonymous" 34 | _, _ = fmt.Fprintf(c.ReadWriter.Out, "Welcome to GitLab, Anonymous!\n") 35 | } else { 36 | logData.Username = response.Username 37 | _, _ = fmt.Fprintf(c.ReadWriter.Out, "Welcome to GitLab, @%s!\n", response.Username) 38 | } 39 | 40 | ctxWithLogData := context.WithValue(ctx, logDataKey{}, logData) 41 | 42 | return ctxWithLogData, nil 43 | } 44 | 45 | func (c *Command) getUserInfo(ctx context.Context) (*discover.Response, error) { 46 | client, err := discover.NewClient(c.Config) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | return client.GetByCommandArgs(ctx, c.Args) 52 | } 53 | -------------------------------------------------------------------------------- /internal/command/authorizedprincipals/authorized_principals_test.go: -------------------------------------------------------------------------------- 1 | package authorizedprincipals 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 13 | ) 14 | 15 | func TestExecute(t *testing.T) { 16 | defaultConfig := &config.Config{RootDir: "/tmp"} 17 | 18 | testCases := []struct { 19 | desc string 20 | arguments *commandargs.AuthorizedPrincipals 21 | expectedOutput string 22 | }{ 23 | { 24 | desc: "With single principal", 25 | arguments: &commandargs.AuthorizedPrincipals{KeyID: "key", Principals: []string{"principal"}}, 26 | expectedOutput: "command=\"/tmp/bin/gitlab-shell username-key\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty principal\n", 27 | }, 28 | { 29 | desc: "With multiple principals", 30 | arguments: &commandargs.AuthorizedPrincipals{KeyID: "key", Principals: []string{"principal-1", "principal-2"}}, 31 | expectedOutput: "command=\"/tmp/bin/gitlab-shell username-key\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty principal-1\ncommand=\"/tmp/bin/gitlab-shell username-key\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty principal-2\n", 32 | }, 33 | } 34 | 35 | for _, tc := range testCases { 36 | t.Run(tc.desc, func(t *testing.T) { 37 | buffer := &bytes.Buffer{} 38 | 39 | cmd := &Command{ 40 | Config: defaultConfig, 41 | Args: tc.arguments, 42 | ReadWriter: &readwriter.ReadWriter{Out: buffer}, 43 | } 44 | 45 | _, err := cmd.Execute(context.Background()) 46 | 47 | require.NoError(t, err) 48 | require.Equal(t, tc.expectedOutput, buffer.String()) 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /internal/gitaly/gitaly_test.go: -------------------------------------------------------------------------------- 1 | package gitaly 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/prometheus/client_golang/prometheus/testutil" 8 | "github.com/stretchr/testify/require" 9 | 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/metrics" 11 | ) 12 | 13 | func TestPrometheusMetrics(t *testing.T) { 14 | metrics.GitalyConnectionsTotal.Reset() 15 | 16 | c := newClient() 17 | 18 | cmd := Command{ServiceName: "git-upload-pack", Address: "tcp://localhost:9999"} 19 | c.newConnection(context.Background(), cmd) 20 | c.newConnection(context.Background(), cmd) 21 | 22 | require.Equal(t, 1, testutil.CollectAndCount(metrics.GitalyConnectionsTotal)) 23 | require.InDelta(t, 2, testutil.ToFloat64(metrics.GitalyConnectionsTotal.WithLabelValues("ok")), 0.1) 24 | require.InDelta(t, 0, testutil.ToFloat64(metrics.GitalyConnectionsTotal.WithLabelValues("fail")), 0.1) 25 | 26 | cmd = Command{Address: ""} 27 | c.newConnection(context.Background(), cmd) 28 | 29 | require.InDelta(t, 2, testutil.ToFloat64(metrics.GitalyConnectionsTotal.WithLabelValues("ok")), 0.1) 30 | require.InDelta(t, 1, testutil.ToFloat64(metrics.GitalyConnectionsTotal.WithLabelValues("fail")), 0.1) 31 | } 32 | 33 | func TestCachedConnections(t *testing.T) { 34 | c := newClient() 35 | 36 | require.Empty(t, c.cache.connections) 37 | 38 | cmd := Command{ServiceName: "git-upload-pack", Address: "tcp://localhost:9999"} 39 | 40 | conn, err := c.GetConnection(context.Background(), cmd) 41 | require.NoError(t, err) 42 | require.Len(t, c.cache.connections, 1) 43 | 44 | newConn, err := c.GetConnection(context.Background(), cmd) 45 | require.NoError(t, err) 46 | require.Len(t, c.cache.connections, 1) 47 | require.Equal(t, conn, newConn) 48 | 49 | cmd = Command{ServiceName: "git-upload-pack", Address: "tcp://localhost:9998"} 50 | _, err = c.GetConnection(context.Background(), cmd) 51 | require.NoError(t, err) 52 | require.Len(t, c.cache.connections, 2) 53 | } 54 | 55 | func newClient() *Client { 56 | c := &Client{} 57 | c.InitSidechannelRegistry(context.Background()) 58 | return c 59 | } 60 | -------------------------------------------------------------------------------- /internal/gitlabnet/authorizedkeys/client.go: -------------------------------------------------------------------------------- 1 | // Package authorizedkeys provides functionality for interacting with authorized keys. 2 | package authorizedkeys 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "net/url" 8 | 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/client" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet" 12 | ) 13 | 14 | const ( 15 | // AuthorizedKeysPath represents the path to authorized keys endpoint 16 | AuthorizedKeysPath = "/authorized_keys" 17 | ) 18 | 19 | // Client represents a client for interacting with authorized keys 20 | type Client struct { 21 | config *config.Config 22 | client *client.GitlabNetClient 23 | } 24 | 25 | // Response represents the response structure for authorized keys 26 | type Response struct { 27 | ID int64 `json:"id"` 28 | Key string `json:"key"` 29 | } 30 | 31 | // NewClient creates a new instance of the authorized keys client 32 | func NewClient(config *config.Config) (*Client, error) { 33 | client, err := gitlabnet.GetClient(config) 34 | if err != nil { 35 | return nil, fmt.Errorf("error creating http client: %v", err) 36 | } 37 | 38 | return &Client{config: config, client: client}, nil 39 | } 40 | 41 | // GetByKey retrieves authorized keys by key 42 | func (c *Client) GetByKey(ctx context.Context, key string) (*Response, error) { 43 | path, err := pathWithKey(key) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | response, err := c.client.Get(ctx, path) 49 | if err != nil { 50 | return nil, err 51 | } 52 | defer func() { _ = response.Body.Close() }() 53 | 54 | parsedResponse := &Response{} 55 | if err := gitlabnet.ParseJSON(response, parsedResponse); err != nil { 56 | return nil, err 57 | } 58 | 59 | return parsedResponse, nil 60 | } 61 | 62 | func pathWithKey(key string) (string, error) { 63 | u, err := url.Parse(AuthorizedKeysPath) 64 | if err != nil { 65 | return "", err 66 | } 67 | 68 | params := u.Query() 69 | params.Set("key", key) 70 | u.RawQuery = params.Encode() 71 | 72 | return u.String(), nil 73 | } 74 | -------------------------------------------------------------------------------- /internal/command/uploadpack/uploadpack_test.go: -------------------------------------------------------------------------------- 1 | package uploadpack 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/client/testserver" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command" 14 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 15 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 16 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 17 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/testhelper/requesthandlers" 18 | ) 19 | 20 | func TestAllowedAccess(t *testing.T) { 21 | gitalyAddress, _ := testserver.StartGitalyServer(t, "unix") 22 | requests := requesthandlers.BuildAllowedWithGitalyHandlers(t, gitalyAddress) 23 | cmd := setup(t, "1", requests) 24 | cmd.Config.GitalyClient.InitSidechannelRegistry(context.Background()) 25 | 26 | ctxWithLogData, err := cmd.Execute(context.Background()) 27 | require.NoError(t, err) 28 | 29 | data := ctxWithLogData.Value(logDataKey{}).(command.LogData) 30 | require.Equal(t, "alex-doe", data.Username) 31 | require.Equal(t, "group/project-path", data.Meta.Project) 32 | require.Equal(t, "group", data.Meta.RootNamespace) 33 | } 34 | 35 | func TestForbiddenAccess(t *testing.T) { 36 | requests := requesthandlers.BuildDisallowedByAPIHandlers(t) 37 | cmd := setup(t, "disallowed", requests) 38 | 39 | _, err := cmd.Execute(context.Background()) 40 | require.Equal(t, "Disallowed by API call", err.Error()) 41 | } 42 | 43 | func setup(t *testing.T, keyID string, requests []testserver.TestRequestHandler) *Command { 44 | url := testserver.StartHTTPServer(t, requests) 45 | 46 | output := &bytes.Buffer{} 47 | input := io.NopCloser(strings.NewReader("input")) 48 | 49 | cmd := &Command{ 50 | Config: &config.Config{GitlabUrl: url}, 51 | Args: &commandargs.Shell{GitlabKeyID: keyID, SSHArgs: []string{"git-upload-pack", "group/repo"}}, 52 | ReadWriter: &readwriter.ReadWriter{ErrOut: output, Out: output, In: input}, 53 | } 54 | 55 | return cmd 56 | } 57 | -------------------------------------------------------------------------------- /client/transport.go: -------------------------------------------------------------------------------- 1 | // Package client provides an HTTP client with enhanced logging, tracing, and correlation handling. 2 | package client 3 | 4 | import ( 5 | "net/http" 6 | "time" 7 | 8 | "gitlab.com/gitlab-org/labkit/correlation" 9 | "gitlab.com/gitlab-org/labkit/log" 10 | "gitlab.com/gitlab-org/labkit/tracing" 11 | ) 12 | 13 | type transport struct { 14 | next http.RoundTripper 15 | } 16 | 17 | // RoundTrip executes a single HTTP transaction, adding logging and tracing capabilities. 18 | func (rt *transport) RoundTrip(request *http.Request) (*http.Response, error) { 19 | ctx := request.Context() 20 | 21 | originalRemoteIP, ok := ctx.Value(OriginalRemoteIPContextKey{}).(string) 22 | if ok { 23 | request.Header.Add("X-Forwarded-For", originalRemoteIP) 24 | } 25 | request.Close = true 26 | request.Header.Add("User-Agent", defaultUserAgent) 27 | 28 | start := time.Now() 29 | 30 | response, err := rt.next.RoundTrip(request) 31 | 32 | fields := log.Fields{ 33 | "method": request.Method, 34 | "url": request.URL.String(), 35 | "duration_ms": time.Since(start) / time.Millisecond, 36 | } 37 | logger := log.WithContextFields(ctx, fields) 38 | 39 | if err != nil { 40 | logger.WithError(err).Error("Internal API unreachable") 41 | return response, err 42 | } 43 | 44 | logger = logger.WithField("status", response.StatusCode) 45 | 46 | if response.StatusCode >= 400 { 47 | logger.WithError(err).Error("Internal API error") 48 | return response, err 49 | } 50 | 51 | if response.ContentLength >= 0 { 52 | logger = logger.WithField("content_length_bytes", response.ContentLength) 53 | } 54 | 55 | logger.Info("Finished HTTP request") 56 | 57 | return response, nil 58 | } 59 | 60 | // DefaultTransport returns a clone of the default HTTP transport. 61 | func DefaultTransport() http.RoundTripper { 62 | return http.DefaultTransport.(*http.Transport).Clone() 63 | } 64 | 65 | // NewTransport creates a new transport with logging, tracing, and correlation handling. 66 | func NewTransport(next http.RoundTripper) http.RoundTripper { 67 | t := &transport{next: next} 68 | return correlation.NewInstrumentedRoundTripper(tracing.NewRoundTripper(t)) 69 | } 70 | -------------------------------------------------------------------------------- /internal/gitlabnet/authorizedcerts/client.go: -------------------------------------------------------------------------------- 1 | // Package authorizedcerts implements functions for authorizing users with ssh certificates 2 | package authorizedcerts 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "net/url" 8 | 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/client" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet" 12 | ) 13 | 14 | const ( 15 | authorizedCertsPath = "/authorized_certs" 16 | ) 17 | 18 | // Client wraps a gitlab client and its associated config 19 | type Client struct { 20 | config *config.Config 21 | client *client.GitlabNetClient 22 | } 23 | 24 | // Response contains the json response from authorized_certs 25 | type Response struct { 26 | Username string `json:"username"` 27 | Namespace string `json:"namespace"` 28 | } 29 | 30 | // NewClient instantiates a Client with config 31 | func NewClient(config *config.Config) (*Client, error) { 32 | client, err := gitlabnet.GetClient(config) 33 | if err != nil { 34 | return nil, fmt.Errorf("error creating http client: %v", err) 35 | } 36 | 37 | return &Client{config: config, client: client}, nil 38 | } 39 | 40 | // GetByKey makes a request to authorized_certs for the namespace configured with a cert that matches fingerprint 41 | func (c *Client) GetByKey(ctx context.Context, userID, fingerprint string) (*Response, error) { 42 | path, err := pathWithKey(userID, fingerprint) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | response, err := c.client.Get(ctx, path) 48 | if err != nil { 49 | return nil, err 50 | } 51 | defer func() { 52 | _ = response.Body.Close() 53 | }() 54 | 55 | parsedResponse := &Response{} 56 | if err := gitlabnet.ParseJSON(response, parsedResponse); err != nil { 57 | return nil, err 58 | } 59 | 60 | return parsedResponse, nil 61 | } 62 | 63 | func pathWithKey(userID, fingerprint string) (string, error) { 64 | u, err := url.Parse(authorizedCertsPath) 65 | if err != nil { 66 | return "", err 67 | } 68 | 69 | params := u.Query() 70 | params.Set("key", fingerprint) 71 | params.Set("user_identifier", userID) 72 | u.RawQuery = params.Encode() 73 | 74 | return u.String(), nil 75 | } 76 | -------------------------------------------------------------------------------- /internal/command/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | stage: Create 3 | group: Source Code 4 | info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments 5 | --- 6 | 7 | # Overview 8 | 9 | This package consists of a set of packages that are responsible for executing a particular command/feature/operation. 10 | The full list of features can be viewed [here](https://gitlab.com/gitlab-org/gitlab-shell/-/blob/main/doc/features.md). 11 | The commands implement the common interface: 12 | 13 | ```go 14 | type Command interface { 15 | Execute(ctx context.Context) error 16 | } 17 | ``` 18 | 19 | A command is executed by running the `Execute` method. The execution logic mostly shares the common pattern: 20 | 21 | - Parse the arguments and validate them. 22 | - Communicate with GitLab Rails using [gitlabnet](https://gitlab.com/gitlab-org/gitlab-shell/-/tree/main/internal/gitlabnet) package. For example, it can be checking whether a client is authorized to execute this particular command or asking for a personal access token in order to return it to the client. 23 | - If a command is related to Git operations, establish a connection with Gitaly using [handler](https://gitlab.com/gitlab-org/gitlab-shell/-/tree/main/internal/handler) and [gitaly](https://gitlab.com/gitlab-org/gitlab-shell/-/tree/main/internal/gitaly) packages and provide two-way communication between Gitaly and the client. 24 | - Return results to the client. 25 | 26 | This package is being used to build a particular command based on the passed arguments in the following files that are under `cmd` directory: 27 | - [cmd/gitlab-shell/command](https://gitlab.com/gitlab-org/gitlab-shell/-/tree/main/cmd/gitlab-shell/command) 28 | - [cmd/gitlab-shell-check/command](https://gitlab.com/gitlab-org/gitlab-shell/-/tree/main/cmd/gitlab-shell-check/command) 29 | - [cmd/gitlab-shell-authorized-keys-check/command](https://gitlab.com/gitlab-org/gitlab-shell/-/tree/main/cmd/gitlab-shell-authorized-keys-check/command) 30 | - [cmd/gitlab-shell-authorized-principals-check/command](https://gitlab.com/gitlab-org/gitlab-shell/-/tree/main/cmd/gitlab-shell-authorized-principals-check/command) 31 | -------------------------------------------------------------------------------- /cmd/gitlab-shell-check/main.go: -------------------------------------------------------------------------------- 1 | // Package main is the entry point for the GitLab Shell health check command. 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | 8 | checkCmd "gitlab.com/gitlab-org/gitlab-shell/v14/cmd/gitlab-shell-check/command" 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/executable" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/logger" 14 | ) 15 | 16 | var ( 17 | // Version is the current version of gitlab-shell 18 | Version = "(unknown version)" // Set at build time in the Makefile 19 | // BuildTime signifies the time the binary was build 20 | BuildTime = "19700101.000000" // Set at build time in the Makefile 21 | ) 22 | 23 | func main() { 24 | os.Exit(run()) 25 | } 26 | 27 | func run() int { 28 | command.CheckForVersionFlag(os.Args, Version, BuildTime) 29 | 30 | readWriter := &readwriter.ReadWriter{ 31 | Out: &readwriter.CountingWriter{W: os.Stdout}, 32 | In: os.Stdin, 33 | ErrOut: os.Stderr, 34 | } 35 | 36 | exitOnError := func(err error, message string) int { 37 | if err != nil { 38 | _, _ = fmt.Fprintf(readWriter.ErrOut, "%s: %v\n", message, err) 39 | return 1 40 | } 41 | return 0 42 | } 43 | 44 | executable, err := executable.New(executable.Healthcheck) 45 | if code := exitOnError(err, "Failed to determine executable, exiting"); code != 0 { 46 | return code 47 | } 48 | 49 | config, err := config.NewFromDirExternal(executable.RootDir) 50 | if code := exitOnError(err, "Failed to read config, exiting"); code != 0 { 51 | return code 52 | } 53 | 54 | logCloser := logger.Configure(config) 55 | defer logCloser.Close() //nolint:errcheck 56 | 57 | cmd, err := checkCmd.New(config, readWriter) 58 | if code := exitOnError(err, "Failed to create command"); code != 0 { 59 | return code 60 | } 61 | 62 | ctx, finished := command.Setup(executable.Name, config) 63 | defer finished() 64 | 65 | if _, err = cmd.Execute(ctx); err != nil { 66 | _, _ = fmt.Fprintf(readWriter.ErrOut, "%v\n", err) 67 | return 1 68 | } 69 | return 0 70 | } 71 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/certs/client/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | 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 33 | bQ== 34 | -----END CERTIFICATE----- 35 | -------------------------------------------------------------------------------- /internal/console/console.go: -------------------------------------------------------------------------------- 1 | // Package console provides functions for displaying console messages. 2 | package console 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "strings" 8 | ) 9 | 10 | // DisplayWarningMessage displays a warning message to the specified output. 11 | func DisplayWarningMessage(message string, out io.Writer) { 12 | DisplayWarningMessages([]string{message}, out) 13 | } 14 | 15 | // DisplayInfoMessage displays an informational message to the specified output. 16 | func DisplayInfoMessage(message string, out io.Writer) { 17 | DisplayInfoMessages([]string{message}, out) 18 | } 19 | 20 | // DisplayWarningMessages displays multiple warning messages to the specified output. 21 | func DisplayWarningMessages(messages []string, out io.Writer) { 22 | DisplayMessages(messages, out, true) 23 | } 24 | 25 | // DisplayInfoMessages displays multiple informational messages to the specified output. 26 | func DisplayInfoMessages(messages []string, out io.Writer) { 27 | DisplayMessages(messages, out, false) 28 | } 29 | 30 | // DisplayMessages displays multiple messages to the specified output, with an optional divider. 31 | func DisplayMessages(messages []string, out io.Writer, displayDivider bool) { 32 | if noMessages(messages) { 33 | return 34 | } 35 | 36 | displayBlankLineOrDivider(out, displayDivider) 37 | 38 | for _, msg := range messages { 39 | _, _ = fmt.Fprint(out, formatLine(msg)) 40 | } 41 | 42 | displayBlankLineOrDivider(out, displayDivider) 43 | } 44 | 45 | func noMessages(messages []string) bool { 46 | if len(messages) == 0 { 47 | return true 48 | } 49 | 50 | for _, msg := range messages { 51 | if len(strings.TrimSpace(msg)) > 0 { 52 | return false 53 | } 54 | } 55 | 56 | return true 57 | } 58 | 59 | func formatLine(message string) string { 60 | return fmt.Sprintf("remote: %v\n", message) 61 | } 62 | 63 | func displayBlankLineOrDivider(out io.Writer, displayDivider bool) { 64 | if displayDivider { 65 | _, _ = fmt.Fprint(out, divider()) 66 | } else { 67 | _, _ = fmt.Fprint(out, blankLine()) 68 | } 69 | } 70 | 71 | func blankLine() string { 72 | return formatLine("") 73 | } 74 | 75 | func divider() string { 76 | ruler := strings.Repeat("=", 72) 77 | 78 | return fmt.Sprintf("%v%v%v", blankLine(), formatLine(ruler), blankLine()) 79 | } 80 | -------------------------------------------------------------------------------- /cmd/gitlab-shell-authorized-principals-check/main.go: -------------------------------------------------------------------------------- 1 | // Package main is the entry point for the GitLab Shell authorized principals check command. 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | 8 | cmd "gitlab.com/gitlab-org/gitlab-shell/v14/cmd/gitlab-shell-authorized-principals-check/command" 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/console" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/executable" 14 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/logger" 15 | ) 16 | 17 | var ( 18 | // Version is the current version of gitlab-shell 19 | Version = "(unknown version)" // Set at build time in the Makefile 20 | // BuildTime signifies the time the binary was build 21 | BuildTime = "19700101.000000" // Set at build time in the Makefile 22 | ) 23 | 24 | func main() { 25 | os.Exit(run()) 26 | } 27 | 28 | func run() int { 29 | command.CheckForVersionFlag(os.Args, Version, BuildTime) 30 | 31 | readWriter := &readwriter.ReadWriter{ 32 | Out: &readwriter.CountingWriter{W: os.Stdout}, 33 | In: os.Stdin, 34 | ErrOut: os.Stderr, 35 | } 36 | 37 | executable, err := executable.New(executable.AuthorizedPrincipalsCheck) 38 | if err != nil { 39 | _, _ = fmt.Fprintln(readWriter.ErrOut, "Failed to determine executable, exiting") 40 | return 1 41 | } 42 | 43 | config, err := config.NewFromDirExternal(executable.RootDir) 44 | if err != nil { 45 | _, _ = fmt.Fprintln(readWriter.ErrOut, "Failed to read config, exiting:", err) 46 | return 1 47 | } 48 | 49 | logCloser := logger.Configure(config) 50 | defer logCloser.Close() //nolint:errcheck 51 | 52 | cmd, err := cmd.New(os.Args[1:], config, readWriter) 53 | if err != nil { 54 | // For now this could happen if `SSH_CONNECTION` is not set on 55 | // the environment 56 | _, _ = fmt.Fprintf(readWriter.ErrOut, "%v\n", err) 57 | return 1 58 | } 59 | 60 | ctx, finished := command.Setup(executable.Name, config) 61 | defer finished() 62 | 63 | if _, err = cmd.Execute(ctx); err != nil { 64 | console.DisplayWarningMessage(err.Error(), readWriter.ErrOut) 65 | return 1 66 | } 67 | return 0 68 | } 69 | -------------------------------------------------------------------------------- /internal/command/authorizedkeys/authorized_keys.go: -------------------------------------------------------------------------------- 1 | // Package authorizedkeys handles fetching and printing authorized SSH keys. 2 | package authorizedkeys 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "strconv" 8 | 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/authorizedkeys" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/keyline" 14 | ) 15 | 16 | // Command contains the configuration, arguments, and I/O interfaces. 17 | type Command struct { 18 | Config *config.Config 19 | Args *commandargs.AuthorizedKeys 20 | ReadWriter *readwriter.ReadWriter 21 | } 22 | 23 | // Execute runs the command to fetch and print the authorized SSH key. 24 | func (c *Command) Execute(ctx context.Context) (context.Context, error) { 25 | // Do and return nothing when the expected and actual user don't match. 26 | // This can happen when the user in sshd_config doesn't match the user 27 | // trying to login. When nothing is printed, the user will be denied access. 28 | if c.Args.ExpectedUser != c.Args.ActualUser { 29 | // TODO: Log this event once we have a consistent way to log in Go. 30 | // See https://gitlab.com/gitlab-org/gitlab-shell/issues/192 for more info. 31 | return ctx, nil 32 | } 33 | 34 | if err := c.printKeyLine(ctx); err != nil { 35 | return ctx, err 36 | } 37 | 38 | return ctx, nil 39 | } 40 | 41 | func (c *Command) printKeyLine(ctx context.Context) error { 42 | response, err := c.getAuthorizedKey(ctx) 43 | if err != nil { 44 | _, _ = fmt.Fprintf(c.ReadWriter.Out, "# No key was found for %s\n", c.Args.Key) 45 | return nil 46 | } 47 | 48 | keyLine, err := keyline.NewPublicKeyLine(strconv.FormatInt(response.ID, 10), response.Key, c.Config) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | _, _ = fmt.Fprintln(c.ReadWriter.Out, keyLine.ToString()) 54 | 55 | return nil 56 | } 57 | 58 | func (c *Command) getAuthorizedKey(ctx context.Context) (*authorizedkeys.Response, error) { 59 | client, err := authorizedkeys.NewClient(c.Config) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | return client.GetByKey(ctx, c.Args.Key) 65 | } 66 | -------------------------------------------------------------------------------- /internal/command/uploadarchive/uploadarchive_test.go: -------------------------------------------------------------------------------- 1 | package uploadarchive 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | "gitlab.com/gitlab-org/labkit/correlation" 12 | 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/client/testserver" 14 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command" 15 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 16 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 17 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 18 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/testhelper/requesthandlers" 19 | ) 20 | 21 | func TestAllowedAccess(t *testing.T) { 22 | gitalyAddress, _ := testserver.StartGitalyServer(t, "unix") 23 | requests := requesthandlers.BuildAllowedWithGitalyHandlers(t, gitalyAddress) 24 | cmd := setup(t, "1", requests) 25 | cmd.Config.GitalyClient.InitSidechannelRegistry(context.Background()) 26 | 27 | correlationID := correlation.SafeRandomID() 28 | ctx := correlation.ContextWithCorrelation(context.Background(), correlationID) 29 | ctx = correlation.ContextWithClientName(ctx, "gitlab-shell-tests") 30 | ctxWithLogData, err := cmd.Execute(ctx) 31 | require.NoError(t, err) 32 | 33 | data := ctxWithLogData.Value(logInfo{}).(command.LogData) 34 | require.Equal(t, "alex-doe", data.Username) 35 | require.Equal(t, "group/project-path", data.Meta.Project) 36 | require.Equal(t, "group", data.Meta.RootNamespace) 37 | } 38 | 39 | func TestForbiddenAccess(t *testing.T) { 40 | requests := requesthandlers.BuildDisallowedByAPIHandlers(t) 41 | 42 | cmd := setup(t, "disallowed", requests) 43 | 44 | _, err := cmd.Execute(context.Background()) 45 | require.Equal(t, "Disallowed by API call", err.Error()) 46 | } 47 | 48 | func setup(t *testing.T, keyID string, requests []testserver.TestRequestHandler) *Command { 49 | url := testserver.StartHTTPServer(t, requests) 50 | 51 | output := &bytes.Buffer{} 52 | input := io.NopCloser(strings.NewReader("input")) 53 | 54 | cmd := &Command{ 55 | Config: &config.Config{GitlabUrl: url}, 56 | Args: &commandargs.Shell{GitlabKeyID: keyID, SSHArgs: []string{"git-upload-archive", "group/repo"}}, 57 | ReadWriter: &readwriter.ReadWriter{ErrOut: output, Out: output, In: input}, 58 | } 59 | 60 | return cmd 61 | } 62 | -------------------------------------------------------------------------------- /internal/gitlabnet/gitauditevent/client.go: -------------------------------------------------------------------------------- 1 | // Package gitauditevent handles Git audit events for GitLab. 2 | package gitauditevent 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | 8 | pb "gitlab.com/gitlab-org/gitaly/v16/proto/go/gitalypb" 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/client" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet" 13 | ) 14 | 15 | const uri = "/api/v4/internal/shellhorse/git_audit_event" 16 | 17 | // Client handles communication with the GitLab audit event API. 18 | type Client struct { 19 | config *config.Config 20 | client *client.GitlabNetClient 21 | } 22 | 23 | // NewClient creates a new Client for sending audit events. 24 | func NewClient(config *config.Config) (*Client, error) { 25 | client, err := gitlabnet.GetClient(config) 26 | if err != nil { 27 | return nil, fmt.Errorf("error creating http client: %w", err) 28 | } 29 | 30 | return &Client{config: config, client: client}, nil 31 | } 32 | 33 | // Request represents the data for a Git audit event. 34 | type Request struct { 35 | Action commandargs.CommandType `json:"action"` 36 | Protocol string `json:"protocol"` 37 | Repo string `json:"gl_repository"` 38 | Username string `json:"username"` 39 | PackfileStats *pb.PackfileNegotiationStatistics `json:"packfile_stats,omitempty"` 40 | CheckIP string `json:"check_ip,omitempty"` 41 | Changes string `json:"changes"` 42 | } 43 | 44 | // Audit sends an audit event to the GitLab API. 45 | func (c *Client) Audit(ctx context.Context, username string, args *commandargs.Shell, repo string, packfileStats *pb.PackfileNegotiationStatistics) error { 46 | request := &Request{ 47 | Action: args.CommandType, 48 | Repo: repo, 49 | Protocol: "ssh", 50 | Username: username, 51 | PackfileStats: packfileStats, 52 | CheckIP: gitlabnet.ParseIP(args.Env.RemoteAddr), 53 | Changes: "_any", 54 | } 55 | 56 | response, err := c.client.Post(ctx, uri, request) 57 | if err != nil { 58 | return err 59 | } 60 | defer response.Body.Close() //nolint:errcheck 61 | 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /internal/keyline/key_line.go: -------------------------------------------------------------------------------- 1 | // Package keyline provides functionality for managing SSH key lines 2 | package keyline 3 | 4 | import ( 5 | "fmt" 6 | "path" 7 | "regexp" 8 | "strings" 9 | 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/executable" 12 | ) 13 | 14 | var ( 15 | keyRegex = regexp.MustCompile(`\A[a-zA-Z0-9._-]+\z`) 16 | ) 17 | 18 | const ( 19 | // PublicKeyPrefix is the prefix used for public keys 20 | PublicKeyPrefix = "key" 21 | 22 | // PrincipalPrefix is the prefix used for principals 23 | PrincipalPrefix = "username" 24 | 25 | // SSHOptions specifies SSH options for key lines 26 | SSHOptions = "no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty" 27 | ) 28 | 29 | // KeyLine represents a struct used for SSH key management 30 | type KeyLine struct { 31 | ID string // This can be either an ID of a Key or username 32 | Value string // This can be either a public key or a principal name 33 | Prefix string 34 | Config *config.Config 35 | } 36 | 37 | // NewPublicKeyLine creates a new KeyLine for a public key 38 | func NewPublicKeyLine(id, publicKey string, config *config.Config) (*KeyLine, error) { 39 | return newKeyLine(id, publicKey, PublicKeyPrefix, config) 40 | } 41 | 42 | // NewPrincipalKeyLine creates a new KeyLine for a principal 43 | func NewPrincipalKeyLine(keyID, principal string, config *config.Config) (*KeyLine, error) { 44 | return newKeyLine(keyID, principal, PrincipalPrefix, config) 45 | } 46 | 47 | // ToString converts a KeyLine to a string representation 48 | func (k *KeyLine) ToString() string { 49 | command := fmt.Sprintf("%s %s-%s", path.Join(k.Config.RootDir, executable.BinDir, executable.GitlabShell), k.Prefix, k.ID) 50 | 51 | return fmt.Sprintf(`command="%s",%s %s`, command, SSHOptions, k.Value) 52 | } 53 | 54 | func newKeyLine(id, value, prefix string, config *config.Config) (*KeyLine, error) { 55 | if err := validate(id, value); err != nil { 56 | return nil, err 57 | } 58 | 59 | return &KeyLine{ID: id, Value: value, Prefix: prefix, Config: config}, nil 60 | } 61 | 62 | func validate(id string, value string) error { 63 | if !keyRegex.MatchString(id) { 64 | return fmt.Errorf("invalid key_id: %s", id) 65 | } 66 | 67 | if strings.Contains(value, "\n") { 68 | return fmt.Errorf("invalid value: %s", value) 69 | } 70 | 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /internal/command/uploadpack/uploadpack.go: -------------------------------------------------------------------------------- 1 | // Package uploadpack provides functionality for handling upload-pack command. 2 | package uploadpack 3 | 4 | import ( 5 | "context" 6 | 7 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/githttp" 8 | 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/gitauditevent" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/shared/accessverifier" 14 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/shared/disallowedcommand" 15 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 16 | ) 17 | 18 | // Command represents the upload-pack command 19 | type Command struct { 20 | Config *config.Config 21 | Args *commandargs.Shell 22 | ReadWriter *readwriter.ReadWriter 23 | } 24 | 25 | type logDataKey struct{} 26 | 27 | // Execute executes the upload-pack command 28 | func (c *Command) Execute(ctx context.Context) (context.Context, error) { 29 | args := c.Args.SSHArgs 30 | if len(args) != 2 { 31 | return ctx, disallowedcommand.Error 32 | } 33 | 34 | repo := args[1] 35 | response, err := c.verifyAccess(ctx, repo) 36 | if err != nil { 37 | return ctx, err 38 | } 39 | 40 | logData := command.NewLogData( 41 | response.Gitaly.Repo.GlProjectPath, 42 | response.Username, 43 | response.ProjectID, 44 | response.RootNamespaceID, 45 | ) 46 | ctxWithLogData := context.WithValue(ctx, logDataKey{}, logData) 47 | 48 | if response.IsCustomAction() { 49 | cmd := githttp.PullCommand{ 50 | Config: c.Config, 51 | ReadWriter: c.ReadWriter, 52 | Args: c.Args, 53 | Response: response, 54 | } 55 | 56 | return ctxWithLogData, cmd.Execute(ctx) 57 | } 58 | 59 | stats, err := c.performGitalyCall(ctx, response) 60 | if err != nil { 61 | return ctxWithLogData, err 62 | } 63 | 64 | if response.NeedAudit { 65 | gitauditevent.Audit(ctx, c.Args, c.Config, response, stats) 66 | } 67 | return ctxWithLogData, nil 68 | } 69 | 70 | func (c *Command) verifyAccess(ctx context.Context, repo string) (*accessverifier.Response, error) { 71 | cmd := accessverifier.Command{ 72 | Config: c.Config, 73 | Args: c.Args, 74 | ReadWriter: c.ReadWriter, 75 | } 76 | 77 | return cmd.Verify(ctx, c.Args.CommandType, repo) 78 | } 79 | -------------------------------------------------------------------------------- /internal/gitlabnet/lfsauthenticate/client.go: -------------------------------------------------------------------------------- 1 | // Package lfsauthenticate provides functionality for authenticating Large File Storage (LFS) requests 2 | package lfsauthenticate 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "net/http" 8 | "strings" 9 | 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/client" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet" 14 | ) 15 | 16 | // Client represents a client for LFS authentication 17 | type Client struct { 18 | config *config.Config 19 | client *client.GitlabNetClient 20 | args *commandargs.Shell 21 | } 22 | 23 | // Request represents a request for LFS authentication 24 | type Request struct { 25 | Operation string `json:"operation"` 26 | Repo string `json:"project"` 27 | KeyID string `json:"key_id,omitempty"` 28 | UserID string `json:"user_id,omitempty"` 29 | } 30 | 31 | // Response represents a response from LFS authentication 32 | type Response struct { 33 | Username string `json:"username"` 34 | LfsToken string `json:"lfs_token"` 35 | RepoPath string `json:"repository_http_path"` 36 | ExpiresIn int `json:"expires_in"` 37 | } 38 | 39 | // NewClient creates a new LFS authentication client 40 | func NewClient(config *config.Config, args *commandargs.Shell) (*Client, error) { 41 | client, err := gitlabnet.GetClient(config) 42 | if err != nil { 43 | return nil, fmt.Errorf("error creating http client: %v", err) 44 | } 45 | 46 | return &Client{config: config, client: client, args: args}, nil 47 | } 48 | 49 | // Authenticate performs authentication for LFS requests 50 | func (c *Client) Authenticate(ctx context.Context, operation, repo, userID string) (*Response, error) { 51 | request := &Request{Operation: operation, Repo: repo} 52 | if c.args.GitlabKeyID != "" { 53 | request.KeyID = c.args.GitlabKeyID 54 | } else { 55 | request.UserID = strings.TrimPrefix(userID, "user-") 56 | } 57 | 58 | response, err := c.client.Post(ctx, "/lfs_authenticate", request) 59 | if err != nil { 60 | return nil, err 61 | } 62 | defer func() { _ = response.Body.Close() }() 63 | 64 | return parse(response) 65 | } 66 | 67 | func parse(hr *http.Response) (*Response, error) { 68 | response := &Response{} 69 | if err := gitlabnet.ParseJSON(hr, response); err != nil { 70 | return nil, err 71 | } 72 | 73 | return response, nil 74 | } 75 | -------------------------------------------------------------------------------- /cmd/gitlab-shell-authorized-keys-check/main.go: -------------------------------------------------------------------------------- 1 | // Package main is the entry point for the gitlab-shell-authorized-keys-check command 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | 8 | cmd "gitlab.com/gitlab-org/gitlab-shell/v14/cmd/gitlab-shell-authorized-keys-check/command" 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/console" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/executable" 14 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/logger" 15 | ) 16 | 17 | var ( 18 | // Version is the current version of gitlab-shell 19 | Version = "(unknown version)" // Set at build time in the Makefile 20 | // BuildTime signifies the time the binary was build 21 | BuildTime = "19700101.000000" // Set at build time in the Makefile 22 | ) 23 | 24 | const ( 25 | exitCodeSuccess = 0 26 | exitCodeFailure = 1 27 | ) 28 | 29 | func main() { 30 | command.CheckForVersionFlag(os.Args, Version, BuildTime) 31 | 32 | readWriter := &readwriter.ReadWriter{ 33 | Out: &readwriter.CountingWriter{W: os.Stdout}, 34 | In: os.Stdin, 35 | ErrOut: os.Stderr, 36 | } 37 | 38 | code, err := execute(readWriter) 39 | if err != nil { 40 | fmt.Fprintf(readWriter.ErrOut, "%v\n", err) 41 | } 42 | 43 | os.Exit(int(code)) 44 | } 45 | 46 | func execute(readWriter *readwriter.ReadWriter) (int, error) { 47 | executable, err := executable.New(executable.AuthorizedKeysCheck) 48 | if err != nil { 49 | return exitCodeFailure, fmt.Errorf("failed to determine executable, exiting") 50 | } 51 | 52 | config, err := config.NewFromDirExternal(executable.RootDir) 53 | if err != nil { 54 | return exitCodeFailure, fmt.Errorf("failed to read config, exiting") 55 | } 56 | 57 | logCloser := logger.Configure(config) 58 | defer func() { _ = logCloser.Close() }() 59 | 60 | cmd, err := cmd.New(os.Args[1:], config, readWriter) 61 | if err != nil { 62 | // For now this could happen if `SSH_CONNECTION` is not set on 63 | // the environment 64 | return exitCodeFailure, err 65 | } 66 | 67 | ctx, finished := command.Setup(executable.Name, config) 68 | defer finished() 69 | 70 | if _, err = cmd.Execute(ctx); err != nil { 71 | console.DisplayWarningMessage(err.Error(), readWriter.ErrOut) 72 | return exitCodeFailure, nil 73 | } 74 | 75 | return exitCodeSuccess, nil 76 | } 77 | -------------------------------------------------------------------------------- /internal/command/receivepack/receivepack_test.go: -------------------------------------------------------------------------------- 1 | package receivepack 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/client/testserver" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command" 14 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 15 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 16 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 17 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/testhelper/requesthandlers" 18 | ) 19 | 20 | func TestAllowedAccess(t *testing.T) { 21 | gitalyAddress, _ := testserver.StartGitalyServer(t, "unix") 22 | requests := requesthandlers.BuildAllowedWithGitalyHandlers(t, gitalyAddress) 23 | cmd, _ := setup(t, "1", requests) 24 | cmd.Config.GitalyClient.InitSidechannelRegistry(context.Background()) 25 | 26 | ctxWithLogData, err := cmd.Execute(context.Background()) 27 | 28 | require.NoError(t, err) 29 | data := ctxWithLogData.Value(logData{}).(command.LogData) 30 | require.Equal(t, "alex-doe", data.Username) 31 | require.Equal(t, "group/project-path", data.Meta.Project) 32 | require.Equal(t, "group", data.Meta.RootNamespace) 33 | } 34 | 35 | func TestForbiddenAccess(t *testing.T) { 36 | requests := requesthandlers.BuildDisallowedByAPIHandlers(t) 37 | cmd, _ := setup(t, "disallowed", requests) 38 | 39 | _, err := cmd.Execute(context.Background()) 40 | require.Equal(t, "Disallowed by API call", err.Error()) 41 | } 42 | 43 | func TestCustomReceivePack(t *testing.T) { 44 | cmd, output := setup(t, "1", requesthandlers.BuildAllowedWithCustomActionsHandlers(t)) 45 | 46 | _, err := cmd.Execute(context.Background()) 47 | require.NoError(t, err) 48 | 49 | // Output of the Git HTTP protocol 50 | require.Contains(t, output.String(), "ok refs/heads/master") 51 | } 52 | 53 | func setup(t *testing.T, keyID string, requests []testserver.TestRequestHandler) (*Command, *bytes.Buffer) { 54 | url := testserver.StartSocketHTTPServer(t, requests) 55 | 56 | output := &bytes.Buffer{} 57 | input := io.NopCloser(strings.NewReader("input")) 58 | 59 | cmd := &Command{ 60 | Config: &config.Config{GitlabUrl: url}, 61 | Args: &commandargs.Shell{GitlabKeyID: keyID, SSHArgs: []string{"git-receive-pack", "group/repo"}}, 62 | ReadWriter: &readwriter.ReadWriter{ErrOut: output, Out: output, In: input}, 63 | } 64 | 65 | return cmd, output 66 | } 67 | -------------------------------------------------------------------------------- /internal/logger/logger_test.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "os" 5 | "regexp" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | "gitlab.com/gitlab-org/labkit/log" 10 | 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 12 | ) 13 | 14 | func TestConfigure(t *testing.T) { 15 | tmpFile := createTempFile(t) 16 | 17 | config := config.Config{ 18 | LogFile: tmpFile, 19 | LogFormat: "json", 20 | } 21 | 22 | closer := Configure(&config) 23 | defer closer.Close() 24 | 25 | log.Info("this is a test") 26 | log.WithFields(log.Fields{}).Debug("debug log message") 27 | 28 | data, err := os.ReadFile(tmpFile) 29 | dataStr := string(data) 30 | require.NoError(t, err) 31 | require.Contains(t, dataStr, `"msg":"this is a test"`) 32 | require.NotContains(t, dataStr, `"msg":"debug log message"`) 33 | require.NotContains(t, dataStr, `"msg":"unknown log level`) 34 | } 35 | 36 | func TestConfigureWithDebugLogLevel(t *testing.T) { 37 | tmpFile := createTempFile(t) 38 | 39 | config := config.Config{ 40 | LogFile: tmpFile, 41 | LogFormat: "json", 42 | LogLevel: "debug", 43 | } 44 | 45 | closer := Configure(&config) 46 | defer closer.Close() 47 | 48 | log.WithFields(log.Fields{}).Debug("debug log message") 49 | 50 | data, err := os.ReadFile(tmpFile) 51 | require.NoError(t, err) 52 | require.Contains(t, string(data), `msg":"debug log message"`) 53 | } 54 | 55 | func TestConfigureWithPermissionError(t *testing.T) { 56 | tempDir := t.TempDir() 57 | 58 | config := config.Config{ 59 | LogFile: tempDir, 60 | LogFormat: "json", 61 | } 62 | 63 | closer := Configure(&config) 64 | defer closer.Close() 65 | 66 | log.Info("this is a test") 67 | } 68 | 69 | func TestLogInUTC(t *testing.T) { 70 | tmpFile := createTempFile(t) 71 | 72 | config := config.Config{ 73 | LogFile: tmpFile, 74 | LogFormat: "json", 75 | } 76 | 77 | closer := Configure(&config) 78 | defer closer.Close() 79 | 80 | log.Info("this is a test") 81 | 82 | data, err := os.ReadFile(tmpFile) 83 | require.NoError(t, err) 84 | 85 | utc := `[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z` 86 | r, e := regexp.MatchString(utc, string(data)) 87 | 88 | require.NoError(t, e) 89 | require.True(t, r) 90 | } 91 | 92 | func createTempFile(t *testing.T) string { 93 | t.Helper() 94 | 95 | tmpFile, err := os.CreateTemp(t.TempDir(), "logtest-") 96 | require.NoError(t, err) 97 | tmpFile.Close() 98 | 99 | return tmpFile.Name() 100 | } 101 | -------------------------------------------------------------------------------- /internal/command/shared/accessverifier/accessverifier_test.go: -------------------------------------------------------------------------------- 1 | package accessverifier 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "io" 8 | "net/http" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | 14 | "gitlab.com/gitlab-org/gitlab-shell/v14/client/testserver" 15 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 16 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 17 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 18 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/accessverifier" 19 | ) 20 | 21 | var ( 22 | repo = "group/repo" 23 | action = commandargs.ReceivePack 24 | ) 25 | 26 | func setup(t *testing.T) (*Command, *bytes.Buffer, *bytes.Buffer) { 27 | requests := []testserver.TestRequestHandler{ 28 | { 29 | Path: "/api/v4/internal/allowed", 30 | Handler: func(w http.ResponseWriter, r *http.Request) { 31 | b, err := io.ReadAll(r.Body) 32 | assert.NoError(t, err) 33 | 34 | var requestBody *accessverifier.Request 35 | err = json.Unmarshal(b, &requestBody) 36 | assert.NoError(t, err) 37 | 38 | if requestBody.KeyID == "1" { 39 | body := map[string]interface{}{ 40 | "gl_console_messages": []string{"console", "message"}, 41 | } 42 | assert.NoError(t, json.NewEncoder(w).Encode(body)) 43 | } else { 44 | body := map[string]interface{}{ 45 | "status": false, 46 | "message": "missing user", 47 | } 48 | assert.NoError(t, json.NewEncoder(w).Encode(body)) 49 | } 50 | }, 51 | }, 52 | } 53 | 54 | url := testserver.StartSocketHTTPServer(t, requests) 55 | 56 | errBuf := &bytes.Buffer{} 57 | outBuf := &bytes.Buffer{} 58 | 59 | readWriter := &readwriter.ReadWriter{Out: outBuf, ErrOut: errBuf} 60 | cmd := &Command{Config: &config.Config{GitlabUrl: url}, ReadWriter: readWriter} 61 | 62 | return cmd, errBuf, outBuf 63 | } 64 | 65 | func TestMissingUser(t *testing.T) { 66 | cmd, _, _ := setup(t) 67 | 68 | cmd.Args = &commandargs.Shell{GitlabKeyID: "2"} 69 | _, err := cmd.Verify(context.Background(), action, repo) 70 | 71 | require.Equal(t, "missing user", err.Error()) 72 | } 73 | 74 | func TestConsoleMessages(t *testing.T) { 75 | cmd, errBuf, outBuf := setup(t) 76 | 77 | cmd.Args = &commandargs.Shell{GitlabKeyID: "1"} 78 | cmd.Verify(context.Background(), action, repo) 79 | 80 | require.Equal(t, "remote: \nremote: console\nremote: message\nremote: \n", errBuf.String()) 81 | require.Empty(t, outBuf.String()) 82 | } 83 | -------------------------------------------------------------------------------- /internal/command/receivepack/receivepack.go: -------------------------------------------------------------------------------- 1 | // Package receivepack provides functionality for handling Git receive-pack commands 2 | package receivepack 3 | 4 | import ( 5 | "context" 6 | 7 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command" 8 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/gitauditevent" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/githttp" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/shared/accessverifier" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/shared/disallowedcommand" 14 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 15 | ) 16 | 17 | // Command represents the receive-pack command 18 | type Command struct { 19 | Config *config.Config 20 | Args *commandargs.Shell 21 | ReadWriter *readwriter.ReadWriter 22 | } 23 | 24 | type logData struct{} 25 | 26 | // Execute executes the receive-pack command 27 | func (c *Command) Execute(ctx context.Context) (context.Context, error) { 28 | args := c.Args.SSHArgs 29 | if len(args) != 2 { 30 | return ctx, disallowedcommand.Error 31 | } 32 | 33 | repo := args[1] 34 | response, err := c.verifyAccess(ctx, repo) 35 | if err != nil { 36 | return ctx, err 37 | } 38 | 39 | ctxWithLogData := context.WithValue(ctx, logData{}, command.NewLogData( 40 | response.Gitaly.Repo.GlProjectPath, 41 | response.Username, 42 | response.ProjectID, 43 | response.RootNamespaceID, 44 | )) 45 | 46 | if response.IsCustomAction() { 47 | // A Git over HTTP direct request to primary repo is performed 48 | // (instead of formerly proxying the request through Gitlab Rails). 49 | cmd := githttp.PushCommand{ 50 | Config: c.Config, 51 | ReadWriter: c.ReadWriter, 52 | Response: response, 53 | Args: c.Args, 54 | } 55 | 56 | return ctxWithLogData, cmd.Execute(ctx) 57 | } 58 | 59 | err = c.performGitalyCall(ctx, response) 60 | if err != nil { 61 | return ctxWithLogData, err 62 | } 63 | 64 | if response.NeedAudit { 65 | gitauditevent.Audit(ctx, c.Args, c.Config, response, nil /* keep nil for `git-receive-pack`*/) 66 | } 67 | return ctxWithLogData, nil 68 | } 69 | 70 | func (c *Command) verifyAccess(ctx context.Context, repo string) (*accessverifier.Response, error) { 71 | cmd := accessverifier.Command{ 72 | Config: c.Config, 73 | Args: c.Args, 74 | ReadWriter: c.ReadWriter, 75 | } 76 | 77 | return cmd.Verify(ctx, c.Args.CommandType, repo) 78 | } 79 | -------------------------------------------------------------------------------- /internal/gitlabnet/discover/client.go: -------------------------------------------------------------------------------- 1 | // Package discover provides functionality for discovering GitLab users 2 | package discover 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/client" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet" 14 | ) 15 | 16 | // Client represents a client for discovering GitLab users 17 | type Client struct { 18 | config *config.Config 19 | client *client.GitlabNetClient 20 | } 21 | 22 | // Response represents the response structure for user discovery 23 | type Response struct { 24 | UserID int64 `json:"id"` 25 | Name string `json:"name"` 26 | Username string `json:"username"` 27 | } 28 | 29 | // NewClient creates a new instance of the user discovery client 30 | func NewClient(config *config.Config) (*Client, error) { 31 | client, err := gitlabnet.GetClient(config) 32 | if err != nil { 33 | return nil, fmt.Errorf("error creating http client: %v", err) 34 | } 35 | 36 | return &Client{config: config, client: client}, nil 37 | } 38 | 39 | // GetByCommandArgs retrieves user information based on command arguments 40 | func (c *Client) GetByCommandArgs(ctx context.Context, args *commandargs.Shell) (*Response, error) { 41 | params := url.Values{} 42 | switch { 43 | case args.GitlabUsername != "": 44 | params.Add("username", args.GitlabUsername) 45 | case args.GitlabKeyID != "": 46 | params.Add("key_id", args.GitlabKeyID) 47 | case args.GitlabKrb5Principal != "": 48 | params.Add("krb5principal", args.GitlabKrb5Principal) 49 | default: 50 | // There was no 'who' information, this matches the ruby error 51 | // message. 52 | return nil, fmt.Errorf("who='' is invalid") 53 | } 54 | 55 | return c.getResponse(ctx, params) 56 | } 57 | 58 | func (c *Client) getResponse(ctx context.Context, params url.Values) (*Response, error) { 59 | path := "/discover?" + params.Encode() 60 | 61 | response, err := c.client.Get(ctx, path) 62 | if err != nil { 63 | return nil, err 64 | } 65 | defer func() { _ = response.Body.Close() }() 66 | 67 | return parse(response) 68 | } 69 | 70 | func parse(hr *http.Response) (*Response, error) { 71 | response := &Response{} 72 | if err := gitlabnet.ParseJSON(hr, response); err != nil { 73 | return nil, err 74 | } 75 | 76 | return response, nil 77 | } 78 | 79 | // IsAnonymous checks if the user is anonymous 80 | func (r *Response) IsAnonymous() bool { 81 | return r.UserID < 1 82 | } 83 | -------------------------------------------------------------------------------- /internal/executable/executable_test.go: -------------------------------------------------------------------------------- 1 | package executable 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/testhelper" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | type fakeOs struct { 13 | OldExecutable func() (string, error) 14 | Path string 15 | Error error 16 | } 17 | 18 | func (f *fakeOs) Executable() (string, error) { 19 | return f.Path, f.Error 20 | } 21 | 22 | func (f *fakeOs) Setup() { 23 | f.OldExecutable = osExecutable 24 | osExecutable = f.Executable 25 | } 26 | 27 | func (f *fakeOs) Cleanup() { 28 | osExecutable = f.OldExecutable 29 | } 30 | 31 | func TestNewSuccess(t *testing.T) { 32 | testCases := []struct { 33 | desc string 34 | fakeOs *fakeOs 35 | environment map[string]string 36 | expectedRootDir string 37 | }{ 38 | { 39 | desc: "GITLAB_SHELL_DIR env var is not defined", 40 | fakeOs: &fakeOs{Path: "/tmp/bin/gitlab-shell"}, 41 | expectedRootDir: "/tmp", 42 | }, 43 | { 44 | desc: "GITLAB_SHELL_DIR env var is defined", 45 | fakeOs: &fakeOs{Path: "/opt/bin/gitlab-shell"}, 46 | environment: map[string]string{ 47 | "GITLAB_SHELL_DIR": "/tmp", 48 | }, 49 | expectedRootDir: "/tmp", 50 | }, 51 | } 52 | 53 | for _, tc := range testCases { 54 | t.Run(tc.desc, func(t *testing.T) { 55 | testhelper.TempEnv(t, tc.environment) 56 | 57 | fake := tc.fakeOs 58 | fake.Setup() 59 | defer fake.Cleanup() 60 | 61 | result, err := New("gitlab-shell") 62 | 63 | require.NoError(t, err) 64 | require.Equal(t, "gitlab-shell", result.Name) 65 | require.Equal(t, tc.expectedRootDir, result.RootDir) 66 | }) 67 | } 68 | } 69 | 70 | func TestNewFailure(t *testing.T) { 71 | testCases := []struct { 72 | desc string 73 | fakeOs *fakeOs 74 | environment map[string]string 75 | }{ 76 | { 77 | desc: "failed to determine executable", 78 | fakeOs: &fakeOs{Path: "", Error: errors.New("error")}, 79 | }, 80 | { 81 | desc: "GITLAB_SHELL_DIR doesn't exist", 82 | fakeOs: &fakeOs{Path: "/tmp/bin/gitlab-shell"}, 83 | environment: map[string]string{ 84 | "GITLAB_SHELL_DIR": "/tmp/non/existing/directory", 85 | }, 86 | }, 87 | } 88 | 89 | for _, tc := range testCases { 90 | t.Run(tc.desc, func(t *testing.T) { 91 | testhelper.TempEnv(t, tc.environment) 92 | 93 | fake := tc.fakeOs 94 | fake.Setup() 95 | defer fake.Cleanup() 96 | 97 | _, err := New("gitlab-shell") 98 | 99 | require.Error(t, err) 100 | }) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /internal/gitlabnet/gitauditevent/client_test.go: -------------------------------------------------------------------------------- 1 | package gitauditevent 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | pb "gitlab.com/gitlab-org/gitaly/v16/proto/go/gitalypb" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/client/testserver" 14 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 15 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 16 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/sshenv" 17 | ) 18 | 19 | var ( 20 | testUsername = "gitlab-shell" 21 | testRepo = "gitlab-org/gitlab-shell" 22 | testPackfileWants int64 = 100 23 | testPackfileHaves int64 = 100 24 | testArgs = &commandargs.Shell{ 25 | Env: sshenv.Env{RemoteAddr: "18.245.0.42"}, 26 | CommandType: "git-upload-pack", 27 | } 28 | ) 29 | 30 | func TestAudit(t *testing.T) { 31 | client := setup(t, http.StatusOK) 32 | 33 | err := client.Audit(context.Background(), testUsername, testArgs, testRepo, &pb.PackfileNegotiationStatistics{ 34 | Wants: testPackfileWants, 35 | Haves: testPackfileHaves, 36 | }) 37 | require.NoError(t, err) 38 | } 39 | 40 | func TestAuditFailed(t *testing.T) { 41 | client := setup(t, http.StatusBadRequest) 42 | 43 | err := client.Audit(context.Background(), testUsername, testArgs, testRepo, &pb.PackfileNegotiationStatistics{ 44 | Wants: testPackfileWants, 45 | Haves: testPackfileHaves, 46 | }) 47 | require.Error(t, err) 48 | } 49 | 50 | func setup(t *testing.T, responseStatus int) *Client { 51 | requests := []testserver.TestRequestHandler{ 52 | { 53 | Path: uri, 54 | Handler: func(w http.ResponseWriter, r *http.Request) { 55 | body, err := io.ReadAll(r.Body) 56 | assert.NoError(t, err) 57 | defer r.Body.Close() 58 | 59 | var request *Request 60 | assert.NoError(t, json.Unmarshal(body, &request)) 61 | assert.Equal(t, testUsername, request.Username) 62 | assert.Equal(t, testArgs.Env.RemoteAddr, request.CheckIP) 63 | assert.Equal(t, testArgs.CommandType, request.Action) 64 | assert.Equal(t, testRepo, request.Repo) 65 | assert.Equal(t, "ssh", request.Protocol) 66 | assert.Equal(t, testPackfileWants, request.PackfileStats.Wants) 67 | assert.Equal(t, testPackfileHaves, request.PackfileStats.Haves) 68 | assert.Equal(t, "_any", request.Changes) 69 | 70 | w.WriteHeader(responseStatus) 71 | }, 72 | }, 73 | } 74 | 75 | url := testserver.StartSocketHTTPServer(t, requests) 76 | 77 | client, err := NewClient(&config.Config{GitlabUrl: url}) 78 | require.NoError(t, err) 79 | 80 | return client 81 | } 82 | -------------------------------------------------------------------------------- /internal/pktline/pktline.go: -------------------------------------------------------------------------------- 1 | // Package pktline provides utility functions for working with the Git pkt-line format. 2 | package pktline 3 | 4 | // Utility functions for working with the Git pkt-line format. See 5 | // https://github.com/git/git/blob/master/Documentation/technical/protocol-common.txt 6 | 7 | import ( 8 | "bufio" 9 | "bytes" 10 | "fmt" 11 | "io" 12 | "regexp" 13 | "strconv" 14 | ) 15 | 16 | const ( 17 | maxPktSize = 0xffff 18 | pktDelim = "0001" 19 | ) 20 | 21 | var branchRemovalPktRegexp = regexp.MustCompile(`\A[a-f0-9]{4}[a-f0-9]{40} 0{40} `) 22 | 23 | // NewScanner returns a bufio.Scanner that splits on Git pktline boundaries 24 | func NewScanner(r io.Reader) *bufio.Scanner { 25 | scanner := bufio.NewScanner(r) 26 | scanner.Buffer(make([]byte, maxPktSize), maxPktSize) 27 | scanner.Split(pktLineSplitter) 28 | return scanner 29 | } 30 | 31 | // IsRefRemoval checks if the packet represents a reference removal. 32 | func IsRefRemoval(pkt []byte) bool { 33 | return branchRemovalPktRegexp.Match(pkt) 34 | } 35 | 36 | // IsFlush detects the special flush packet '0000' 37 | func IsFlush(pkt []byte) bool { 38 | return bytes.Equal(pkt, []byte("0000")) 39 | } 40 | 41 | // IsDone detects the special done packet '0009done\n' 42 | func IsDone(pkt []byte) bool { 43 | return bytes.Equal(pkt, PktDone()) 44 | } 45 | 46 | // PktDone returns the bytes for a "done" packet. 47 | func PktDone() []byte { 48 | return []byte("0009done\n") 49 | } 50 | 51 | func pktLineSplitter(data []byte, atEOF bool) (advance int, token []byte, err error) { 52 | if len(data) < 4 { 53 | if atEOF && len(data) > 0 { 54 | return 0, nil, fmt.Errorf("pktLineSplitter: incomplete length prefix on %q", data) 55 | } 56 | return 0, nil, nil // want more data 57 | } 58 | 59 | // We have at least 4 bytes available so we can decode the 4-hex digit 60 | // length prefix of the packet line. 61 | pktLength64, err := strconv.ParseInt(string(data[:4]), 16, 0) 62 | if err != nil { 63 | return 0, nil, fmt.Errorf("pktLineSplitter: decode length: %v", err) 64 | } 65 | 66 | // Cast is safe because we requested an int-size number from strconv.ParseInt 67 | pktLength := int(pktLength64) 68 | 69 | if pktLength < 0 { 70 | return 0, nil, fmt.Errorf("pktLineSplitter: invalid length: %d", pktLength) 71 | } 72 | 73 | if pktLength < 4 { 74 | // Special case: magic empty packet 0000, 0001, 0002 or 0003. 75 | return 4, data[:4], nil 76 | } 77 | 78 | if len(data) < pktLength { 79 | // data contains incomplete packet 80 | 81 | if atEOF { 82 | return 0, nil, fmt.Errorf("pktLineSplitter: less than %d bytes in input %q", pktLength, data) 83 | } 84 | 85 | return 0, nil, nil // want more data 86 | } 87 | 88 | return pktLength, data[:pktLength], nil 89 | } 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | --- 2 | stage: Create 3 | group: Source Code 4 | info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments 5 | --- 6 | 7 | [![pipeline status](https://gitlab.com/gitlab-org/gitlab-shell/badges/main/pipeline.svg)](https://gitlab.com/gitlab-org/gitlab-shell/-/pipelines?ref=main) 8 | [![coverage report](https://gitlab.com/gitlab-org/gitlab-shell/badges/main/coverage.svg)](https://gitlab.com/gitlab-org/gitlab-shell/-/pipelines?ref=main) 9 | [![Code Climate](https://codeclimate.com/github/gitlabhq/gitlab-shell.svg)](https://codeclimate.com/github/gitlabhq/gitlab-shell) 10 | 11 | # GitLab Shell 12 | 13 | GitLab Shell handles Git SSH sessions for GitLab and modifies the list of 14 | authorized keys. GitLab Shell is not a Unix shell nor a replacement for Bash or Zsh. 15 | 16 | GitLab supports Git LFS authentication through SSH. 17 | 18 | ## Development Documentation 19 | 20 | Development documentation for GitLab Shell [has moved into the `gitlab` repository](https://docs.gitlab.com/ee/development/gitlab_shell/). 21 | 22 | ## Project structure 23 | 24 | | Directory | Description | 25 | |-----------|-------------| 26 | | `cmd/` | 'Commands' that will ultimately be compiled into binaries. | 27 | | `internal/` | Internal Go source code that is not intended to be used outside of the project/module. | 28 | | `client/` | HTTP and GitLab client logic that is used internally and by other modules, e.g. Gitaly. | 29 | | `bin/` | Compiled binaries are created here. | 30 | | `support/` | Scripts and tools that assist in development and/or testing. | 31 | | `spec/` | Ruby based integration tests. | 32 | 33 | ## Building 34 | 35 | Run `make` or `make build`. 36 | 37 | ## Testing 38 | 39 | Run `make test`. 40 | 41 | ## Release Process 42 | 43 | 1. Create a `gitlab-org/gitlab-shell` MR to update [`VERSION`](https://gitlab.com/gitlab-org/gitlab-shell/-/blob/main/VERSION) and [`CHANGELOG`](https://gitlab.com/gitlab-org/gitlab-shell/-/blob/main/CHANGELOG) files, e.g. [Release v14.39.0](https://gitlab.com/gitlab-org/gitlab-shell/-/merge_requests/1123). 44 | 2. Once `gitlab-org/gitlab-shell` MR is merged, create the corresponding git tag, e.g. https://gitlab.com/gitlab-org/gitlab-shell/-/tags/v14.39.0. 45 | 3. Create a `gitlab-org/gitlab` MR to update [`GITLAB_SHELL_VERSION`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/GITLAB_SHELL_VERSION) to the proposed tag, e.g. [Bump GitLab Shell to 14.39.0](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/162661). 46 | 4. Announce in `#gitlab-shell` a new version has been created. 47 | 48 | ## Licensing 49 | 50 | See the `LICENSE` file for licensing information as it pertains to files in 51 | this repository. 52 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/certs/valid/ca: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | 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 38 | -----END OPENSSH PRIVATE KEY----- 39 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | Thank you for your interest in contributing to this GitLab project! We welcome 4 | all contributions. By participating in this project, you agree to abide by the 5 | [code of conduct](#code-of-conduct). 6 | 7 | ## Developer Certificate of Origin + License 8 | 9 | By contributing to GitLab Inc., You accept and agree to the following terms and 10 | conditions for Your present and future Contributions submitted to GitLab Inc. 11 | Except for the license granted herein to GitLab Inc. and recipients of software 12 | distributed by GitLab Inc., You reserve all right, title, and interest in and to 13 | Your Contributions. All Contributions are subject to the following DCO + License 14 | terms. 15 | 16 | [DCO + License](https://gitlab.com/gitlab-org/dco/blob/master/README.md) 17 | 18 | _This notice should stay as the first item in the CONTRIBUTING.md file._ 19 | 20 | ## Code of conduct 21 | 22 | As contributors and maintainers of this project, we pledge to respect all people 23 | who contribute through reporting issues, posting feature requests, updating 24 | documentation, submitting pull requests or patches, and other activities. 25 | 26 | We are committed to making participation in this project a harassment-free 27 | experience for everyone, regardless of level of experience, gender, gender 28 | identity and expression, sexual orientation, disability, personal appearance, 29 | body size, race, ethnicity, age, or religion. 30 | 31 | Examples of unacceptable behavior by participants include the use of sexual 32 | language or imagery, derogatory comments or personal attacks, trolling, public 33 | or private harassment, insults, or other unprofessional conduct. 34 | 35 | Project maintainers have the right and responsibility to remove, edit, or reject 36 | comments, commits, code, wiki edits, issues, and other contributions that are 37 | not aligned to this Code of Conduct. Project maintainers who do not follow the 38 | Code of Conduct may be removed from the project team. 39 | 40 | This code of conduct applies both within project spaces and in public spaces 41 | when an individual is representing the project or its community. 42 | 43 | Instances of abusive, harassing, or otherwise unacceptable behavior can be 44 | reported by emailing contact@gitlab.com. 45 | 46 | This Code of Conduct is adapted from the [Contributor Covenant][contributor-covenant], version 1.1.0, 47 | available at [http://contributor-covenant.org/version/1/1/0/](http://contributor-covenant.org/version/1/1/0/). 48 | 49 | [contributor-covenant]: http://contributor-covenant.org 50 | [individual-agreement]: https://docs.gitlab.com/ee/legal/individual_contributor_license_agreement.html 51 | [corporate-agreement]: https://docs.gitlab.com/ee/legal/corporate_contributor_license_agreement.html 52 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/certs/valid/server2.key: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | 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 38 | -----END OPENSSH PRIVATE KEY----- 39 | -------------------------------------------------------------------------------- /internal/command/twofactorverify/twofactorverify.go: -------------------------------------------------------------------------------- 1 | // Package twofactorverify provides functionality for two-factor verification 2 | package twofactorverify 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "io" 8 | "time" 9 | 10 | "gitlab.com/gitlab-org/labkit/log" 11 | 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 14 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 15 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/twofactorverify" 16 | ) 17 | 18 | const ( 19 | timeout = 30 * time.Second 20 | prompt = "OTP: " 21 | ) 22 | 23 | // Command represents the command for two-factor verification 24 | type Command struct { 25 | Config *config.Config 26 | Args *commandargs.Shell 27 | ReadWriter *readwriter.ReadWriter 28 | } 29 | 30 | // Execute executes the two-factor verification command 31 | func (c *Command) Execute(ctx context.Context) (context.Context, error) { 32 | client, err := twofactorverify.NewClient(c.Config) 33 | if err != nil { 34 | return ctx, err 35 | } 36 | 37 | ctx, cancel := context.WithTimeout(ctx, timeout) 38 | defer cancel() 39 | 40 | _, _ = fmt.Fprint(c.ReadWriter.Out, prompt) 41 | 42 | resultCh := make(chan string) 43 | go func() { 44 | err := client.PushAuth(ctx, c.Args) 45 | if err == nil { 46 | resultCh <- "OTP has been validated by Push Authentication. Git operations are now allowed." 47 | } 48 | }() 49 | 50 | go func() { 51 | answer, err := c.getOTP(ctx) 52 | if err != nil { 53 | resultCh <- formatErr(err) 54 | } 55 | 56 | if err := client.VerifyOTP(ctx, c.Args, answer); err != nil { 57 | resultCh <- formatErr(err) 58 | } else { 59 | resultCh <- "OTP validation successful. Git operations are now allowed." 60 | } 61 | }() 62 | 63 | var message string 64 | select { 65 | case message = <-resultCh: 66 | case <-ctx.Done(): 67 | message = formatErr(ctx.Err()) 68 | } 69 | 70 | log.WithContextFields(ctx, log.Fields{"message": message}).Info("Two factor verify command finished") 71 | _, _ = fmt.Fprintf(c.ReadWriter.Out, "\n%v\n", message) 72 | 73 | return ctx, nil 74 | } 75 | 76 | func (c *Command) getOTP(ctx context.Context) (string, error) { 77 | var answer string 78 | otpLength := int64(64) 79 | reader := io.LimitReader(c.ReadWriter.In, otpLength) 80 | if _, err := fmt.Fscanln(reader, &answer); err != nil { 81 | log.ContextLogger(ctx).WithError(err).Debug("twofactorverify: getOTP: Failed to get user input") 82 | } 83 | 84 | if answer == "" { 85 | return "", fmt.Errorf("OTP cannot be blank") //revive:disable:error-strings 86 | } 87 | 88 | return answer, nil 89 | } 90 | 91 | func formatErr(err error) string { 92 | return fmt.Sprintf("OTP validation failed: %v", err) 93 | } 94 | -------------------------------------------------------------------------------- /internal/command/healthcheck/healthcheck_test.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "net/http" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/client/testserver" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 14 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 15 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/healthcheck" 16 | ) 17 | 18 | var ( 19 | okResponse = &healthcheck.Response{ 20 | APIVersion: "v4", 21 | GitlabVersion: "v12.0.0-ee", 22 | GitlabRevision: "3b13818e8330f68625d80d9bf5d8049c41fbe197", 23 | Redis: true, 24 | } 25 | 26 | badRedisResponse = &healthcheck.Response{Redis: false} 27 | 28 | okHandlers = buildTestHandlers(200, okResponse) 29 | badRedisHandlers = buildTestHandlers(200, badRedisResponse) 30 | brokenHandlers = buildTestHandlers(500, nil) 31 | ) 32 | 33 | func buildTestHandlers(code int, rsp *healthcheck.Response) []testserver.TestRequestHandler { 34 | return []testserver.TestRequestHandler{ 35 | { 36 | Path: "/api/v4/internal/check", 37 | Handler: func(w http.ResponseWriter, _ *http.Request) { 38 | w.WriteHeader(code) 39 | if rsp != nil { 40 | json.NewEncoder(w).Encode(rsp) 41 | } 42 | }, 43 | }, 44 | } 45 | } 46 | 47 | func TestExecute(t *testing.T) { 48 | url := testserver.StartSocketHTTPServer(t, okHandlers) 49 | 50 | buffer := &bytes.Buffer{} 51 | cmd := &Command{ 52 | Config: &config.Config{GitlabUrl: url}, 53 | ReadWriter: &readwriter.ReadWriter{Out: buffer}, 54 | } 55 | 56 | _, err := cmd.Execute(context.Background()) 57 | 58 | require.NoError(t, err) 59 | require.Equal(t, "Internal API available: OK\nRedis available via internal API: OK\n", buffer.String()) 60 | } 61 | 62 | func TestFailingRedisExecute(t *testing.T) { 63 | url := testserver.StartSocketHTTPServer(t, badRedisHandlers) 64 | 65 | buffer := &bytes.Buffer{} 66 | cmd := &Command{ 67 | Config: &config.Config{GitlabUrl: url}, 68 | ReadWriter: &readwriter.ReadWriter{Out: buffer}, 69 | } 70 | 71 | _, err := cmd.Execute(context.Background()) 72 | require.Error(t, err, "Redis available via internal API: FAILED") 73 | require.Equal(t, "Internal API available: OK\n", buffer.String()) 74 | } 75 | 76 | func TestFailingAPIExecute(t *testing.T) { 77 | url := testserver.StartSocketHTTPServer(t, brokenHandlers) 78 | 79 | buffer := &bytes.Buffer{} 80 | cmd := &Command{ 81 | Config: &config.Config{GitlabUrl: url}, 82 | ReadWriter: &readwriter.ReadWriter{Out: buffer}, 83 | } 84 | 85 | _, err := cmd.Execute(context.Background()) 86 | require.Empty(t, buffer.String()) 87 | require.EqualError(t, err, "Internal API available: FAILED - Internal API unreachable") 88 | } 89 | -------------------------------------------------------------------------------- /cmd/gitlab-shell/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "reflect" 7 | 8 | grpccodes "google.golang.org/grpc/codes" 9 | grpcstatus "google.golang.org/grpc/status" 10 | 11 | "gitlab.com/gitlab-org/labkit/fips" 12 | "gitlab.com/gitlab-org/labkit/log" 13 | 14 | shellCmd "gitlab.com/gitlab-org/gitlab-shell/v14/cmd/gitlab-shell/command" 15 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command" 16 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 17 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 18 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/console" 19 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/executable" 20 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/logger" 21 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/sshenv" 22 | ) 23 | 24 | var ( 25 | // Version is the current version of gitlab-shell 26 | Version = "(unknown version)" // Set at build time in the Makefile 27 | // BuildTime signifies the time the binary was build 28 | BuildTime = "19700101.000000" // Set at build time in the Makefile 29 | ) 30 | 31 | func main() { 32 | command.CheckForVersionFlag(os.Args, Version, BuildTime) 33 | 34 | readWriter := &readwriter.ReadWriter{ 35 | Out: &readwriter.CountingWriter{W: os.Stdout}, 36 | In: os.Stdin, 37 | ErrOut: os.Stderr, 38 | } 39 | 40 | executable, err := executable.New(executable.GitlabShell) 41 | if err != nil { 42 | fmt.Fprintln(readWriter.ErrOut, "Failed to determine executable, exiting") 43 | os.Exit(1) 44 | } 45 | 46 | config, err := config.NewFromDirExternal(executable.RootDir) 47 | if err != nil { 48 | fmt.Fprintln(readWriter.ErrOut, "Failed to read config, exiting:", err) 49 | os.Exit(1) 50 | } 51 | 52 | logCloser := logger.Configure(config) 53 | defer logCloser.Close() 54 | 55 | env := sshenv.NewFromEnv() 56 | cmd, err := shellCmd.New(os.Args[1:], env, config, readWriter) 57 | if err != nil { 58 | // For now this could happen if `SSH_CONNECTION` is not set on 59 | // the environment 60 | fmt.Fprintf(readWriter.ErrOut, "%v\n", err) 61 | os.Exit(1) 62 | } 63 | 64 | ctx, finished := command.Setup(executable.Name, config) 65 | defer finished() 66 | 67 | config.GitalyClient.InitSidechannelRegistry(ctx) 68 | 69 | cmdName := reflect.TypeOf(cmd).String() 70 | ctxlog := log.ContextLogger(ctx) 71 | ctxlog.WithFields(log.Fields{"env": env, "command": cmdName}).Info("gitlab-shell: main: executing command") 72 | fips.Check() 73 | 74 | if _, err := cmd.Execute(ctx); err != nil { 75 | ctxlog.WithError(err).Warn("gitlab-shell: main: command execution failed") 76 | if grpcstatus.Convert(err).Code() != grpccodes.Internal { 77 | console.DisplayWarningMessage(err.Error(), readWriter.ErrOut) 78 | } 79 | os.Exit(1) 80 | } 81 | 82 | ctxlog.Info("gitlab-shell: main: command executed successfully") 83 | } 84 | -------------------------------------------------------------------------------- /internal/gitlabnet/authorizedkeys/client_test.go: -------------------------------------------------------------------------------- 1 | package authorizedkeys 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/client" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/client/testserver" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 13 | ) 14 | 15 | var ( 16 | requests []testserver.TestRequestHandler 17 | ) 18 | 19 | func init() { 20 | requests = []testserver.TestRequestHandler{ 21 | { 22 | Path: "/api/v4/internal/authorized_keys", 23 | Handler: func(w http.ResponseWriter, r *http.Request) { 24 | switch r.URL.Query().Get("key") { 25 | case "key": 26 | body := &Response{ 27 | ID: 1, 28 | Key: "public-key", 29 | } 30 | json.NewEncoder(w).Encode(body) 31 | case "broken-message": 32 | w.WriteHeader(http.StatusForbidden) 33 | body := &client.ErrorResponse{ 34 | Message: "Not allowed!", 35 | } 36 | json.NewEncoder(w).Encode(body) 37 | case "broken-json": 38 | w.Write([]byte("{ \"message\": \"broken json!\"")) 39 | case "broken-empty": 40 | w.WriteHeader(http.StatusForbidden) 41 | default: 42 | w.WriteHeader(http.StatusNotFound) 43 | } 44 | }, 45 | }, 46 | } 47 | } 48 | 49 | func TestGetByKey(t *testing.T) { 50 | client := setup(t) 51 | 52 | result, err := client.GetByKey(context.Background(), "key") 53 | require.NoError(t, err) 54 | require.Equal(t, &Response{ID: 1, Key: "public-key"}, result) 55 | } 56 | 57 | func TestGetByKeyErrorResponses(t *testing.T) { 58 | client := setup(t) 59 | 60 | testCases := []struct { 61 | desc string 62 | key string 63 | expectedError string 64 | }{ 65 | { 66 | desc: "A response with an error message", 67 | key: "broken-message", 68 | expectedError: "Not allowed!", 69 | }, 70 | { 71 | desc: "A response with bad JSON", 72 | key: "broken-json", 73 | expectedError: "parsing failed", 74 | }, 75 | { 76 | desc: "A forbidden (403) response without message", 77 | key: "broken-empty", 78 | expectedError: "Internal API error (403)", 79 | }, 80 | { 81 | desc: "A not found (404) response without message", 82 | key: "not-found", 83 | expectedError: "Internal API error (404)", 84 | }, 85 | } 86 | 87 | for _, tc := range testCases { 88 | t.Run(tc.desc, func(t *testing.T) { 89 | resp, err := client.GetByKey(context.Background(), tc.key) 90 | 91 | require.EqualError(t, err, tc.expectedError) 92 | require.Nil(t, resp) 93 | }) 94 | } 95 | } 96 | 97 | func setup(t *testing.T) *Client { 98 | url := testserver.StartSocketHTTPServer(t, requests) 99 | 100 | client, err := NewClient(&config.Config{GitlabUrl: url}) 101 | require.NoError(t, err) 102 | 103 | return client 104 | } 105 | -------------------------------------------------------------------------------- /internal/command/uploadpack/gitalycall_test.go: -------------------------------------------------------------------------------- 1 | package uploadpack 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | "gitlab.com/gitlab-org/labkit/correlation" 11 | 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/client/testserver" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 14 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 15 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 16 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/sshenv" 17 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/testhelper/requesthandlers" 18 | ) 19 | 20 | func TestUploadPack(t *testing.T) { 21 | for _, network := range []string{"unix", "tcp", "dns"} { 22 | t.Run(fmt.Sprintf("via %s network", network), func(t *testing.T) { 23 | gitalyAddress, testServer := testserver.StartGitalyServer(t, network) 24 | t.Logf("Server address: %s", gitalyAddress) 25 | 26 | requests := requesthandlers.BuildAllowedWithGitalyHandlers(t, gitalyAddress) 27 | url := testserver.StartHTTPServer(t, requests) 28 | 29 | output := &bytes.Buffer{} 30 | input := &bytes.Buffer{} 31 | 32 | userID := "1" 33 | repo := "group/repo" 34 | 35 | env := sshenv.Env{ 36 | IsSSHConnection: true, 37 | OriginalCommand: "git-upload-pack " + repo, 38 | RemoteAddr: "127.0.0.1", 39 | } 40 | 41 | args := &commandargs.Shell{ 42 | GitlabKeyID: userID, 43 | CommandType: commandargs.UploadPack, 44 | SSHArgs: []string{"git-upload-pack", repo}, 45 | Env: env, 46 | } 47 | 48 | ctx := correlation.ContextWithCorrelation(context.Background(), "a-correlation-id") 49 | ctx = correlation.ContextWithClientName(ctx, "gitlab-shell-tests") 50 | 51 | cfg := &config.Config{GitlabUrl: url} 52 | cfg.GitalyClient.InitSidechannelRegistry(ctx) 53 | 54 | cmd := &Command{ 55 | Config: cfg, 56 | Args: args, 57 | ReadWriter: &readwriter.ReadWriter{ErrOut: output, Out: output, In: input}, 58 | } 59 | 60 | _, err := cmd.Execute(ctx) 61 | require.NoError(t, err) 62 | 63 | require.Equal(t, "SSHUploadPackWithSidechannel: "+repo, output.String()) 64 | 65 | for k, v := range map[string]string{ 66 | "gitaly-feature-cache_invalidator": "true", 67 | "gitaly-feature-inforef_uploadpack_cache": "false", 68 | "x-gitlab-client-name": "gitlab-shell-tests-git-upload-pack", 69 | "key_id": "123", 70 | "user_id": "1", 71 | "remote_ip": "127.0.0.1", 72 | "key_type": "key", 73 | } { 74 | actual := testServer.ReceivedMD[k] 75 | require.Len(t, actual, 1) 76 | require.Equal(t, v, actual[0]) 77 | } 78 | require.Empty(t, testServer.ReceivedMD["some-other-ff"]) 79 | require.Equal(t, "a-correlation-id", testServer.ReceivedMD["x-gitlab-correlation-id"][0]) 80 | }) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /internal/command/uploadarchive/gitalycall_test.go: -------------------------------------------------------------------------------- 1 | package uploadarchive 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | "gitlab.com/gitlab-org/labkit/correlation" 11 | 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/client/testserver" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 14 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 15 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 16 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/sshenv" 17 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/testhelper/requesthandlers" 18 | ) 19 | 20 | func TestUploadArchive(t *testing.T) { 21 | for _, network := range []string{"unix", "tcp", "dns"} { 22 | t.Run(fmt.Sprintf("via %s network", network), func(t *testing.T) { 23 | gitalyAddress, testServer := testserver.StartGitalyServer(t, network) 24 | t.Logf("Server address: %s", gitalyAddress) 25 | 26 | requests := requesthandlers.BuildAllowedWithGitalyHandlers(t, gitalyAddress) 27 | url := testserver.StartHTTPServer(t, requests) 28 | 29 | output := &bytes.Buffer{} 30 | input := &bytes.Buffer{} 31 | 32 | userID := "1" 33 | repo := "group/repo" 34 | 35 | env := sshenv.Env{ 36 | IsSSHConnection: true, 37 | OriginalCommand: "git-upload-archive " + repo, 38 | RemoteAddr: "127.0.0.1", 39 | } 40 | 41 | args := &commandargs.Shell{ 42 | GitlabKeyID: userID, 43 | CommandType: commandargs.UploadArchive, 44 | SSHArgs: []string{"git-upload-archive", repo}, 45 | Env: env, 46 | } 47 | 48 | cfg := &config.Config{GitlabUrl: url} 49 | cfg.GitalyClient.InitSidechannelRegistry(context.Background()) 50 | cmd := &Command{ 51 | Config: cfg, 52 | Args: args, 53 | ReadWriter: &readwriter.ReadWriter{ErrOut: output, Out: output, In: input}, 54 | } 55 | 56 | correlationID := correlation.SafeRandomID() 57 | ctx := correlation.ContextWithCorrelation(context.Background(), correlationID) 58 | ctx = correlation.ContextWithClientName(ctx, "gitlab-shell-tests") 59 | 60 | _, err := cmd.Execute(ctx) 61 | require.NoError(t, err) 62 | 63 | require.Equal(t, "UploadArchive: "+repo, output.String()) 64 | 65 | for k, v := range map[string]string{ 66 | "gitaly-feature-cache_invalidator": "true", 67 | "gitaly-feature-inforef_uploadpack_cache": "false", 68 | "x-gitlab-client-name": "gitlab-shell-tests-git-upload-archive", 69 | "key_id": "123", 70 | "user_id": "1", 71 | "remote_ip": "127.0.0.1", 72 | "key_type": "key", 73 | } { 74 | actual := testServer.ReceivedMD[k] 75 | require.Len(t, actual, 1) 76 | require.Equal(t, v, actual[0]) 77 | } 78 | require.Empty(t, testServer.ReceivedMD["some-other-ff"]) 79 | require.Equal(t, testServer.ReceivedMD["x-gitlab-correlation-id"][0], correlationID) 80 | }) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | addressable (2.8.7) 5 | public_suffix (>= 2.0.2, < 7.0) 6 | base64 (0.3.0) 7 | bigdecimal (3.1.9) 8 | claide (1.1.0) 9 | claide-plugins (0.9.2) 10 | cork 11 | nap 12 | open4 (~> 1.3) 13 | colored2 (3.1.2) 14 | cork (0.3.0) 15 | colored2 (~> 3.1) 16 | csv (3.3.5) 17 | danger (9.5.1) 18 | base64 (~> 0.2) 19 | claide (~> 1.0) 20 | claide-plugins (>= 0.9.2) 21 | colored2 (~> 3.1) 22 | cork (~> 0.1) 23 | faraday (>= 0.9.0, < 3.0) 24 | faraday-http-cache (~> 2.0) 25 | git (~> 1.13) 26 | kramdown (~> 2.3) 27 | kramdown-parser-gfm (~> 1.0) 28 | octokit (>= 4.0) 29 | pstore (~> 0.1) 30 | terminal-table (>= 1, < 4) 31 | danger-gitlab (8.0.0) 32 | danger 33 | gitlab (~> 4.2, >= 4.2.0) 34 | diff-lcs (1.5.1) 35 | faraday (2.13.1) 36 | faraday-net_http (>= 2.0, < 3.5) 37 | json 38 | logger 39 | faraday-http-cache (2.5.1) 40 | faraday (>= 0.8) 41 | faraday-net_http (3.4.0) 42 | net-http (>= 0.5.0) 43 | git (1.19.1) 44 | addressable (~> 2.8) 45 | rchardet (~> 1.8) 46 | gitlab (4.20.1) 47 | httparty (~> 0.20) 48 | terminal-table (>= 1.5.1) 49 | gitlab-dangerfiles (4.9.2) 50 | danger (>= 9.3.0) 51 | danger-gitlab (>= 8.0.0) 52 | rake (~> 13.0) 53 | httparty (0.23.1) 54 | csv 55 | mini_mime (>= 1.0.0) 56 | multi_xml (>= 0.5.2) 57 | json (2.11.3) 58 | kramdown (2.5.1) 59 | rexml (>= 3.3.9) 60 | kramdown-parser-gfm (1.1.0) 61 | kramdown (~> 2.0) 62 | logger (1.7.0) 63 | mini_mime (1.1.5) 64 | multi_xml (0.7.2) 65 | bigdecimal (~> 3.1) 66 | nap (1.1.0) 67 | net-http (0.6.0) 68 | uri 69 | octokit (6.1.1) 70 | faraday (>= 1, < 3) 71 | sawyer (~> 0.9) 72 | open4 (1.3.4) 73 | pstore (0.2.0) 74 | public_suffix (5.1.1) 75 | rake (13.2.1) 76 | rchardet (1.9.0) 77 | rexml (3.4.1) 78 | rspec (3.13.1) 79 | rspec-core (~> 3.13.0) 80 | rspec-expectations (~> 3.13.0) 81 | rspec-mocks (~> 3.13.0) 82 | rspec-core (3.13.4) 83 | rspec-support (~> 3.13.0) 84 | rspec-expectations (3.13.5) 85 | diff-lcs (>= 1.2.0, < 2.0) 86 | rspec-support (~> 3.13.0) 87 | rspec-mocks (3.13.5) 88 | diff-lcs (>= 1.2.0, < 2.0) 89 | rspec-support (~> 3.13.0) 90 | rspec-support (3.13.4) 91 | sawyer (0.9.2) 92 | addressable (>= 2.3.5) 93 | faraday (>= 0.17.3, < 3) 94 | terminal-table (3.0.2) 95 | unicode-display_width (>= 1.1.1, < 3) 96 | unicode-display_width (2.6.0) 97 | uri (0.13.2) 98 | webrick (1.9.1) 99 | 100 | PLATFORMS 101 | ruby 102 | 103 | DEPENDENCIES 104 | base64 (~> 0.3.0) 105 | gitlab-dangerfiles (~> 4.9.2) 106 | rspec (~> 3.13.1) 107 | webrick (~> 1.9, >= 1.9.1) 108 | 109 | BUNDLED WITH 110 | 2.6.5 111 | -------------------------------------------------------------------------------- /internal/gitlabnet/authorizedcerts/client_test.go: -------------------------------------------------------------------------------- 1 | package authorizedcerts 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/client" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/client/testserver" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 13 | ) 14 | 15 | var ( 16 | requests []testserver.TestRequestHandler 17 | ) 18 | 19 | func init() { 20 | requests = []testserver.TestRequestHandler{ 21 | { 22 | Path: "/api/v4/internal/authorized_certs", 23 | Handler: func(w http.ResponseWriter, r *http.Request) { 24 | switch key := r.URL.Query().Get("key"); key { 25 | case "key": 26 | body := &Response{ 27 | Namespace: "group", 28 | Username: r.URL.Query().Get("user_identifier"), 29 | } 30 | json.NewEncoder(w).Encode(body) 31 | case "broken-message": 32 | w.WriteHeader(http.StatusForbidden) 33 | body := &client.ErrorResponse{ 34 | Message: "Not allowed!", 35 | } 36 | json.NewEncoder(w).Encode(body) 37 | case "broken-json": 38 | w.Write([]byte("{ \"message\": \"broken json!\"")) 39 | case "broken-empty": 40 | w.WriteHeader(http.StatusForbidden) 41 | default: 42 | w.WriteHeader(http.StatusNotFound) 43 | } 44 | }, 45 | }, 46 | } 47 | } 48 | 49 | func TestGetByKey(t *testing.T) { 50 | client := setup(t) 51 | 52 | result, err := client.GetByKey(context.Background(), "user-id", "key") 53 | require.NoError(t, err) 54 | require.Equal(t, &Response{Namespace: "group", Username: "user-id"}, result) 55 | } 56 | 57 | func TestGetByKeyErrorResponses(t *testing.T) { 58 | client := setup(t) 59 | 60 | testCases := []struct { 61 | desc string 62 | key string 63 | expectedError string 64 | }{ 65 | { 66 | desc: "A response with an error message", 67 | key: "broken-message", 68 | expectedError: "Not allowed!", 69 | }, 70 | { 71 | desc: "A response with bad JSON", 72 | key: "broken-json", 73 | expectedError: "parsing failed", 74 | }, 75 | { 76 | desc: "A forbidden (403) response without message", 77 | key: "broken-empty", 78 | expectedError: "Internal API error (403)", 79 | }, 80 | { 81 | desc: "A not found (404) response without message", 82 | key: "not-found", 83 | expectedError: "Internal API error (404)", 84 | }, 85 | } 86 | 87 | for _, tc := range testCases { 88 | t.Run(tc.desc, func(t *testing.T) { 89 | resp, err := client.GetByKey(context.Background(), "user-id", tc.key) 90 | 91 | require.EqualError(t, err, tc.expectedError) 92 | require.Nil(t, resp) 93 | }) 94 | } 95 | } 96 | 97 | func setup(t *testing.T) *Client { 98 | url := testserver.StartSocketHTTPServer(t, requests) 99 | 100 | client, err := NewClient(&config.Config{GitlabUrl: url}) 101 | require.NoError(t, err) 102 | 103 | return client 104 | } 105 | -------------------------------------------------------------------------------- /internal/gitlabnet/twofactorrecover/client.go: -------------------------------------------------------------------------------- 1 | // Package twofactorrecover provides functionality for interacting with GitLab Two-Factor Authentication recovery codes 2 | package twofactorrecover 3 | 4 | import ( 5 | "context" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/client" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet" 14 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/discover" 15 | ) 16 | 17 | // Client represents a client for interacting with GitLab Two-Factor Authentication recovery codes 18 | type Client struct { 19 | config *config.Config 20 | client *client.GitlabNetClient 21 | } 22 | 23 | // Response represents the response structure for Two-Factor Authentication recovery code requests 24 | type Response struct { 25 | Success bool `json:"success"` 26 | RecoveryCodes []string `json:"recovery_codes"` 27 | Message string `json:"message"` 28 | } 29 | 30 | // RequestBody represents the request body structure for Two-Factor Authentication recovery code requests 31 | type RequestBody struct { 32 | KeyID string `json:"key_id,omitempty"` 33 | UserID int64 `json:"user_id,omitempty"` 34 | } 35 | 36 | // NewClient creates a new Client instance with the provided configuration 37 | func NewClient(config *config.Config) (*Client, error) { 38 | client, err := gitlabnet.GetClient(config) 39 | if err != nil { 40 | return nil, fmt.Errorf("error creating http client: %v", err) 41 | } 42 | 43 | return &Client{config: config, client: client}, nil 44 | } 45 | 46 | // GetRecoveryCodes retrieves the recovery codes for the specified user 47 | func (c *Client) GetRecoveryCodes(ctx context.Context, args *commandargs.Shell) ([]string, error) { 48 | requestBody, err := c.getRequestBody(ctx, args) 49 | 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | response, err := c.client.Post(ctx, "/two_factor_recovery_codes", requestBody) 55 | if err != nil { 56 | return nil, err 57 | } 58 | defer func() { _ = response.Body.Close() }() 59 | 60 | return parse(response) 61 | } 62 | 63 | func parse(hr *http.Response) ([]string, error) { 64 | response := &Response{} 65 | if err := gitlabnet.ParseJSON(hr, response); err != nil { 66 | return nil, err 67 | } 68 | 69 | if !response.Success { 70 | return nil, errors.New(response.Message) 71 | } 72 | 73 | return response.RecoveryCodes, nil 74 | } 75 | 76 | func (c *Client) getRequestBody(ctx context.Context, args *commandargs.Shell) (*RequestBody, error) { 77 | client, err := discover.NewClient(c.config) 78 | 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | var requestBody *RequestBody 84 | if args.GitlabKeyID != "" { 85 | requestBody = &RequestBody{KeyID: args.GitlabKeyID} 86 | } else { 87 | userInfo, err := client.GetByCommandArgs(ctx, args) 88 | 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | requestBody = &RequestBody{UserID: userInfo.UserID} 94 | } 95 | 96 | return requestBody, nil 97 | } 98 | -------------------------------------------------------------------------------- /internal/command/authorizedkeys/authorized_keys_test.go: -------------------------------------------------------------------------------- 1 | package authorizedkeys 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "net/http" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/client/testserver" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 14 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 15 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 16 | ) 17 | 18 | var ( 19 | requests = []testserver.TestRequestHandler{ 20 | { 21 | Path: "/api/v4/internal/authorized_keys", 22 | Handler: func(w http.ResponseWriter, r *http.Request) { 23 | if r.URL.Query().Get("key") == "key" { 24 | body := map[string]interface{}{ 25 | "id": 1, 26 | "key": "public-key", 27 | } 28 | json.NewEncoder(w).Encode(body) 29 | } else if r.URL.Query().Get("key") == "broken-message" { 30 | body := map[string]string{ 31 | "message": "Forbidden!", 32 | } 33 | w.WriteHeader(http.StatusForbidden) 34 | json.NewEncoder(w).Encode(body) 35 | } else if r.URL.Query().Get("key") == "broken" { 36 | w.WriteHeader(http.StatusInternalServerError) 37 | } else { 38 | w.WriteHeader(http.StatusNotFound) 39 | } 40 | }, 41 | }, 42 | } 43 | ) 44 | 45 | func TestExecute(t *testing.T) { 46 | url := testserver.StartSocketHTTPServer(t, requests) 47 | 48 | defaultConfig := &config.Config{RootDir: "/tmp", GitlabUrl: url} 49 | 50 | testCases := []struct { 51 | desc string 52 | arguments *commandargs.AuthorizedKeys 53 | expectedOutput string 54 | }{ 55 | { 56 | desc: "With matching username and key", 57 | arguments: &commandargs.AuthorizedKeys{ExpectedUser: "user", ActualUser: "user", Key: "key"}, 58 | expectedOutput: "command=\"/tmp/bin/gitlab-shell key-1\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty public-key\n", 59 | }, 60 | { 61 | desc: "When key doesn't match any existing key", 62 | arguments: &commandargs.AuthorizedKeys{ExpectedUser: "user", ActualUser: "user", Key: "not-found"}, 63 | expectedOutput: "# No key was found for not-found\n", 64 | }, 65 | { 66 | desc: "When the API returns an error", 67 | arguments: &commandargs.AuthorizedKeys{ExpectedUser: "user", ActualUser: "user", Key: "broken-message"}, 68 | expectedOutput: "# No key was found for broken-message\n", 69 | }, 70 | { 71 | desc: "When the API fails", 72 | arguments: &commandargs.AuthorizedKeys{ExpectedUser: "user", ActualUser: "user", Key: "broken"}, 73 | expectedOutput: "# No key was found for broken\n", 74 | }, 75 | } 76 | 77 | for _, tc := range testCases { 78 | t.Run(tc.desc, func(t *testing.T) { 79 | buffer := &bytes.Buffer{} 80 | 81 | cmd := &Command{ 82 | Config: defaultConfig, 83 | Args: tc.arguments, 84 | ReadWriter: &readwriter.ReadWriter{Out: buffer}, 85 | } 86 | 87 | _, err := cmd.Execute(context.Background()) 88 | 89 | require.NoError(t, err) 90 | require.Equal(t, tc.expectedOutput, buffer.String()) 91 | }) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /internal/command/twofactorrecover/twofactorrecover.go: -------------------------------------------------------------------------------- 1 | // Package twofactorrecover defines logic for 2FA codes recovery 2 | package twofactorrecover 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "io" 8 | "strings" 9 | 10 | "gitlab.com/gitlab-org/labkit/log" 11 | 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 14 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 15 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/twofactorrecover" 16 | ) 17 | 18 | const readerLimit = 1024 19 | 20 | // Command provides arguments to configure 2FA 21 | type Command struct { 22 | Config *config.Config 23 | Args *commandargs.Shell 24 | ReadWriter *readwriter.ReadWriter 25 | } 26 | 27 | // Execute generates new recovery codes 28 | func (c *Command) Execute(ctx context.Context) (context.Context, error) { 29 | ctxlog := log.ContextLogger(ctx) 30 | ctxlog.Debug("twofactorrecover: execute: Waiting for user input") 31 | 32 | if c.getUserAnswer(ctx) == "yes" { 33 | ctxlog.Debug("twofactorrecover: execute: User chose to continue") 34 | c.displayRecoveryCodes(ctx) 35 | } else { 36 | ctxlog.Debug("twofactorrecover: execute: User chose not to continue") 37 | _, _ = fmt.Fprintln(c.ReadWriter.Out, "\nNew recovery codes have *not* been generated. Existing codes will remain valid.") 38 | } 39 | 40 | return ctx, nil 41 | } 42 | 43 | func (c *Command) getUserAnswer(ctx context.Context) string { 44 | question := 45 | "Are you sure you want to generate new two-factor recovery codes?\n" + 46 | "Any existing recovery codes you saved will be invalidated. (yes/no)" 47 | _, _ = fmt.Fprintln(c.ReadWriter.Out, question) 48 | 49 | var answer string 50 | if _, err := fmt.Fscanln(io.LimitReader(c.ReadWriter.In, readerLimit), &answer); err != nil { 51 | log.ContextLogger(ctx).WithError(err).Debug("twofactorrecover: getUserAnswer: Failed to get user input") 52 | } 53 | 54 | return answer 55 | } 56 | 57 | func (c *Command) displayRecoveryCodes(ctx context.Context) { 58 | ctxlog := log.ContextLogger(ctx) 59 | 60 | codes, err := c.getRecoveryCodes(ctx) 61 | 62 | if err == nil { 63 | ctxlog.Debug("twofactorrecover: displayRecoveryCodes: recovery codes successfully generated") 64 | messageWithCodes := 65 | "\nYour two-factor authentication recovery codes are:\n\n" + 66 | strings.Join(codes, "\n") + 67 | "\n\nDuring sign in, use one of the codes above when prompted for\n" + 68 | "your two-factor code. Then, visit your Profile Settings and add\n" + 69 | "a new device so you do not lose access to your account again.\n" 70 | _, _ = fmt.Fprint(c.ReadWriter.Out, messageWithCodes) 71 | } else { 72 | ctxlog.WithError(err).Error("twofactorrecover: displayRecoveryCodes: failed to generate recovery codes") 73 | _, _ = fmt.Fprintf(c.ReadWriter.Out, "\nAn error occurred while trying to generate new recovery codes.\n%v\n", err) 74 | } 75 | } 76 | 77 | func (c *Command) getRecoveryCodes(ctx context.Context) ([]string, error) { 78 | client, err := twofactorrecover.NewClient(c.Config) 79 | 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | return client.GetRecoveryCodes(ctx, c.Args) 85 | } 86 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | 8 | "github.com/prometheus/client_golang/prometheus" 9 | "github.com/stretchr/testify/require" 10 | yaml "gopkg.in/yaml.v3" 11 | 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/client/testserver" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/testhelper" 14 | ) 15 | 16 | func TestDefaultConfig(t *testing.T) { 17 | config := &Config{} 18 | 19 | require.False(t, config.LFSConfig.PureSSHProtocol) 20 | } 21 | 22 | func TestConfigApplyGlobalState(t *testing.T) { 23 | testhelper.TempEnv(t, map[string]string{"SSL_CERT_DIR": "unmodified"}) 24 | 25 | config := &Config{SslCertDir: ""} 26 | config.ApplyGlobalState() 27 | 28 | require.Equal(t, "unmodified", os.Getenv("SSL_CERT_DIR")) 29 | 30 | config.SslCertDir = "foo" 31 | config.ApplyGlobalState() 32 | 33 | require.Equal(t, "foo", os.Getenv("SSL_CERT_DIR")) 34 | } 35 | 36 | func TestCustomPrometheusMetrics(t *testing.T) { 37 | url := testserver.StartHTTPServer(t, []testserver.TestRequestHandler{}) 38 | 39 | config := &Config{GitlabUrl: url} 40 | client, err := config.HTTPClient() 41 | require.NoError(t, err) 42 | 43 | if client.RetryableHTTP != nil { 44 | _, err = client.RetryableHTTP.Get(url) 45 | require.NoError(t, err) 46 | } 47 | 48 | ms, err := prometheus.DefaultGatherer.Gather() 49 | require.NoError(t, err) 50 | 51 | var actualNames []string 52 | for _, m := range ms[0:9] { 53 | actualNames = append(actualNames, m.GetName()) 54 | } 55 | 56 | expectedMetricNames := []string{ 57 | "gitlab_shell_http_in_flight_requests", 58 | "gitlab_shell_http_request_duration_seconds", 59 | "gitlab_shell_http_requests_total", 60 | "gitlab_shell_sshd_concurrent_limited_sessions_total", 61 | "gitlab_shell_sshd_in_flight_connections", 62 | "gitlab_shell_sshd_session_duration_seconds", 63 | "gitlab_shell_sshd_session_established_duration_seconds", 64 | "gitlab_sli:shell_sshd_sessions:errors_total", 65 | "gitlab_sli:shell_sshd_sessions:total", 66 | } 67 | 68 | require.Equal(t, expectedMetricNames, actualNames) 69 | } 70 | 71 | func TestNewFromDir(t *testing.T) { 72 | testRoot := testhelper.PrepareTestRootDir(t) 73 | 74 | cfg, err := NewFromDir(testRoot) 75 | require.NoError(t, err) 76 | 77 | require.Equal(t, 10*time.Second, time.Duration(cfg.Server.GracePeriod)) 78 | require.Equal(t, 1*time.Minute, time.Duration(cfg.Server.ClientAliveInterval)) 79 | require.Equal(t, 500*time.Millisecond, time.Duration(cfg.Server.ProxyHeaderTimeout)) 80 | } 81 | 82 | func TestYAMLDuration(t *testing.T) { 83 | testCases := []struct { 84 | desc string 85 | data string 86 | duration time.Duration 87 | }{ 88 | {"seconds assumed by default", "duration: 10", 10 * time.Second}, 89 | {"milliseconds are parsed", "duration: 500ms", 500 * time.Millisecond}, 90 | {"minutes are parsed", "duration: 1m", 1 * time.Minute}, 91 | } 92 | 93 | type durationCfg struct { 94 | Duration YamlDuration `yaml:"duration"` 95 | } 96 | 97 | for _, tc := range testCases { 98 | t.Run(tc.desc, func(t *testing.T) { 99 | var cfg durationCfg 100 | err := yaml.Unmarshal([]byte(tc.data), &cfg) 101 | require.NoError(t, err) 102 | 103 | require.Equal(t, tc.duration, time.Duration(cfg.Duration)) 104 | }) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /internal/logger/logger.go: -------------------------------------------------------------------------------- 1 | // Package logger provides logging configuration utilities for the gitlab-shell 2 | package logger 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "log/syslog" 8 | "os" 9 | "time" 10 | 11 | "gitlab.com/gitlab-org/labkit/log" 12 | 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 14 | ) 15 | 16 | func logFmt(inFmt string) string { 17 | // Hide the "combined" format, since that makes no sense in gitlab-shell. 18 | // The default is JSON when unspecified. 19 | if inFmt == "" || inFmt == "combined" { 20 | return "json" 21 | } 22 | 23 | return inFmt 24 | } 25 | 26 | func logLevel(inLevel string) string { 27 | if inLevel == "" { 28 | return "info" 29 | } 30 | 31 | return inLevel 32 | } 33 | 34 | func logFile(inFile string) string { 35 | if inFile == "" { 36 | return "stderr" 37 | } 38 | 39 | return inFile 40 | } 41 | 42 | func buildOpts(cfg *config.Config) []log.LoggerOption { 43 | return []log.LoggerOption{ 44 | log.WithFormatter(logFmt(cfg.LogFormat)), 45 | log.WithOutputName(logFile(cfg.LogFile)), 46 | log.WithTimezone(time.UTC), 47 | log.WithLogLevel(logLevel(cfg.LogLevel)), 48 | } 49 | } 50 | 51 | // Configure configures the logging singleton for operation inside a remote TTY (like SSH). In this 52 | // mode an empty LogFile is not accepted and syslog is used as a fallback when LogFile could not be 53 | // opened for writing. 54 | func Configure(cfg *config.Config) io.Closer { 55 | var closer io.Closer = io.NopCloser(nil) 56 | err := fmt.Errorf("no logfile specified") 57 | 58 | if cfg.LogFile != "" { 59 | closer, err = log.Initialize(buildOpts(cfg)...) 60 | } 61 | 62 | if err != nil { 63 | progName, _ := os.Executable() 64 | syslogLogger, syslogLoggerErr := syslog.NewLogger(syslog.LOG_ERR|syslog.LOG_USER, 0) 65 | if syslogLoggerErr == nil { 66 | msg := fmt.Sprintf("%s: Unable to configure logging: %v\n", progName, err.Error()) 67 | syslogLogger.Print(msg) 68 | } else { 69 | msg := fmt.Sprintf("%s: Unable to configure logging: %v, %v\n", progName, err.Error(), syslogLoggerErr.Error()) 70 | fmt.Fprintln(os.Stderr, msg) 71 | } 72 | 73 | cfg.LogFile = "/dev/null" 74 | closer, err = log.Initialize(buildOpts(cfg)...) 75 | if err != nil { 76 | log.WithError(err).Warn("Unable to configure logging to /dev/null, leaving unconfigured") 77 | } 78 | } 79 | 80 | return closer 81 | } 82 | 83 | // ConfigureStandalone configures the logging singleton for standalone operation. In this mode an 84 | // empty LogFile is treated as logging to stderr, and standard output is used as a fallback 85 | // when LogFile could not be opened for writing. 86 | func ConfigureStandalone(cfg *config.Config) io.Closer { 87 | closer, err1 := log.Initialize(buildOpts(cfg)...) 88 | if err1 != nil { 89 | var err2 error 90 | 91 | cfg.LogFile = "stdout" 92 | closer, err2 = log.Initialize(buildOpts(cfg)...) 93 | 94 | // Output this after the logger has been configured! 95 | log.WithError(err1).WithField("log_file", cfg.LogFile).Warn("Unable to configure logging, falling back to STDOUT") 96 | 97 | // LabKit v1.7.0 doesn't have any conditions where logging to "stdout" will fail 98 | if err2 != nil { 99 | log.WithError(err2).Warn("Unable to configure logging to STDOUT, leaving unconfigured") 100 | } 101 | } 102 | 103 | return closer 104 | } 105 | -------------------------------------------------------------------------------- /internal/gitlabnet/personalaccesstoken/client.go: -------------------------------------------------------------------------------- 1 | // Package personalaccesstoken provides functionality for managing personal access tokens 2 | package personalaccesstoken 3 | 4 | import ( 5 | "context" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/client" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet" 14 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/discover" 15 | ) 16 | 17 | // Client represents a client for managing personal access tokens 18 | type Client struct { 19 | config *config.Config 20 | client *client.GitlabNetClient 21 | } 22 | 23 | // Response represents the response from creating a personal access token 24 | type Response struct { 25 | Success bool `json:"success"` 26 | Token string `json:"token"` 27 | Scopes []string `json:"scopes"` 28 | ExpiresAt string `json:"expires_at"` 29 | Message string `json:"message"` 30 | } 31 | 32 | // RequestBody represents the request body for creating a personal access token 33 | type RequestBody struct { 34 | KeyID string `json:"key_id,omitempty"` 35 | UserID int64 `json:"user_id,omitempty"` 36 | Name string `json:"name"` 37 | Scopes []string `json:"scopes"` 38 | ExpiresAt string `json:"expires_at,omitempty"` 39 | } 40 | 41 | // NewClient creates a new instance of Client 42 | func NewClient(config *config.Config) (*Client, error) { 43 | client, err := gitlabnet.GetClient(config) 44 | if err != nil { 45 | return nil, fmt.Errorf("error creating http client: %v", err) 46 | } 47 | 48 | return &Client{config: config, client: client}, nil 49 | } 50 | 51 | // GetPersonalAccessToken retrieves or creates a personal access token 52 | func (c *Client) GetPersonalAccessToken(ctx context.Context, args *commandargs.Shell, name string, scopes *[]string, expiresAt string) (*Response, error) { 53 | requestBody, err := c.getRequestBody(ctx, args, name, scopes, expiresAt) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | response, err := c.client.Post(ctx, "/personal_access_token", requestBody) 59 | if err != nil { 60 | return nil, err 61 | } 62 | defer func() { _ = response.Body.Close() }() 63 | 64 | return parse(response) 65 | } 66 | 67 | func parse(hr *http.Response) (*Response, error) { 68 | response := &Response{} 69 | if err := gitlabnet.ParseJSON(hr, response); err != nil { 70 | return nil, err 71 | } 72 | 73 | if !response.Success { 74 | return nil, errors.New(response.Message) 75 | } 76 | 77 | return response, nil 78 | } 79 | 80 | func (c *Client) getRequestBody(ctx context.Context, args *commandargs.Shell, name string, scopes *[]string, expiresAt string) (*RequestBody, error) { 81 | client, err := discover.NewClient(c.config) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | requestBody := &RequestBody{Name: name, Scopes: *scopes, ExpiresAt: expiresAt} 87 | if args.GitlabKeyID != "" { 88 | requestBody.KeyID = args.GitlabKeyID 89 | 90 | return requestBody, nil 91 | } 92 | 93 | userInfo, err := client.GetByCommandArgs(ctx, args) 94 | if err != nil { 95 | return nil, err 96 | } 97 | requestBody.UserID = userInfo.UserID 98 | 99 | return requestBody, nil 100 | } 101 | -------------------------------------------------------------------------------- /internal/pktline/pktline_test.go: -------------------------------------------------------------------------------- 1 | package pktline 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | var ( 11 | largestString = strings.Repeat("z", 0xffff-4) 12 | ) 13 | 14 | func TestScanner(t *testing.T) { 15 | largestPacket := "ffff" + largestString 16 | testCases := []struct { 17 | desc string 18 | in string 19 | out []string 20 | fail bool 21 | }{ 22 | { 23 | desc: "happy path", 24 | in: "0010hello world!000000010010hello world!", 25 | out: []string{"0010hello world!", "0000", "0001", "0010hello world!"}, 26 | }, 27 | { 28 | desc: "large input", 29 | in: "0010hello world!0000" + largestPacket + "0000", 30 | out: []string{"0010hello world!", "0000", largestPacket, "0000"}, 31 | }, 32 | { 33 | desc: "missing byte middle", 34 | in: "0010hello world!00000010010hello world!", 35 | out: []string{"0010hello world!", "0000", "0010010hello wor"}, 36 | fail: true, 37 | }, 38 | { 39 | desc: "unfinished prefix", 40 | in: "0010hello world!000", 41 | out: []string{"0010hello world!"}, 42 | fail: true, 43 | }, 44 | { 45 | desc: "short read in data, only prefix", 46 | in: "0010hello world!0005", 47 | out: []string{"0010hello world!"}, 48 | fail: true, 49 | }, 50 | } 51 | 52 | for _, tc := range testCases { 53 | t.Run(tc.desc, func(t *testing.T) { 54 | scanner := NewScanner(strings.NewReader(tc.in)) 55 | var output []string 56 | for scanner.Scan() { 57 | output = append(output, scanner.Text()) 58 | } 59 | 60 | if tc.fail { 61 | require.Error(t, scanner.Err()) 62 | } else { 63 | require.NoError(t, scanner.Err()) 64 | } 65 | 66 | require.Equal(t, tc.out, output) 67 | }) 68 | } 69 | } 70 | 71 | func TestIsRefRemoval(t *testing.T) { 72 | testCases := []struct { 73 | in string 74 | isRemoval bool 75 | }{ 76 | {in: "003f7217a7c7e582c46cec22a130adf4b9d7d950fba0 7d1665144a3a975c05f1f43902ddaf084e784dbe refs/heads/debug", isRemoval: false}, 77 | {in: "003f0000000000000000000000000000000000000000 7d1665144a3a975c05f1f43902ddaf084e784dbe refs/heads/debug", isRemoval: false}, 78 | {in: "003f7217a7c7e582c46cec22a130adf4b9d7d950fba0 0000000000000000000000000000000000000000 refs/heads/debug", isRemoval: true}, 79 | } 80 | 81 | for _, tc := range testCases { 82 | t.Run(tc.in, func(t *testing.T) { 83 | require.Equal(t, tc.isRemoval, IsRefRemoval([]byte(tc.in))) 84 | }) 85 | } 86 | } 87 | 88 | func TestIsFlush(t *testing.T) { 89 | testCases := []struct { 90 | in string 91 | flush bool 92 | }{ 93 | {in: "0008abcd", flush: false}, 94 | {in: "invalid packet", flush: false}, 95 | {in: "0000", flush: true}, 96 | } 97 | 98 | for _, tc := range testCases { 99 | t.Run(tc.in, func(t *testing.T) { 100 | require.Equal(t, tc.flush, IsFlush([]byte(tc.in))) 101 | }) 102 | } 103 | } 104 | 105 | func TestIsDone(t *testing.T) { 106 | testCases := []struct { 107 | in string 108 | done bool 109 | }{ 110 | {in: "0008abcd", done: false}, 111 | {in: "invalid packet", done: false}, 112 | {in: "0009done\n", done: true}, 113 | {in: "0001", done: false}, 114 | } 115 | 116 | for _, tc := range testCases { 117 | t.Run(tc.in, func(t *testing.T) { 118 | require.Equal(t, tc.done, IsDone([]byte(tc.in))) 119 | }) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /internal/testhelper/testdata/testroot/certs/client/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | 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 51 | bmebasEKpSH4ftWF03etDPQQiA4o3A== 52 | -----END PRIVATE KEY----- 53 | -------------------------------------------------------------------------------- /spec/gitlab_shell_authorized_keys_check_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | describe 'bin/gitlab-shell-authorized-keys-check' do 4 | include_context 'gitlab shell' 5 | 6 | def tmp_socket_path 7 | # This has to be a relative path shorter than 100 bytes due to 8 | # limitations in how Unix sockets work. 9 | 'tmp/gitlab-shell-authorized-keys-check-socket' 10 | end 11 | 12 | def mock_server(server) 13 | server.mount_proc('/api/v4/internal/authorized_keys') do |req, res| 14 | if req.query['key'] == 'known-rsa-key' 15 | res.status = 200 16 | res.content_type = 'application/json' 17 | res.body = '{"key":"known-rsa-key", "id": 1}' 18 | else 19 | res.status = 404 20 | end 21 | end 22 | end 23 | 24 | let(:authorized_keys_check_path) { File.join(tmp_root_path, 'bin', 'gitlab-shell-authorized-keys-check') } 25 | 26 | shared_examples 'authorized keys check' do 27 | it 'succeeds when a valid key is given' do 28 | output, status = run! 29 | 30 | expect(output).to eq("command=\"#{gitlab_shell_path} key-1\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty known-rsa-key\n") 31 | expect(status).to be_success 32 | end 33 | 34 | it 'returns nothing when an unknown key is given' do 35 | output, status = run!(key: 'unknown-key') 36 | 37 | expect(output).to eq("# No key was found for unknown-key\n") 38 | expect(status).to be_success 39 | end 40 | 41 | it' fails when not enough arguments are given' do 42 | output, status = run!(key: nil) 43 | 44 | expect(output).to eq('') 45 | expect(status).not_to be_success 46 | end 47 | 48 | it' fails when too many arguments are given' do 49 | output, status = run!(key: ['a', 'b']) 50 | 51 | expect(output).to eq('') 52 | expect(status).not_to be_success 53 | end 54 | 55 | it 'skips when run as the wrong user' do 56 | output, status = run!(expected_user: 'unknown-user') 57 | 58 | expect(output).to eq('') 59 | expect(status).to be_success 60 | end 61 | 62 | it 'skips when the wrong users connects' do 63 | output, status = run!(actual_user: 'unknown-user') 64 | 65 | expect(output).to eq('') 66 | expect(status).to be_success 67 | end 68 | end 69 | 70 | describe 'without go features' do 71 | before(:all) do 72 | write_config( 73 | "gitlab_url" => "http+unix://#{CGI.escape(tmp_socket_path)}", 74 | ) 75 | end 76 | 77 | it_behaves_like 'authorized keys check' 78 | end 79 | 80 | describe 'without go features (via go)', :go do 81 | before(:all) do 82 | write_config( 83 | "gitlab_url" => "http+unix://#{CGI.escape(tmp_socket_path)}", 84 | ) 85 | end 86 | 87 | it_behaves_like 'authorized keys check' 88 | end 89 | 90 | describe 'with the go authorized-keys-check feature', :go do 91 | before(:all) do 92 | write_config( 93 | 'gitlab_url' => "http+unix://#{CGI.escape(tmp_socket_path)}" 94 | ) 95 | end 96 | 97 | it_behaves_like 'authorized keys check' 98 | end 99 | 100 | def run!(expected_user: 'git', actual_user: 'git', key: 'known-rsa-key') 101 | cmd = [ 102 | authorized_keys_check_path, 103 | expected_user, 104 | actual_user, 105 | key 106 | ].flatten.compact 107 | 108 | output = IO.popen(cmd, &:read) 109 | 110 | [output, $?] 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /internal/gitlabnet/twofactorverify/client.go: -------------------------------------------------------------------------------- 1 | // Package twofactorverify provides functionality for verifying two-factor authentication in GitLab. 2 | package twofactorverify 3 | 4 | import ( 5 | "context" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/client" 11 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet" 14 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/discover" 15 | ) 16 | 17 | // Client represents a client for interacting with the two-factor verification API. 18 | type Client struct { 19 | config *config.Config 20 | client *client.GitlabNetClient 21 | } 22 | 23 | // Response represents the response from the two-factor verification API. 24 | type Response struct { 25 | Success bool `json:"success"` 26 | Message string `json:"message"` 27 | } 28 | 29 | // RequestBody represents the request body for two-factor verification. 30 | type RequestBody struct { 31 | KeyID string `json:"key_id,omitempty"` 32 | UserID int64 `json:"user_id,omitempty"` 33 | OTPAttempt string `json:"otp_attempt,omitempty"` 34 | } 35 | 36 | // NewClient creates a new instance of the two-factor verification client. 37 | func NewClient(config *config.Config) (*Client, error) { 38 | client, err := gitlabnet.GetClient(config) 39 | if err != nil { 40 | return nil, fmt.Errorf("error creating http client: %v", err) 41 | } 42 | 43 | return &Client{config: config, client: client}, nil 44 | } 45 | 46 | // VerifyOTP verifies the one-time password (OTP) for two-factor authentication. 47 | func (c *Client) VerifyOTP(ctx context.Context, args *commandargs.Shell, otp string) error { 48 | requestBody, err := c.getRequestBody(ctx, args, otp) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | response, err := c.client.Post(ctx, "/two_factor_manual_otp_check", requestBody) 54 | if err != nil { 55 | return err 56 | } 57 | defer func() { _ = response.Body.Close() }() 58 | 59 | return parse(response) 60 | } 61 | 62 | // PushAuth performs two-factor push authentication. 63 | func (c *Client) PushAuth(ctx context.Context, args *commandargs.Shell) error { 64 | requestBody, err := c.getRequestBody(ctx, args, "") 65 | if err != nil { 66 | return err 67 | } 68 | 69 | response, err := c.client.Post(ctx, "/two_factor_push_otp_check", requestBody) 70 | if err != nil { 71 | return err 72 | } 73 | defer func() { _ = response.Body.Close() }() 74 | 75 | return parse(response) 76 | } 77 | 78 | func parse(hr *http.Response) error { 79 | response := &Response{} 80 | if err := gitlabnet.ParseJSON(hr, response); err != nil { 81 | return err 82 | } 83 | 84 | if !response.Success { 85 | return errors.New(response.Message) 86 | } 87 | 88 | return nil 89 | } 90 | 91 | func (c *Client) getRequestBody(ctx context.Context, args *commandargs.Shell, otp string) (*RequestBody, error) { 92 | client, err := discover.NewClient(c.config) 93 | 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | var requestBody *RequestBody 99 | if args.GitlabKeyID != "" { 100 | requestBody = &RequestBody{KeyID: args.GitlabKeyID, OTPAttempt: otp} 101 | } else { 102 | userInfo, err := client.GetByCommandArgs(ctx, args) 103 | 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | requestBody = &RequestBody{UserID: userInfo.UserID, OTPAttempt: otp} 109 | } 110 | 111 | return requestBody, nil 112 | } 113 | -------------------------------------------------------------------------------- /internal/command/receivepack/gitalycall_test.go: -------------------------------------------------------------------------------- 1 | package receivepack 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | "gitlab.com/gitlab-org/labkit/correlation" 11 | 12 | "gitlab.com/gitlab-org/gitlab-shell/v14/client/testserver" 13 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 14 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 15 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 16 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/sshenv" 17 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/testhelper/requesthandlers" 18 | ) 19 | 20 | func TestReceivePack(t *testing.T) { 21 | for _, network := range []string{"unix", "tcp", "dns"} { 22 | t.Run(fmt.Sprintf("via %s network", network), func(t *testing.T) { 23 | gitalyAddress, testServer := testserver.StartGitalyServer(t, network) 24 | t.Log(fmt.Sprintf("Server address: %s", gitalyAddress)) 25 | 26 | requests := requesthandlers.BuildAllowedWithGitalyHandlers(t, gitalyAddress) 27 | url := testserver.StartHTTPServer(t, requests) 28 | 29 | testCases := []struct { 30 | username string 31 | keyId string 32 | }{ 33 | { 34 | username: "john.doe", 35 | }, 36 | { 37 | keyId: "123", 38 | }, 39 | } 40 | 41 | for _, tc := range testCases { 42 | output := &bytes.Buffer{} 43 | input := &bytes.Buffer{} 44 | repo := "group/repo" 45 | 46 | env := sshenv.Env{ 47 | IsSSHConnection: true, 48 | OriginalCommand: "git-receive-pack " + repo, 49 | RemoteAddr: "127.0.0.1", 50 | } 51 | 52 | args := &commandargs.Shell{ 53 | CommandType: commandargs.ReceivePack, 54 | SSHArgs: []string{"git-receive-pack", repo}, 55 | Env: env, 56 | } 57 | 58 | if tc.username != "" { 59 | args.GitlabUsername = tc.username 60 | } else { 61 | args.GitlabKeyID = tc.keyId 62 | } 63 | 64 | cfg := &config.Config{GitlabUrl: url} 65 | cfg.GitalyClient.InitSidechannelRegistry(context.Background()) 66 | cmd := &Command{ 67 | Config: cfg, 68 | Args: args, 69 | ReadWriter: &readwriter.ReadWriter{ErrOut: output, Out: output, In: input}, 70 | } 71 | 72 | ctx := correlation.ContextWithCorrelation(context.Background(), "a-correlation-id") 73 | ctx = correlation.ContextWithClientName(ctx, "gitlab-shell-tests") 74 | 75 | _, err := cmd.Execute(ctx) 76 | require.NoError(t, err) 77 | 78 | if tc.username != "" { 79 | require.Equal(t, "ReceivePack: 1 "+repo, output.String()) 80 | } else { 81 | require.Equal(t, "ReceivePack: key-123 "+repo, output.String()) 82 | } 83 | 84 | for k, v := range map[string]string{ 85 | "gitaly-feature-cache_invalidator": "true", 86 | "gitaly-feature-inforef_uploadpack_cache": "false", 87 | "x-gitlab-client-name": "gitlab-shell-tests-git-receive-pack", 88 | "key_id": "123", 89 | "user_id": "1", 90 | "remote_ip": "127.0.0.1", 91 | "key_type": "key", 92 | } { 93 | actual := testServer.ReceivedMD[k] 94 | require.Len(t, actual, 1) 95 | require.Equal(t, v, actual[0]) 96 | } 97 | require.Empty(t, testServer.ReceivedMD["some-other-ff"]) 98 | require.Equal(t, testServer.ReceivedMD["x-gitlab-correlation-id"][0], "a-correlation-id") 99 | } 100 | }) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /internal/command/command.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path" 8 | "strings" 9 | 10 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 11 | "gitlab.com/gitlab-org/labkit/correlation" 12 | "gitlab.com/gitlab-org/labkit/tracing" 13 | ) 14 | 15 | type Command interface { 16 | Execute(ctx context.Context) (context.Context, error) 17 | } 18 | 19 | type LogMetadata struct { 20 | Project string `json:"project,omitempty"` 21 | RootNamespace string `json:"root_namespace,omitempty"` 22 | ProjectID int `json:"project_id,omitempty"` 23 | RootNamespaceID int `json:"root_namespace_id,omitempty"` 24 | } 25 | 26 | type LogData struct { 27 | Username string `json:"username"` 28 | WrittenBytes int64 `json:"written_bytes"` 29 | Meta LogMetadata `json:"meta"` 30 | } 31 | 32 | type contextKey string 33 | 34 | // LogDataKey is the context key used to store log data in request contexts. 35 | const LogDataKey contextKey = "logData" 36 | 37 | func CheckForVersionFlag(osArgs []string, version, buildTime string) { 38 | // We can't use the flag library because gitlab-shell receives other arguments 39 | // that confuse the parser. 40 | // 41 | // See: https://gitlab.com/gitlab-org/gitlab-shell/-/merge_requests/800#note_1459474735 42 | if len(osArgs) == 2 && osArgs[1] == "-version" { 43 | fmt.Printf("%s %s-%s\n", path.Base(osArgs[0]), version, buildTime) 44 | os.Exit(0) 45 | } 46 | } 47 | 48 | // Setup() initializes tracing from the configuration file and generates a 49 | // background context from which all other contexts in the process should derive 50 | // from, as it has a service name and initial correlation ID set. 51 | func Setup(serviceName string, config *config.Config) (context.Context, func()) { 52 | closer := tracing.Initialize( 53 | tracing.WithServiceName(serviceName), 54 | 55 | // For GitLab-Shell, we explicitly initialize tracing from a config file 56 | // instead of the default environment variable (using GITLAB_TRACING) 57 | // This decision was made owing to the difficulty in passing environment 58 | // variables into GitLab-Shell processes. 59 | // 60 | // Processes are spawned as children of the SSH daemon, which tightly 61 | // controls environment variables; doing this means we don't have to 62 | // enable PermitUserEnvironment 63 | // 64 | // gitlab-sshd could use the standard GITLAB_TRACING envvar, but that 65 | // would lead to inconsistencies between the two forms of operation 66 | tracing.WithConnectionString(config.GitlabTracing), 67 | ) 68 | 69 | ctx, finished := tracing.ExtractFromEnv(context.Background()) 70 | ctx = correlation.ContextWithClientName(ctx, serviceName) 71 | 72 | correlationID := correlation.ExtractFromContext(ctx) 73 | if correlationID == "" { 74 | correlationID := correlation.SafeRandomID() 75 | ctx = correlation.ContextWithCorrelation(ctx, correlationID) 76 | } 77 | 78 | return ctx, func() { 79 | finished() 80 | closer.Close() 81 | } 82 | } 83 | 84 | func NewLogData(project, username string, projectID, rootNamespaceID int) LogData { 85 | rootNameSpace := "" 86 | 87 | if len(project) > 0 { 88 | splitFn := func(c rune) bool { 89 | return c == '/' 90 | } 91 | m := strings.FieldsFunc(project, splitFn) 92 | if len(m) > 0 { 93 | rootNameSpace = m[0] 94 | } 95 | } 96 | 97 | return LogData{ 98 | Username: username, 99 | WrittenBytes: 0, 100 | Meta: LogMetadata{ 101 | Project: project, 102 | RootNamespace: rootNameSpace, 103 | ProjectID: projectID, 104 | RootNamespaceID: rootNamespaceID, 105 | }, 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /internal/command/personalaccesstoken/personalaccesstoken.go: -------------------------------------------------------------------------------- 1 | // Package personalaccesstoken handles operations related to personal access tokens, 2 | // including parsing arguments, requesting tokens, and formatting responses. 3 | package personalaccesstoken 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | "fmt" 9 | "slices" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "gitlab.com/gitlab-org/labkit/log" 15 | 16 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs" 17 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter" 18 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/config" 19 | "gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/personalaccesstoken" 20 | ) 21 | 22 | const ( 23 | usageText = "Usage: personal_access_token [ttl_days]" 24 | expiresDateFormat = "2006-01-02" 25 | ) 26 | 27 | // Command represents a command to manage personal access tokens. 28 | type Command struct { 29 | Config *config.Config 30 | Args *commandargs.Shell 31 | ReadWriter *readwriter.ReadWriter 32 | TokenArgs *tokenArgs 33 | } 34 | 35 | type tokenArgs struct { 36 | Name string 37 | Scopes []string 38 | ExpiresDate string // Calculated, a TTL is passed from command-line. 39 | } 40 | 41 | // Execute processes the command, requests a personal access token, and prints the result. 42 | func (c *Command) Execute(ctx context.Context) (context.Context, error) { 43 | err := c.parseTokenArgs() 44 | if err != nil { 45 | return ctx, err 46 | } 47 | 48 | log.WithContextFields(ctx, log.Fields{ 49 | "token_args": c.TokenArgs, 50 | }).Info("personalaccesstoken: execute: requesting token") 51 | 52 | response, err := c.getPersonalAccessToken(ctx) 53 | if err != nil { 54 | return ctx, err 55 | } 56 | 57 | _, _ = fmt.Fprint(c.ReadWriter.Out, "Token: "+response.Token+"\n") 58 | _, _ = fmt.Fprint(c.ReadWriter.Out, "Scopes: "+strings.Join(response.Scopes, ",")+"\n") 59 | _, _ = fmt.Fprint(c.ReadWriter.Out, "Expires: "+response.ExpiresAt+"\n") 60 | 61 | return ctx, nil 62 | } 63 | 64 | func (c *Command) parseTokenArgs() error { 65 | if len(c.Args.SSHArgs) < 3 || len(c.Args.SSHArgs) > 4 { 66 | return errors.New(usageText) // nolint:stylecheck // usageText is customer facing 67 | } 68 | 69 | var rectfiedScopes []string 70 | requestedScopes := strings.Split(c.Args.SSHArgs[2], ",") 71 | if len(c.Config.PATConfig.AllowedScopes) > 0 { 72 | for _, requestedScope := range requestedScopes { 73 | if slices.Contains(c.Config.PATConfig.AllowedScopes, requestedScope) { 74 | rectfiedScopes = append(rectfiedScopes, requestedScope) 75 | } 76 | } 77 | } else { 78 | rectfiedScopes = requestedScopes 79 | } 80 | c.TokenArgs = &tokenArgs{ 81 | Name: c.Args.SSHArgs[1], 82 | Scopes: rectfiedScopes, 83 | } 84 | 85 | if len(c.Args.SSHArgs) < 4 { 86 | c.TokenArgs.ExpiresDate = time.Now().AddDate(0, 0, 30).Format(expiresDateFormat) 87 | return nil 88 | } 89 | rawTTL := c.Args.SSHArgs[3] 90 | 91 | TTL, err := strconv.Atoi(rawTTL) 92 | if err != nil || TTL < 0 { 93 | return fmt.Errorf("Invalid value for days_ttl: '%s'", rawTTL) //nolint:stylecheck //message is customer facing 94 | } 95 | 96 | c.TokenArgs.ExpiresDate = time.Now().AddDate(0, 0, TTL+1).Format(expiresDateFormat) 97 | 98 | return nil 99 | } 100 | 101 | func (c *Command) getPersonalAccessToken(ctx context.Context) (*personalaccesstoken.Response, error) { 102 | client, err := personalaccesstoken.NewClient(c.Config) 103 | if err != nil { 104 | return nil, err 105 | } 106 | 107 | return client.GetPersonalAccessToken(ctx, c.Args, c.TokenArgs.Name, &c.TokenArgs.Scopes, c.TokenArgs.ExpiresDate) 108 | } 109 | -------------------------------------------------------------------------------- /internal/gitlabnet/git/client.go: -------------------------------------------------------------------------------- 1 | // Package git provides functionality for interacting with Git repositories. 2 | package git 3 | 4 | import ( 5 | "context" 6 | "io" 7 | "net/http" 8 | 9 | "gitlab.com/gitlab-org/gitlab-shell/v14/client" 10 | "gitlab.com/gitlab-org/labkit/log" 11 | ) 12 | 13 | var httpClient = &http.Client{ 14 | Transport: client.NewTransport(client.DefaultTransport()), 15 | } 16 | 17 | const ( 18 | repoUnavailableErrMsg = "Remote repository is unavailable" 19 | sshUploadPackPath = "/ssh-upload-pack" 20 | sshReceivePackPath = "/ssh-receive-pack" 21 | ) 22 | 23 | // Client represents a client for interacting with Git repositories. 24 | type Client struct { 25 | URL string 26 | Headers map[string]string 27 | } 28 | 29 | // InfoRefs retrieves information about the Git repository references. 30 | func (c *Client) InfoRefs(ctx context.Context, service string) (*http.Response, error) { 31 | request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.URL+"/info/refs?service="+service, nil) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | return c.do(request) 37 | } 38 | 39 | // ReceivePack sends a Git push request to the server. 40 | func (c *Client) ReceivePack(ctx context.Context, body io.Reader) (*http.Response, error) { 41 | request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL+"/git-receive-pack", body) 42 | if err != nil { 43 | return nil, err 44 | } 45 | request.Header.Add("Content-Type", "application/x-git-receive-pack-request") 46 | request.Header.Add("Accept", "application/x-git-receive-pack-result") 47 | 48 | return c.do(request) 49 | } 50 | 51 | // UploadPack sends a Git fetch request to the server. 52 | func (c *Client) UploadPack(ctx context.Context, body io.Reader) (*http.Response, error) { 53 | request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL+"/git-upload-pack", body) 54 | if err != nil { 55 | return nil, err 56 | } 57 | request.Header.Add("Content-Type", "application/x-git-upload-pack-request") 58 | request.Header.Add("Accept", "application/x-git-upload-pack-result") 59 | 60 | return c.do(request) 61 | } 62 | 63 | // SSHUploadPack sends a SSH Git fetch request to the server. 64 | func (c *Client) SSHUploadPack(ctx context.Context, body io.Reader) (*http.Response, error) { 65 | request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL+sshUploadPackPath, body) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | return c.do(request) 71 | } 72 | 73 | // SSHReceivePack sends a SSH Git push request to the server. 74 | func (c *Client) SSHReceivePack(ctx context.Context, body io.Reader) (*http.Response, error) { 75 | request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL+sshReceivePackPath, body) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | return c.do(request) 81 | } 82 | 83 | func (c *Client) do(request *http.Request) (*http.Response, error) { 84 | for k, v := range c.Headers { 85 | request.Header.Add(k, v) 86 | } 87 | 88 | response, err := httpClient.Do(request) 89 | if err != nil { 90 | return nil, &client.APIError{Msg: repoUnavailableErrMsg} 91 | } 92 | 93 | if response.StatusCode >= 400 { 94 | defer func() { 95 | if err := response.Body.Close(); err != nil { 96 | log.WithError(err).Error("Unable to close response body") 97 | } 98 | }() 99 | 100 | body, err := io.ReadAll(response.Body) 101 | if err != nil { 102 | return nil, &client.APIError{Msg: repoUnavailableErrMsg} 103 | } 104 | 105 | if len(body) > 0 { 106 | return nil, &client.APIError{Msg: string(body)} 107 | } 108 | 109 | return nil, &client.APIError{Msg: repoUnavailableErrMsg} 110 | } 111 | 112 | return response, nil 113 | } 114 | -------------------------------------------------------------------------------- /spec/gitlab_shell_personal_access_token_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | require 'json' 4 | require 'open3' 5 | require 'date' 6 | 7 | describe 'bin/gitlab-shell personal_access_token' do 8 | include_context 'gitlab shell' 9 | 10 | before(:context) do 11 | write_config("gitlab_url" => "http+unix://#{CGI.escape(tmp_socket_path)}") 12 | end 13 | 14 | def mock_server(server) 15 | server.mount_proc('/api/v4/internal/personal_access_token') do |req, res| 16 | params = JSON.parse(req.body) 17 | 18 | res.content_type = 'application/json' 19 | res.status = 200 20 | 21 | if params['key_id'] == '000' 22 | res.body = { success: false, message: "Something wrong!"}.to_json 23 | else 24 | res.body = { 25 | success: true, 26 | token: 'aAY1G3YPeemECgUvxuXY', 27 | scopes: params['scopes'], 28 | expires_at: params['expires_at'] 29 | }.to_json 30 | end 31 | end 32 | 33 | server.mount_proc('/api/v4/internal/discover') do |req, res| 34 | res.status = 200 35 | res.content_type = 'application/json' 36 | res.body = '{"id":100, "name": "Some User", "username": "someuser"}' 37 | end 38 | end 39 | 40 | describe 'command' do 41 | let(:key_id) { 'key-100' } 42 | 43 | let(:output) do 44 | env = { 45 | 'SSH_CONNECTION' => 'fake', 46 | 'SSH_ORIGINAL_COMMAND' => "personal_access_token #{args}" 47 | } 48 | Open3.popen2e(env, "#{gitlab_shell_path} #{key_id}")[1].read() 49 | end 50 | 51 | let(:help_message) do 52 | <<~OUTPUT 53 | remote: 54 | remote: ======================================================================== 55 | remote: 56 | remote: Usage: personal_access_token [ttl_days] 57 | remote: 58 | remote: ======================================================================== 59 | remote: 60 | OUTPUT 61 | end 62 | 63 | context 'without any arguments' do 64 | let(:args) { '' } 65 | 66 | it 'prints the help message' do 67 | expect(output).to eq(help_message) 68 | end 69 | end 70 | 71 | context 'with only the name argument' do 72 | let(:args) { 'newtoken' } 73 | 74 | it 'prints the help message' do 75 | expect(output).to eq(help_message) 76 | end 77 | end 78 | 79 | context 'without a ttl argument' do 80 | let(:args) { 'newtoken api' } 81 | 82 | it 'prints a token with a 30 day expiration date' do 83 | expect(output).to eq(<<~OUTPUT) 84 | Token: aAY1G3YPeemECgUvxuXY 85 | Scopes: api 86 | Expires: #{(Date.today + 30).iso8601} 87 | OUTPUT 88 | end 89 | end 90 | 91 | context 'with a ttl argument' do 92 | let(:args) { 'newtoken read_api,read_user 60' } 93 | 94 | it 'prints a token with an expiration date' do 95 | expect(output).to eq(<<~OUTPUT) 96 | Token: aAY1G3YPeemECgUvxuXY 97 | Scopes: read_api,read_user 98 | Expires: #{(Date.today + 61).iso8601} 99 | OUTPUT 100 | end 101 | end 102 | 103 | context 'with an API error response' do 104 | let(:args) { 'newtoken api' } 105 | let(:key_id) { 'key-000' } 106 | 107 | it 'prints the error response' do 108 | expect(output).to eq(<<~OUTPUT) 109 | remote: 110 | remote: ======================================================================== 111 | remote: 112 | remote: Something wrong! 113 | remote: 114 | remote: ======================================================================== 115 | remote: 116 | OUTPUT 117 | end 118 | end 119 | end 120 | end 121 | --------------------------------------------------------------------------------