手搓 Git 服务器(一):搭建一个简单的基于 SSH 的 Git 服务器
Git 是目前最流行的源代码控制系统,它是由 Linus Torvalds 以及其他开发内核的人员为了在 Linux 内核开发中使用而创建的。如果深入了解 Git 的传输协议,那么就会感受到 Unix 哲学中的简单性。Git 的中文文档介绍了 《服务器上的 Git - 协议》(英文文档),参考该文档,可以快速构建一个简单的 Git 服务器,并加入自定义的能力。
Git 支持基于 SSH 和 HTTP/HTTPS 的协议。本文介绍一种使用 Go 语言实现基于 SSH 的 Git 服务器的 智能协议 的方法。
Git 智能协议 🔗
Git 智能协议是一种用于在 Git 客户端和服务器之间进行高效通信的机制,“它需要在服务端运行一个进程,而这也是 Git 的智能之处——它可以读取本地数据,理解客户端有什么和需要什么,并为它生成合适的包文件。 总共有两组进程用于传输数据,它们分别负责上传和下载数据”。当 git
客户端程序与 Git 服务器交互时,会在服务器端运行对应的程序,进行数据交换。如下图所示:
上传数据 🔗
当 Git 客户端上传数据到 Git 服务器(比如 git push origin main
)时,实际上会在客户端运行 send-pack
进程,它通过 SSH 连接 Git 服务器,然后调用服务器端的 git-receive-pack
程序,git-receive-pack
“会立即为它所拥有的每一个引用发送一行响应”。然后, send-pack
和 git-receive-pack
进行交互,完成文件上传。具体可见文档 “上传数据”一节。
下载数据 🔗
当 Git 客户端从 Git 服务器下载数据时,实际上会在客户端运行 fetch-pack
,它通过 SSH 连接 Git 服务器,然后调用服务器端的 git-upload-pack
的程序,协商后续传输的数据,最终返回客户端下载到所需的数据。具体可见文档 “下载数据” 一节。
实现思路 🔗
当服务器安装了 Git 之后,那么机器上已经有了 git-receive-pack
和 git-upload-pack
。比如,在 macOS 上使用 Homebrew 后,可以看到 Git 程序所包含的可执行程序:
$ ls -1 /usr/local/Cellar/git/2.46.0/bin
git
git-cvsserver
git-receive-pack
git-shell
git-upload-archive
git-upload-pack
scalar
因此,在这种情况下,只需要在文件系统上创建裸仓库,并对外提供 SSH 服务,那么就可以实现 Git 服务器。
使用 SSH 服务器实现极简的 Git 服务器 🔗
Linux 服务器本身就提供了 SSH 服务,只需要安装 Git,确保 git-receive-pack
和 git-upload-pack
位于系统路径之中,那么一个极简的 Git 服务器已经就绪了。在本地电脑可通过如下步骤尝试:
- 确保有一个可以使用 SSH 登录服务器的用户,比如
foo
。 - 在服务器的文件系统上,创建一个裸仓库。比如在
/tmp/repositories/bar
创建仓库:
mkdir -p /tmp/repositories/bar
cd /tmp/repositories/bar
git init --bare
- 找到任意的一个本地仓库,假定其包含
main
分支。确保本机的 SSH 服务器已经开启并允许访问,那么可以将它推送到/tmp/repositories/bar
仓库中:
git remote add local localhost:/tmp/repositories/bar/
git push local main
- 如果想测试拉取仓库,那么在新的目录克隆仓库:
mkdir -p /tmp/repositories/
cd /tmp/repositories
git clone localhost:/tmp/repositories/bar/ bar2
如果一切顺利,那么 /tmp/repositories/bar
就是服务器端的仓库,/tmp/repositories/bar2
是克隆到客户端的仓库。
这就是一个极简的 Git 服务器。如果仅仅是用于代码的上传下载,这样的服务器已经够用了。但是,在实际中,如果仅仅是为了搭建 Git 服务器而开发 SSH 服务器的所有功能和权限,那不太满足“最小特权”原则;另外,真实的 Git 的服务器可能还需要一些其他的周边功能,比如用户管理、权限、日志等。那么,使用服务器上的原生 SSH 服务器就显得过于简陋了。
通过编程实现 Git 服务器 🔗
对于复杂的场景,需要通过编程语言实现 Git 服务器。具体地说,实现一个 SSH 服务,除了调用 git-receive-pack
和 git-upload-pack
程序与 Git 客户端程序交互之外,还应该能够添加复杂的逻辑。
技术选型 🔗
SSH 服务器 🔗
对于大部分编程语言,都有开源的 SSH 相关的库,有些比较底层,有些进行了高层的抽象。选择一个使用起来顺手的,并且足够安全的库,然后编写集成代码,即可手搓一个 Git 服务器。Go 语言有一个开源的 SSH 软件包 gliderlabs/ssh 。它封装了标准库里的 crypto/ssh ,提供了构建 SSH 服务器的高层接口。
命令行处理 🔗
对于客户端发送给SSH服务器的命令,需要进行解析后使用。判断其调用了 git-receive-pack
、git-upload-pack
,还是其它。尽管可以自行解析,但是这不一定是简单的任务。可使用第三方库google/shlex解析
进程间通行 🔗
当 Git 服务器接收到 git-receive-pack
和 git-upload-pack
命令时,需要调用相应的程序,然后与客户端交互。为此,可使用标准库里的 os/exec
调用命令行程序,将其标准输入与标准输出与客户端连接。
示例 🔗
代码示例 🔗
以下是一个简单的 Git 服务器的示例。它通过 SSH 进行认证,然后在接收到命令和客户端上传数据后,打印了一些信息:
package main
import (
"fmt"
"log"
"os"
"os/exec"
"github.com/gliderlabs/ssh"
"github.com/google/shlex"
)
var GIT_RECEIVE_PACK_CMD = "git-receive-pack"
var GIT_UPLOAD_PACK_CMD = "git-upload-pack"
type SshOut struct {
s ssh.Session
}
type SshIn struct {
session ssh.Session
}
func (writer SshOut) Write(data []byte) (int, error) {
return writer.s.Write(data)
}
func (reader SshIn) Read(data []byte) (int, error) {
return reader.session.Read(data)
}
func runGitReceivePackCmd(session ssh.Session, repoPath string) error {
cmd := exec.Command(GIT_RECEIVE_PACK_CMD, repoPath)
sshOut := SshOut{s: session}
cmd.Stdout = sshOut
cmd.Stderr = session.Stderr()
cmd.Stdin = SshIn{session: session}
return cmd.Run()
}
func runGitUploadPackCmd(session ssh.Session, repoPath string) error {
cmd := exec.Command(GIT_UPLOAD_PACK_CMD, repoPath)
sshOut := SshOut{s: session}
cmd.Stdout = sshOut
cmd.Stderr = session.Stderr()
cmd.Stdin = SshIn{session: session}
return cmd.Run()
}
func main() {
ssh.Handle(func(s ssh.Session) {
fmt.Println("ssh raw command:", s.RawCommand())
args, err := shlex.Split(s.RawCommand())
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
return
}
if args[0] == GIT_RECEIVE_PACK_CMD {
if err := runGitReceivePackCmd(s, args[1]); err != nil {
fmt.Fprintln(os.Stderr, err.Error())
}
} else if args[0] == GIT_UPLOAD_PACK_CMD {
if err := runGitUploadPackCmd(s, args[1]); err != nil {
fmt.Fprintln(os.Stderr, err.Error())
}
}
})
log.Fatal(ssh.ListenAndServe(":2222", nil, ssh.HostKeyFile(".ssh/id_ed25519")))
}
测试运行 🔗
将认证公钥放在 .ssh/id_ed25519
目录后运行程序:
go run main.go
克隆项目 🔗
使用 git
命令克隆项目:
git clone ssh://localhost:2222/tmp/repositories/bar bar3
此时 Git 服务器打印出了 git
客户端发送的原始命令:
ssh raw command: git-upload-pack '/tmp/repositories/bar'
推送代码 🔗
在代码库中添加文件,然后推送到 Git 服务器:
git push origin main
此时 Git 服务器打印的 SSH 原始命令和返回给客户端的数据如下:
ssh raw command: git-receive-pack '/tmp/repositories/bar'
拉取代码 🔗
当使用 git remote update
更新远程分支列表,或者使用 git fetch
从远程仓库获取最新的变更时,Git 服务器处理的是下载数据的指令,打印出的 SSH 原始命令都是:
ssh raw command: git-upload-pack '/tmp/repositories/bar'
加入复杂逻辑 🔗
既然可以通过编程的方式实现一个 SSH 服务器,那么就可以在代码中加入复杂逻辑了:
- 用户的权限认证,可以使用 gliderlabs/ssh 的相关处理器
- 对于代码操作的 Hook,可以参考 Server-Side Hooks 中的说明,包括
pre-receive
、update
、post-receive
等
结尾 🔗
得益于 Git 的实现哲学,搭建或者开发基于 SSH 的 Git 服务器,本质上是构建 SSH 服务器,然后在服务器端调用相关的命令行程序。