GitLab Shell如何通过SSH工作

5 mins to read

GitLab访问Git仓库

首先回顾GitLab的Git仓库四种访问方式:

  1. git pull over http -> gitlab-rails (Authorization) -> accept or decline -> execute git command
  2. git push over http -> gitlab-rails (git command is not executed yet) -> execute git command -> gitlab-shell pre-receive hook -> API call to gitlab-rails (authorization) -> accept or decline push
  3. git pull over ssh -> gitlab-shell -> API call to gitlab-rails (Authorization) -> accept or decline -> execute git command
  4. git push over ssh -> gitlab-shell (git command is not executed yet) -> execute git command -> gitlab-shell pre-receive hook -> API call to gitlab-rails (authorization) -> accept or decline push

四种方式都有GitLab Shell的参与,但不同过程GitLab Shell发挥了不同的作用,并且它并不是一个整体的服务,而是由一些子命令组合而成。HTTP方式的Git操作,经gitlab workhorse直接交由Rails应用处理,然后通过HTTP协议交换数据,对于git的操作有三条路径:Gem包Rugged、Raw Git命令或者Gitaly,push/pull一般只跟后两种有关,GitLab Shell充当的作用仅仅是git hook的作用。

SSH方式的push/pull是GitLab Shell的主场景,而Rails在这其中充当了权限再验证的角色。

Git SSH 传输协议

首先,简要说明Git是如何通过SSH协议与服务端的Git交互数据。

ssh git@example.com "the-command"

在客户端执行这样的命令时候,服务端SSHD验证身份通过后,默认将启动一个Shell解析执行the-command的命令。普通使用中,大多都不加自定义命令,这将启动一个Shell交互式命令解析器。

要了解GitLab Shell的原理,不能不说它的“前任”——Gitolite。Gitolite是一个Git的授权层前端,同样提供了HTTP(httpd)和SSH的方式访问Git仓库。而Gitlab在v5.0完全用GitLab Shell替代了Gitolite,前者完全依赖Rails层的权限认证(项目、分支、用户等的权限),后者则由于需要完全保持一份冗余数据在自身的配置文件,主要由于速度和数据不同步被Gitlab官方放弃。但是,二者仍然采用了 authorized_keyscommand magic方案。

GitLab CE 10.4之后加入了`AuthorizedKeysCommand`的使用(_require_ OpenSSH 6.9+),使用自定义程序匹配Key而不是文件文本匹配。

SSH command magic

SSHD服务端收到客户端的连接请求后,会在authorized_keys进行匹配,认证失败则拒绝连接。查看~/.ssh/authorized_keys文件内容如下:

# Gitolite
command="[path]/gitolite-shell user-one",[more options] ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEArXtCT...
# GitLab Shell
command="/home/git/gitlab-shell/bin/gitlab-shell key-1",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEArXtCT...

可以发现保存的每条公钥前面都有command=,Gitolite和GitLab Shell后面的参数代表着用户的识别信息,Gitolite保存在配置文件,而gitlab保存在数据库。当用户的服务端校验成功后,则会执行command=后的命令,而不是shell access,同时将SSH命令所要执行的命令赋值给SSH_ORIGINAL_COMMAND。所以与客户端交互的真正命令是(以pull代码为例):

SSH_ORIGINAL_COMMAND=git-upload-pack git-pack/home/git/gitlab-shell/bin/gitlab-shell key-1

“Shell”

GitLab Shell由ruby脚本和go程序组成,首先看GitLab Shell入口代码:bin/gitlab-shell

#!/usr/bin/env ruby
# ...
key_id = /key-[0-9]+/.match(ARGV.join).to_s
original_cmd = ENV.delete('SSH_ORIGINAL_COMMAND')
# ...
require File.join(ROOT_PATH, 'lib', 'gitlab_shell')
if GitlabShell.new(key_id).exec(original_cmd)
# ...

这段代码里面的两个变量:

  • key_id -> sshd调用GitLab Shell时传入的参数。
  • original_cmd -> 即上文提到的SSH_ORIGINAL_COMMAND环境变量,并且获取完即移除。

举个例子,如果用户通过git客户端调用git clone git@server的时候,实际上git客户端启动的是receive-pack(git由许许多多子命令组成)并且在内部执行的是ssh git@git@server git-upload-pack git@server,那么此时,服务端给GitLab Shell设定的环境变量则是git-upload-pack git@server,。

然后看lib/gitlab-shell.rb代码,初始化一个GitlabShell,并且执行exec,而exec里面有几个关键步骤:

def exec(origin_cmd)
  unless origin_cmd
    puts "Welcome to GitLab, #{username}!"
    return true
  end

  args = Shellwords.shellwords(origin_cmd)
  args = parse_cmd(args)

  if GIT_COMMANDS.include?(args.first)
    GitlabMetrics.measure('verify-access') { verify_access }
  end

  process_cmd(args)

  true
rescue something #异常
end
1. 解析命令 parse_cmd

主要是处理Windows/Linux命令差异、屏蔽非法命令、LFS命令。

2. 验证权限 verify_access
/api/v4/internal/allowed

通过Rails的HTTP API :/api/v4/internal/allowed发送查询参数到Rails,接口返回此用户对这个仓库是否有此操作的权限。

参数:

{
  command => git命令
  repo => 仓库信息
  key_id => SSH key id(在数据库能找查找到对应的用户)
  protocol => ssh/env
}
3. 处理命令 process_cmd

处理命令,检测gitaly此特性是否有开启,如果开启则调用gitaly处理,否则则调用git原生命令。

设定环境变量,然后使用Kernel.exec调用目标进程替代当前进程。

env = {
    'HOME' => ENV['HOME'],
    'PATH' => ENV['PATH'],
    'LD_LIBRARY_PATH' => ENV['LD_LIBRARY_PATH'],
    'LANG' => ENV['LANG'],
    'GL_ID' => @key_id,
    'GL_PROTOCOL' => GL_PROTOCOL,
    'GL_REPOSITORY' => @gl_repository,
    'GL_USERNAME' => @username
}
# ...
Kernel.exec(env, *args, unsetenv_others: true, chdir: ROOT_PATH)

处理Git命令

以Pull,且调用Gitaly为例

// bin/gitaly-upload-pack
code, err := handler.UploadPack(os.Args[1], &request)
// go/internal/handler/upload_pack.go
func UploadPack(gitalyAddress string, request *pb.SSHUploadPackRequest) (int32, error) {
    # ...

	conn, err := client.Dial(gitalyAddress, dialOpts())
	if err != nil {
		return 0, err
	}
	defer conn.Close()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	return client.UploadPack(ctx, conn, os.Stdin, os.Stdout, os.Stderr, request)
}
// go/vendor/gitlab.com/gitlab-org/gitaly/client/upload_pack.go
func UploadPack(ctx context.Context, conn *grpc.ClientConn, stdin io.Reader, stdout, stderr io.Writer, req *pb.SSHUploadPackRequest) (int32, error) {
	ctx2, cancel := context.WithCancel(ctx)
	defer cancel()

	ssh := pb.NewSSHServiceClient(conn)
	stream, err := ssh.SSHUploadPack(ctx2)
	if err != nil {
		return 0, err
	}

	if err = stream.Send(req); err != nil {
		return 0, err
	}

	inWriter := streamio.NewWriter(func(p []byte) error {
		return stream.Send(&pb.SSHUploadPackRequest{Stdin: p})
	})

	return streamHandler(func() (stdoutStderrResponse, error) {
		return smHandler(func() (stdoutStderrResponse, error) {
		return stream.Recv()
	}, func(errC chan error) {
		_, errRecv := io.Copy(inWriter, stdin)
		stream.CloseSend()
		errC <- errRecv
	}, stdout, stderr)
}

可以看到通过grpc跟gitaly server通信,获得响应之后:

# go/vendor/gitlab.com/gitlab-org/gitaly/client/std_stream.go
exited code => resp.GetExitStatus().GetValue()
stderr => stderr.Write(resp.GetStderr())
stdout => stdout.Write(resp.GetStdout())

git客户端将标准错误打印到控制台,解析标准输出作为git数据:通过远程ssh调用命令将数据打印到标准输出传输到客户端解析

Cloning into 'gitlab-shell'...
remote: Counting objects: 5558, done.
remote: Compressing objects: 100% (2548/2548), done.
remote: Total 5558 (delta 3051), reused 5117 (delta 2754)
Receiving objects: 100% (5558/5558), 2.83 MiB | 2.72 MiB/s, done.
Resolving deltas: 100% (3051/3051), done.

Refs