feat: support owner-specific backend routing
目标:让 git clone git@example.com:owner/repo.git 进入 OpenResty 的 stream 入口后,转发到一个基于 libssh 的 SSH gateway,由 gateway 解析 Git SSH 请求并转发到真实 Git 后端。
git clone git@example.com:owner/repo.git
stream
这个仓库包含一个可编译的 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 后端。
exec
--backend
--repo-root
git-upload-pack
git-receive-pack
git-upload-archive
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
./scripts/gen-host-key.sh ./ssh_host_ed25519_key
本地模式:
./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 临时覆盖:
--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。
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,可以使用自定义 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 client | | TCP/22 v OpenResty stream | | TCP/2222 v libssh git gateway | | SSH or local git-shell v real git backend
openresty/nginx.conf:
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; } }
gateway 是一个 SSH server,不是普通 TCP proxy。核心流程:
ssh_bind_new
127.0.0.1:2222
session
做权限校验和仓库路由。
转发到真实后端,二选一:
只允许 Git 白名单命令:
git-upload-pack git-receive-pack git-upload-archive
仓库路径必须规范化,避免命令注入和路径穿越:
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
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:
splice
client channel stdout/stderr <-> backend channel stdout/stderr client channel stdin <-> backend channel stdin
需要处理:
ssh_channel_send_eof
不推荐。OpenResty stream_lua 可以处理 TCP,但 libssh 是完整 SSH 协议栈,包含握手、认证、channel、窗口、加密状态机。把它同步阻塞地放进 nginx worker 会卡 worker;写成 nginx C 模块又要处理事件模型、内存池、生命周期和非阻塞 libssh,复杂度远高于独立 gateway。
stream_lua
更稳的边界是:
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。
版权所有:中国计算机学会技术支持:开源发展技术委员会 京ICP备13000930号-9 京公网安备 11010802047560号
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:
demo 支持两种后端模式:
也就是说,libssh gateway 会终止 SSH、解析 Git
exec命令,然后按仓库路径里的 owner 做 hash。如果没有配置--backend,就在--repo-root目录下执行本机的git-upload-pack、git-receive-pack或git-upload-archive;如果配置了--backend,就把请求转发到选中的 Git SSH 后端。依赖
macOS:
Ubuntu/Debian:
编译
准备测试仓库
生成 SSH host key
启动 gateway
本地模式:
分布式 SSH 后端模式,按 owner hash 到 3 台 Git server:
可以为特定 owner 配置固定后端。固定配置优先于 hash:
也可以放到配置文件里:
owner map 文件格式:
如果同一个 owner 配置多次,后面的配置覆盖前面的配置。这样可以先加载文件,再用
--owner-backend临时覆盖:路由规则:
例如:
如果后端 Git SSH 需要指定私钥:
直接连 gateway 测试:
经过 OpenResty 测试:
本 demo 为方便本地验证,客户端到 gateway 的认证层接受任意 publickey/password。生产环境必须改成真实的 key 校验、用户映射、仓库 ACL 和审计。gateway 到后端 Git SSH 的认证依赖系统
ssh,建议使用专用部署密钥和受控的known_hosts。分布式存储设计
你的场景中有 3 个 Git server:
仓库按 owner 拆分:
gateway 解析 Git SSH command:
然后只对
owner做 hash,而不是对完整 repo 做 hash。这样同一个 owner 下的所有仓库都会在同一台 Git server 上,便于权限、配额、备份和迁移。对于需要迁移、灰度或手工固定位置的 owner,可以使用自定义 owner 配置:
命中自定义配置时不会再走 hash;未命中时继续走 owner hash。
OpenResty 不能直接完成这个 hash 路由,因为 owner 在 SSH 加密通道内部。OpenResty 的职责是:
libssh gateway 的职责是:
结论
如果只是透明 TCP 转发,OpenResty
stream已经足够,不需要 libssh:如果需要按仓库、用户、租户、权限、审计或灰度路由,就必须终止 SSH。原因是 Git SSH 请求在 SSH 加密通道内,OpenResty
stream的 preread 阶段最多只能看到 SSH banner,例如:它看不到后续的:
所以推荐架构是:
OpenResty 配置
openresty/nginx.conf:libssh gateway 要做什么
gateway 是一个 SSH server,不是普通 TCP proxy。核心流程:
ssh_bind_new监听127.0.0.1:2222。sessionchannel。exec请求,解析命令:做权限校验和仓库路由。
转发到真实后端,二选一:
exec命令,然后双向转发 channel。git-upload-pack或git-receive-pack,把 SSH channel stdin/stdout/stderr 接到子进程。Git SSH 命令解析规则
只允许 Git 白名单命令:
仓库路径必须规范化,避免命令注入和路径穿越:
建议解析后统一成:
拒绝这些输入:
推荐的 gateway 伪代码
双向转发注意事项
SSH channel 不是裸 socket,不能直接
splice。需要循环读取两边 channel:需要处理:
ssh_channel_send_eof后继续读另一侧剩余数据。为什么不把 libssh 直接塞进 OpenResty Lua
不推荐。OpenResty
stream_lua可以处理 TCP,但 libssh 是完整 SSH 协议栈,包含握手、认证、channel、窗口、加密状态机。把它同步阻塞地放进 nginx worker 会卡 worker;写成 nginx C 模块又要处理事件模型、内存池、生命周期和非阻塞 libssh,复杂度远高于独立 gateway。更稳的边界是:
测试命令
本地调试 gateway:
经过 OpenResty:
如果客户端报 host key changed,说明现在 SSH 终止点换成了 gateway,需要更新 known_hosts。