|
|
||
|---|---|---|
| config | ||
| openresty | ||
| scripts | ||
| src | ||
| .gitignore | ||
| CMakeLists.txt | ||
| README.md | ||
README.md
OpenResty stream + libssh Git SSH Gateway Demo
目标:让 git clone git@example.com:owner/repo.git 进入 OpenResty 的 stream 入口后,转发到一个基于 libssh 的 SSH gateway,由 gateway 解析 Git SSH 请求并转发到真实 Git 后端。
这个仓库包含一个可编译的 C/CMake demo:
CMakeLists.txt
src/git_ssh_gateway.c
openresty/nginx.conf
scripts/gen-host-key.sh
demo 支持两种后端模式:
本地模式:
git client
|
| SSH
v
OpenResty stream
|
| TCP
v
libssh gateway
|
| fork/exec
v
local git-upload-pack / git-receive-pack
分布式 SSH 后端模式:
git client
|
| SSH
v
OpenResty stream
|
| TCP
v
libssh gateway
|
| owner hash route
v
server1:2221 / server2:2222 / server3:2223
也就是说,libssh gateway 会终止 SSH、解析 Git exec 命令,然后按仓库路径里的 owner 做 hash。如果没有配置 --backend,就在 --repo-root 目录下执行本机的 git-upload-pack、git-receive-pack 或 git-upload-archive;如果配置了 --backend,就把请求转发到选中的 Git SSH 后端。
依赖
macOS:
brew install cmake pkg-config libssh
Ubuntu/Debian:
sudo apt-get update
sudo apt-get install -y build-essential cmake pkg-config libssh-dev git openssh-client
编译
cmake -S . -B build
cmake --build build
准备测试仓库
mkdir -p repositories/owner
git init --bare repositories/owner/repo.git
生成 SSH host key
./scripts/gen-host-key.sh ./ssh_host_ed25519_key
启动 gateway
本地模式:
./build/git-ssh-gateway \
--bind 127.0.0.1 \
--port 2222 \
--repo-root ./repositories \
--host-key ./ssh_host_ed25519_key
分布式 SSH 后端模式,按 owner hash 到 3 台 Git server:
./build/git-ssh-gateway \
--bind 127.0.0.1 \
--port 2200 \
--host-key ./ssh_host_ed25519_key \
--backend-user git \
--backend server1:2221 \
--backend server2:2222 \
--backend server3:2223
可以为特定 owner 配置固定后端。固定配置优先于 hash:
./build/git-ssh-gateway \
--bind 127.0.0.1 \
--port 2200 \
--host-key ./ssh_host_ed25519_key \
--backend-user git \
--backend server1:2221 \
--backend server2:2222 \
--backend server3:2223 \
--owner-backend alice=server1:2221 \
--owner-backend bob=server2:2222
也可以放到配置文件里:
./build/git-ssh-gateway \
--bind 127.0.0.1 \
--port 2200 \
--host-key ./ssh_host_ed25519_key \
--backend-user git \
--backend server1:2221 \
--backend server2:2222 \
--backend server3:2223 \
--owner-map ./config/owner-map.example
owner map 文件格式:
# owner=host:port
alice=server1:2221
bob=server2:2222
carol=server3:2223
如果同一个 owner 配置多次,后面的配置覆盖前面的配置。这样可以先加载文件,再用 --owner-backend 临时覆盖:
./build/git-ssh-gateway \
--bind 127.0.0.1 \
--port 2200 \
--host-key ./ssh_host_ed25519_key \
--backend-user git \
--backend server1:2221 \
--backend server2:2222 \
--backend server3:2223 \
--owner-map ./config/owner-map.example \
--owner-backend alice=server3:2223
路由规则:
if owner in owner map:
backend = configured owner backend
else:
hash = FNV-1a(owner)
backend = backends[hash % backend_count]
例如:
git clone git@git.example.com:alice/repo.git
owner = alice
alice 固定落到同一个 server
如果后端 Git SSH 需要指定私钥:
./build/git-ssh-gateway \
--bind 127.0.0.1 \
--port 2200 \
--host-key ./ssh_host_ed25519_key \
--backend-user git \
--backend-identity /etc/git-gateway/backend_id_ed25519 \
--backend server1:2221 \
--backend server2:2222 \
--backend server3:2223
直接连 gateway 测试:
GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2200" \
git clone git@127.0.0.1:owner/repo.git
经过 OpenResty 测试:
openresty -p "$PWD" -c openresty/nginx.conf
GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2220" \
git clone git@127.0.0.1:owner/repo.git
本 demo 为方便本地验证,客户端到 gateway 的认证层接受任意 publickey/password。生产环境必须改成真实的 key 校验、用户映射、仓库 ACL 和审计。gateway 到后端 Git SSH 的认证依赖系统 ssh,建议使用专用部署密钥和受控的 known_hosts。
分布式存储设计
你的场景中有 3 个 Git server:
server1:2221
server2:2222
server3:2223
仓库按 owner 拆分:
owner/repo.git
gateway 解析 Git SSH command:
git-upload-pack 'owner/repo.git'
git-receive-pack 'owner/repo.git'
git-upload-archive 'owner/repo.git'
然后只对 owner 做 hash,而不是对完整 repo 做 hash。这样同一个 owner 下的所有仓库都会在同一台 Git server 上,便于权限、配额、备份和迁移。
对于需要迁移、灰度或手工固定位置的 owner,可以使用自定义 owner 配置:
alice=server1:2221
bob=server2:2222
命中自定义配置时不会再走 hash;未命中时继续走 owner hash。
OpenResty 不能直接完成这个 hash 路由,因为 owner 在 SSH 加密通道内部。OpenResty 的职责是:
监听 22 / 2220
连接限流
TCP keepalive
日志
proxy_pass 到 libssh gateway
libssh gateway 的职责是:
终止 SSH
解析 git exec command
提取 owner
owner hash 选后端
通过 SSH 转发到 server1/server2/server3
结论
如果只是透明 TCP 转发,OpenResty stream 已经足够,不需要 libssh:
stream {
upstream git_ssh_backend {
server 127.0.0.1:2222;
}
server {
listen 22;
proxy_connect_timeout 5s;
proxy_timeout 1h;
proxy_pass git_ssh_backend;
}
}
如果需要按仓库、用户、租户、权限、审计或灰度路由,就必须终止 SSH。原因是 Git SSH 请求在 SSH 加密通道内,OpenResty stream 的 preread 阶段最多只能看到 SSH banner,例如:
SSH-2.0-OpenSSH_9.x
它看不到后续的:
git-upload-pack 'owner/repo.git'
git-receive-pack 'owner/repo.git'
git-upload-archive 'owner/repo.git'
所以推荐架构是:
git client
|
| TCP/22
v
OpenResty stream
|
| TCP/2222
v
libssh git gateway
|
| SSH or local git-shell
v
real git backend
OpenResty 配置
openresty/nginx.conf:
worker_processes auto;
events {
worker_connections 4096;
}
stream {
log_format ssh_log '$remote_addr [$time_local] '
'$protocol $status $bytes_sent $bytes_received '
'$session_time';
access_log logs/git-ssh-access.log ssh_log;
error_log logs/git-ssh-error.log info;
limit_conn_zone $binary_remote_addr zone=ssh_ip:10m;
upstream libssh_git_gateway {
server 127.0.0.1:2222 max_fails=3 fail_timeout=10s;
keepalive 128;
}
server {
listen 22 reuseport;
proxy_connect_timeout 3s;
proxy_timeout 1h;
proxy_socket_keepalive on;
limit_conn ssh_ip 20;
proxy_pass libssh_git_gateway;
}
}
libssh gateway 要做什么
gateway 是一个 SSH server,不是普通 TCP proxy。核心流程:
ssh_bind_new监听127.0.0.1:2222。- 加载 host key,让客户端信任的是 gateway。
- 完成 SSH key exchange。
- 处理认证:publickey/password/token 均可,生产环境建议 publickey。
- 接收
sessionchannel。 - 读取
exec请求,解析命令:
git-upload-pack 'owner/repo.git'
git-receive-pack 'owner/repo.git'
git-upload-archive 'owner/repo.git'
-
做权限校验和仓库路由。
-
转发到真实后端,二选一:
- 后端也是 SSH:gateway 再作为 libssh client 连接后端,发同样的
exec命令,然后双向转发 channel。 - 后端是本机仓库:直接 fork/exec
git-upload-pack或git-receive-pack,把 SSH channel stdin/stdout/stderr 接到子进程。
- 后端也是 SSH:gateway 再作为 libssh client 连接后端,发同样的
Git SSH 命令解析规则
只允许 Git 白名单命令:
git-upload-pack
git-receive-pack
git-upload-archive
仓库路径必须规范化,避免命令注入和路径穿越:
owner/repo.git
/owner/repo.git
'owner/repo.git'
"owner/repo.git"
建议解析后统一成:
owner/repo.git
拒绝这些输入:
../../etc/passwd
owner/repo.git; id
owner/repo.git && id
owner/../repo.git
推荐的 gateway 伪代码
ssh_bind bind = ssh_bind_new();
ssh_bind_options_set(bind, SSH_BIND_OPTIONS_BINDADDR, "127.0.0.1");
ssh_bind_options_set(bind, SSH_BIND_OPTIONS_BINDPORT_STR, "2222");
ssh_bind_options_set(bind, SSH_BIND_OPTIONS_RSAKEY, "/etc/git-gateway/ssh_host_rsa_key");
ssh_bind_listen(bind);
for (;;) {
ssh_session client = ssh_new();
ssh_bind_accept(bind, client);
if (ssh_handle_key_exchange(client) != SSH_OK) {
ssh_disconnect(client);
ssh_free(client);
continue;
}
authenticate_client_publickey(client);
ssh_channel chan = accept_session_channel(client);
char *exec = read_exec_request(client);
struct git_request req = parse_git_exec(exec);
authorize(req.user, req.repo, req.operation);
ssh_session backend = connect_backend(req);
ssh_channel backend_chan = open_backend_exec(backend, exec);
pump_bidirectional(chan, backend_chan);
ssh_channel_close(backend_chan);
ssh_channel_close(chan);
ssh_disconnect(backend);
ssh_disconnect(client);
}
双向转发注意事项
SSH channel 不是裸 socket,不能直接 splice。需要循环读取两边 channel:
client channel stdout/stderr <-> backend channel stdout/stderr
client channel stdin <-> backend channel stdin
需要处理:
- EOF:一侧
ssh_channel_send_eof后继续读另一侧剩余数据。 - stderr:Git 会通过 sideband 和 stderr 传进度信息,不能吞掉。
- exit status:后端 exit status 要回传给客户端。
- backpressure:不要一次性读入内存,按 16KB 或 32KB chunk 转发。
- 超时:clone 大仓库可能很久,读写超时要比 HTTP 长。
为什么不把 libssh 直接塞进 OpenResty Lua
不推荐。OpenResty stream_lua 可以处理 TCP,但 libssh 是完整 SSH 协议栈,包含握手、认证、channel、窗口、加密状态机。把它同步阻塞地放进 nginx worker 会卡 worker;写成 nginx C 模块又要处理事件模型、内存池、生命周期和非阻塞 libssh,复杂度远高于独立 gateway。
更稳的边界是:
OpenResty = 四层入口、限流、日志、连接保护
libssh gateway = SSH 协议终止、认证、仓库路由、审计
Git backend = 实际仓库读写
测试命令
本地调试 gateway:
ssh -vvv -p 2222 git@127.0.0.1 "git-upload-pack 'owner/repo.git'"
经过 OpenResty:
GIT_SSH_COMMAND="ssh -vvv -p 22" git clone git@127.0.0.1:owner/repo.git
如果客户端报 host key changed,说明现在 SSH 终止点换成了 gateway,需要更新 known_hosts。