目录

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-packgit-receive-packgit-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。核心流程:

  1. ssh_bind_new 监听 127.0.0.1:2222
  2. 加载 host key,让客户端信任的是 gateway。
  3. 完成 SSH key exchange。
  4. 处理认证:publickey/password/token 均可,生产环境建议 publickey。
  5. 接收 session channel。
  6. 读取 exec 请求,解析命令:
git-upload-pack 'owner/repo.git'
git-receive-pack 'owner/repo.git'
git-upload-archive 'owner/repo.git'
  1. 做权限校验和仓库路由。

  2. 转发到真实后端,二选一:

    • 后端也是 SSH:gateway 再作为 libssh client 连接后端,发同样的 exec 命令,然后双向转发 channel。
    • 后端是本机仓库:直接 fork/exec git-upload-packgit-receive-pack,把 SSH channel stdin/stdout/stderr 接到子进程。

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。

关于
47.0 KB
邀请码
    Gitlink(确实开源)
  • 加入我们
  • 官网邮箱:gitlink@ccf.org.cn
  • QQ群
  • QQ群
  • 公众号
  • 公众号

版权所有:中国计算机学会技术支持:开源发展技术委员会
京ICP备13000930号-9 京公网安备 11010802047560号