手搓 Git 服务器(一):搭建一个简单的基于 SSH 的 Git 服务器

2024-08-13#Git#Go#SSH

Git 是目前最流行的源代码控制系统,它是由 Linus Torvalds 以及其他开发内核的人员为了在 Linux 内核开发中使用而创建的。如果深入了解 Git 的传输协议,那么就会感受到 Unix 哲学中的简单性。Git 的中文文档介绍了 《服务器上的 Git - 协议》英文文档),参考该文档,可以快速构建一个简单的 Git 服务器,并加入自定义的能力。

Git 支持基于 SSH 和 HTTP/HTTPS 的协议。本文介绍一种使用 Go 语言实现基于 SSH 的 Git 服务器的 智能协议 的方法。

Git 智能协议 🔗

Git 智能协议是一种用于在 Git 客户端和服务器之间进行高效通信的机制,“它需要在服务端运行一个进程,而这也是 Git 的智能之处——它可以读取本地数据,理解客户端有什么和需要什么,并为它生成合适的包文件。 总共有两组进程用于传输数据,它们分别负责上传和下载数据”。当 git 客户端程序与 Git 服务器交互时,会在服务器端运行对应的程序,进行数据交换。如下图所示:

Git server

上传数据 🔗

当 Git 客户端上传数据到 Git 服务器(比如 git push origin main)时,实际上会在客户端运行 send-pack 进程,它通过 SSH 连接 Git 服务器,然后调用服务器端的 git-receive-pack 程序,git-receive-pack“会立即为它所拥有的每一个引用发送一行响应”。然后, send-packgit-receive-pack 进行交互,完成文件上传。具体可见文档 “上传数据”一节。

下载数据 🔗

当 Git 客户端从 Git 服务器下载数据时,实际上会在客户端运行 fetch-pack,它通过 SSH 连接 Git 服务器,然后调用服务器端的 git-upload-pack 的程序,协商后续传输的数据,最终返回客户端下载到所需的数据。具体可见文档 “下载数据” 一节。

实现思路 🔗

当服务器安装了 Git 之后,那么机器上已经有了 git-receive-packgit-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-packgit-upload-pack 位于系统路径之中,那么一个极简的 Git 服务器已经就绪了。在本地电脑可通过如下步骤尝试:

  1. 确保有一个可以使用 SSH 登录服务器的用户,比如 foo
  2. 在服务器的文件系统上,创建一个裸仓库。比如在 /tmp/repositories/bar 创建仓库:
mkdir -p /tmp/repositories/bar
cd /tmp/repositories/bar
git init --bare
  1. 找到任意的一个本地仓库,假定其包含 main 分支。确保本机的 SSH 服务器已经开启并允许访问,那么可以将它推送到 /tmp/repositories/bar 仓库中:
git remote add local localhost:/tmp/repositories/bar/
git push local main
  1. 如果想测试拉取仓库,那么在新的目录克隆仓库:
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-packgit-upload-pack 程序与 Git 客户端程序交互之外,还应该能够添加复杂的逻辑。

技术选型 🔗

SSH 服务器 🔗

对于大部分编程语言,都有开源的 SSH 相关的库,有些比较底层,有些进行了高层的抽象。选择一个使用起来顺手的,并且足够安全的库,然后编写集成代码,即可手搓一个 Git 服务器。Go 语言有一个开源的 SSH 软件包 gliderlabs/ssh 。它封装了标准库里的 crypto/ssh ,提供了构建 SSH 服务器的高层接口。

命令行处理 🔗

对于客户端发送给SSH服务器的命令,需要进行解析后使用。判断其调用了 git-receive-packgit-upload-pack,还是其它。尽管可以自行解析,但是这不一定是简单的任务。可使用第三方库google/shlex解析

进程间通行 🔗

当 Git 服务器接收到 git-receive-packgit-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-receiveupdatepost-receive

结尾 🔗

得益于 Git 的实现哲学,搭建或者开发基于 SSH 的 Git 服务器,本质上是构建 SSH 服务器,然后在服务器端调用相关的命令行程序。


加载中...