frp SSH Tunnel Gateway:用标准 SSH 反向隧道替代 frpc 暴露内网服务
本文基于 frp 官方文档 SSH Tunnel Gateway 展开,讲解自 v0.53.0 引入的 SSH 隧道网关功能:如何仅通过一个 SSH 客户端命令(无需部署 frpc),利用 SSH 协议原生的 -R 反向隧道能力,将内网服务暴露到公网 frps 一侧,并深入剖析 pkg/ssh 目录下 Gateway 与 TunnelServer 的实现原理、认证机制与完整配置参数。
一、概念:什么是 SSH 反向隧道代理
SSH 协议本身支持反向代理能力(参考 RFC 4254 中 tcpip-forward 请求与 forwarded-tcpip 通道的定义)。frp 的 SSH Tunnel Gateway 利用这一点,让 frps 侧直接监听一个 SSH 端口,用户用系统自带的 ssh 客户端执行 -R 反向端口转发命令,即可在 frps 上完成 TCP 协议代理,而整个过程中不需要 frpc 参与。
需要特别区分两个容易混淆的概念:
- SSH 反向隧道代理(本文主题):不使用 frpc,由 SSH 客户端直连 frps,本质上是借助 SSH 协议完成的一次基础反向代理;
- 通过 frp 代理 SSH 端口:把一台机器上的 22 端口作为普通 TCP 代理转发出去,与本文功能完全不同。
从源码结构看,frps 在 server/service.go 启动时检查配置:只要 sshTunnelGateway.bindPort > 0,就会调用 ssh.NewGateway 创建网关并将日志打印为 frps sshTunnelGateway listen on port %d,随后把该 Gateway 的监听器挂入服务生命周期。对应的配置结构体定义在 pkg/config/v1/server.go 中:
type SSHTunnelGateway struct {
BindPort int `json:"bindPort,omitempty"`
PrivateKeyFile string `json:"privateKeyFile,omitempty"`
AutoGenPrivateKeyPath string `json:"autoGenPrivateKeyPath,omitempty"`
AuthorizedKeysFile string `json:"authorizedKeysFile,omitempty"`
}
func (c *SSHTunnelGateway) Complete() {
c.AutoGenPrivateKeyPath = util.EmptyOr(c.AutoGenPrivateKeyPath, "./.autogen_ssh_key")
}
Complete() 方法印证了文档中“autoGenPrivateKeyPath 默认为 ./.autogen_ssh_key”的说法——未显式配置时会自动补齐默认路径。
二、配置参数详解
在 frps.toml 中,SSH 隧道网关的完整配置形如:
# frps.toml
sshTunnelGateway.bindPort = 0
sshTunnelGateway.privateKeyFile = ""
sshTunnelGateway.autoGenPrivateKeyPath = ""
sshTunnelGateway.authorizedKeysFile = ""
| 字段 | 类型 | 说明 | 是否必填 |
|---|---|---|---|
bindPort |
int | frps 上 SSH 服务监听端口。 | 是 |
privateKeyFile |
string | 默认为空。SSH 服务端使用的私钥文件。为空时 frps 会读取 autoGenPrivateKeyPath 路径下的私钥文件。可以复用本机的 /home/user/.ssh/id_rsa,也可以指定自定义路径。 |
否 |
autoGenPrivateKeyPath |
string | 默认为 ./.autogen_ssh_key。若文件不存在或内容为空,frps 会自动生成 RSA 私钥并写入该文件。 |
否 |
authorizedKeysFile |
string | 默认为空。为空时 SSH 客户端不做认证;不为空时可实现 SSH 免密(公钥)登录认证。可复用本机 /home/user/.ssh/authorized_keys 或指定自定义路径。 |
否 |
私钥的加载逻辑在 pkg/ssh/gateway.go 的 NewGateway 中,优先级非常明确:
- 若
privateKeyFile非空,直接读取该文件; - 否则尝试读取
autoGenPrivateKeyPath下的内容; - 仍为空时,调用
transport.NewRandomPrivateKey()现场生成一把 RSA 私钥,并按0o600权限写回autoGenPrivateKeyPath; - 最终经
ssh.ParsePrivateKey解析后通过sshConfig.AddHostKey注册到 SSH 服务端。
这段实现解释了文档中的两条注意事项:其一,最小配置下 frps 会在当前工作目录自动生成 .autogen_ssh_key 文件用于加解密;其二,也可以指定 privateKeyFile 复用本机已有的 id_rsa。
认证开关的实现同样在 gateway.go 中:
sshConfig.NoClientAuth = cfg.AuthorizedKeysFile == ""
sshConfig.PublicKeyCallback = func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
authorizedKeysMap, err := loadAuthorizedKeysFromFile(cfg.AuthorizedKeysFile)
...
user, ok := authorizedKeysMap[string(key.Marshal())]
if !ok {
return nil, fmt.Errorf("unknown public key for remoteAddr %q", conn.RemoteAddr())
}
return &ssh.Permissions{
Extensions: map[string]string{"user": user},
}, nil
}
可以看出:authorizedKeysFile 为空时 NoClientAuth = true(完全不做客户端认证);不为空时每次握手都会重新解析 authorized_keys 文件,把公钥的 comment 字段作为用户名写入 Permissions.Extensions["user"],后续在 pkg/ssh/server.go 中会回填到客户端配置的 User 字段。
三、基本用法
3.1 服务端 frps 最小配置
sshTunnelGateway.bindPort = 2200
将以上配置放入 frps.toml,运行 ./frps -c frps.toml,frps 即在 2200 端口监听并接收 SSH 反向代理请求。注意事项:
- 最小配置下,frps 会在当前工作目录自动创建
.autogen_ssh_key私钥文件,SSH 服务端使用该私钥完成加解密;也可以选择复用本机已有私钥,如/home/user/.ssh/id_rsa。 - 最小配置模式下,SSH 连接 frps 时不需要任何认证。文档强烈建议同时为 frps 配置
token,并在 SSH 命令行中通过--token传入,作为 frp 层的访问控制。
3.2 客户端 SSH 命令格式
ssh -R :80:{local_ip:port} v0@{frps_address} -p {frps_ssh_listen_port} {tcp|http|https|stcp|tcpmux} --remote_port {real_remote_port} --proxy_name {proxy_name} --token {frp_token}
关键说明(与源码逐条对应):
--proxy_name可选,留空时自动生成随机名。对应 pkg/ssh/server.go 中parseClientAndProxyConfigurer的逻辑:未设置Name时生成sshtunnel-{type}-{8位随机ID}作为代理名;- 登录 frps 的用户名固定为
v0,目前没有实际意义; - 服务端代理监听端口由
--remote_port决定; {tcp|http|https|stcp|tcpmux}五种代理类型各自支持完整的命令行参数,可用--help查看。例如ssh -R :80::8080 v0@127.0.0.1 -p 2200 http --help。源码中该白名单为supportTypes := []string{"tcp", "http", "https", "tcpmux", "stcp"},传入其他类型会直接报错;- token 可选,但出于安全考虑强烈建议在 frps 中配置。
从源码结构看,这套命令行参数并非 SSH 隧道自研:frps 直接把 exec 请求里的 payload 交给一个内嵌的 cobra 命令解析,复用 pkg/config/flags.go 中 RegisterProxyFlags 与 RegisterClientCommonConfigFlags 注册的 frpc 同款 flag(以 WithSSHMode() 模式),因此 SSH 隧道模式下的 --help 输出与 frpc tcp --help 等保持一致。值得注意的是 SSH 模式会屏蔽 local_ip、local_port、server_addr 等 frpc 常规 flag——因为本地地址已经编码在 -R :80:127.0.0.1:8080 的转发目标里了。
3.3 TCP 代理
ssh -R :80:127.0.0.1:8080 v0@{frp_address} -p 2200 tcp --proxy_name "test-tcp" --remote_port 9090
该命令在 frps 上建立一个监听 9090 端口的代理,把流量转发到本机的 8080 端口服务。执行成功后的终端输出形如:
frp (via SSH) (Ctrl+C to quit)
User:
ProxyName: test-tcp
Type: tcp
RemoteAddress: :9090
这段输出由 pkg/ssh/terminal.go 的 createSuccessInfo 生成。该命令等价于:
frpc tcp --proxy_name "test-tcp" --local_ip 127.0.0.1 --local_port 8080 --remote_port 9090
更多参数可通过 --help 获取。
3.4 HTTP 代理
ssh -R :80:127.0.0.1:8080 v0@{frp_address} -p 2200 http --proxy_name "test-http" --custom_domain test-http.frps.com
等价于:
frpc http --proxy_name "test-http" --custom_domain test-http.frps.com
之后即可通过 curl 'http://test-http.frps.com' 访问该 HTTP 服务(需 frps 开启 vhostHTTPPort 并将 subDomainHost 配置为 frps.com,域名的拼接规则见 pkg/config/v1/server.go 中 SubDomainHost 字段的注释)。
3.5 HTTPS / STCP / TCPMUX 代理
获取对应类型的完整用法:
ssh -R :80:127.0.0.1:8080 v0@{frp_address} -p 2200 {https|stcp|tcpmux} --help
以 stcp 为例,实际等价命令需要携带 serverName(-n)、secretKey(--sk)等参数,例如 stcp -n stcp-test --sk=abcdefg --allow-users="*";tcpmux 则支持 --mux=httpconnect --custom_domain ... 形式。这些用法在 e2e 测试 test/e2e/v1/features/ssh_tunnel.go 中有完整覆盖,五个用例(tcp、http、https、tcpmux、stcp)分别验证了各代理类型经 SSH 隧道建立后,流量确实能从公网端口回环到本地 mock 服务。
四、进阶用法
4.1 复用本机 id_rsa 私钥
# frps.toml
sshTunnelGateway.bindPort = 2200
sshTunnelGateway.privateKeyFile = "/home/user/.ssh/id_rsa"
SSH 协议握手阶段双方交换公钥用于数据加密,因此 frps 侧的 SSH 服务端必须持有一把私钥。指定 privateKeyFile 可以直接复用本机已有私钥;该字段为空时 frps 会自动生成 RSA 私钥(写入 autoGenPrivateKeyPath)。
4.2 指定自动生成私钥的路径
# frps.toml
sshTunnelGateway.bindPort = 2200
sshTunnelGateway.autoGenPrivateKeyPath = "/var/frp/ssh-private-key-file"
frps 会自动创建私钥文件并保存至指定路径。
注意:更换 frps 使用的私钥文件可能导致 SSH 客户端登录失败(known_hosts 校验不通过)。若需要成功登录,可删除 /home/user/.ssh/known_hosts 中的旧记录。
4.3 使用既有 authorized_keys 做 SSH 公钥认证
# frps.toml
sshTunnelGateway.bindPort = 2200
sshTunnelGateway.authorizedKeysFile = "/home/user/.ssh/authorized_keys"
authorizedKeysFile 是 SSH 公钥认证使用的文件,包含用户的公钥信息,一行一把密钥。解析逻辑在 pkg/ssh/gateway.go 的 loadAuthorizedKeysFromFile 中,循环调用 ssh.ParseAuthorizedKey 直到文件读完。
若 authorizedKeysFile 为空,frps 不会对 SSH 客户端做任何认证;frps 不支持 SSH 用户名/密码认证,只支持公钥认证。
4.4 使用自定义 authorized_keys 文件
# frps.toml
sshTunnelGateway.bindPort = 2200
sshTunnelGateway.authorizedKeysFile = "/var/frps/custom_authorized_keys_file"
指定自定义 authorized_keys 文件路径即可。注意修改该文件内容可能导致认证失败,需要重新添加公钥信息。
4.5 双层认证的分工
文档特别强调了两层认证的独立性,这一点在源码中可以直接验证(pkg/ssh/server.go 中 virtual.NewClient 的 Spec 字段):
Spec: &msg.ClientSpec{
Type: "ssh-tunnel",
// If ssh does not require authentication, then the virtual client needs
// to authenticate through a token. Otherwise, once ssh authentication
// is passed, the virtual client does not need to authenticate again.
AlwaysAuthPass: !s.sc.NoClientAuth,
},
authorizedKeysFile作用于 SSH 登录阶段的用户认证;- frps 的
token作用于 frp 协议层的认证; - 两者相互独立、先 SSH 认证后 token 认证;
- 若启用了 SSH 公钥认证(
NoClientAuth == false),虚拟客户端会跳过 frp token 认证(AlwaysAuthPass = true);反之若不做 SSH 认证,则强烈建议至少启用 frps token 认证,避免任何人可匿名接入。
五、工作原理:frps 内部的虚拟客户端
理解该功能最有价值的部分,是 frps 如何在 SSH 会话内“无中生有”出一个完整的 frp 客户端。核心流程在 pkg/ssh/server.go 的 TunnelServer.Run 中:
- 完成 SSH 握手:
ssh.NewServerConn建立服务端会话; - 捕获反向转发地址与额外 payload:
waitForwardAddrAndExtraPayload并发监听两类消息——tcpip-forward全局请求(携带-R :80:127.0.0.1:8080中的目标地址)与首个 session 通道中exec请求的 payload(携带tcp --proxy_name ...等命令行参数),3 秒内收齐两者; - 解析命令行:
parseClientAndProxyConfigurer将 payload 拆分为参数,通过 cobra 解析为ClientCommonConfig+ 对应类型的ProxyConfigurer; - 构建虚拟客户端:调用 pkg/virtual/client.go 的
virtual.NewClient,它内部复用完整的client.Service(与 frpc 相同的服务框架),但把网络连接器替换为pipeConnector:控制连接不经过真实网络,而是通过net.Pipe()直接对端投递给 frps 内部的peerServerListener。也就是说,SSH 隧道在 frps 进程内部“扮演”了一个 frpc; - 接管业务流量:注册
HandleWorkConnCb回调,每当服务端要为某代理建立工作连接时,openConn通过forwarded-tcpip通道向 SSH 客户端发起反向连接(即把-R注册的:80端口打回内网),再用libio.Join把该 SSH 通道与 frp 工作连接拼接成一条双向管道,流量即完成公网用户 → frps 9090 → SSH 通道 → 内网 8080的全链路转发; - 状态回报:
waitProxyStatusReady每 100ms 轮询虚拟客户端的代理状态,直到 Running(输出成功信息)或 StartErr/Closed(把错误回写给 SSH 客户端);随后sshConn.Wait()保持会话,直到客户端 Ctrl+C 断开; - 保活:
keepAlive每 30 秒在首个 session 通道上发送heartbeat请求,防止空闲会话被中间设备掐断。
这套“进程内虚拟客户端 + 反向通道回拨”的设计,正是文档所说“本模式不依赖 frpc”的技术本质:frpc 的完整控制/代理逻辑被原样嵌入 frps 进程,仅将传输层换成了 SSH 通道。
六、e2e 测试中的可复制示例
test/e2e/v1/features/ssh_tunnel.go 展示了各代理类型的真实调用方式,其中 SSH 客户端由 test/e2e/pkg/ssh/client.go 的 TunnelClient 模拟,其行为与命令行 ssh -R 完全一致:Listen("tcp", "0.0.0.0:80") 注册反向端口,再 OpenChannel("session") 发送 exec 请求携带代理命令。tcp 用例配置如下:
sshTunnelGateway.bindPort = {sshPort}
客户端发起:
ssh.NewTunnelClient(
fmt.Sprintf("127.0.0.1:%d", localPort),
fmt.Sprintf("127.0.0.1:%d", sshPort),
fmt.Sprintf("tcp --remote-port %d", remotePort),
)
http 用例额外开启 vhostHTTPPort,tcpmux 用例额外开启 tcpmuxHTTPConnectPort 并验证了“不带 HTTP CONNECT 的请求会报错、CONNECT 主机名不匹配会报错、匹配则返回正确响应”的完整语义;stcp 用例则演示了与常规 frpc visitor 协作:SSH 隧道只负责注册 stcp 服务(stcp -n stcp-test --sk=abcdefg --allow-users="*"),访问仍由独立的 visitor 客户端完成。这些用例与本文第三、四节的手动命令行用法一一对应,可作为自动化验证 SSH 隧道是否工作正常的参考。
七、适用场景与限制小结
结合文档与源码,SSH Tunnel Gateway 的适用前提是:
- 客户端无需安装 frpc,只要系统自带 OpenSSH 客户端(如服务器、容器、移动端),适合临时、轻量地把内网服务暴露出去;
- 仅支持
tcp、http、https、stcp、tcpmux五种代理类型(pkg/ssh/server.go中的supportTypes白名单),不支持 udp 等其他类型; - 功能自 v0.53.0 起提供,使用前请确认 frps 版本;
- 安全上务必二选一(或同时启用):SSH 公钥认证(
authorizedKeysFile)或 frp token 认证;两者均为空时任何知道端口的人都可以匿名注册代理; - SSH 客户端侧若更换了 frps 私钥,需要清理
known_hosts中的旧主机指纹才能继续登录; - 登录用户名固定为
v0,无实际语义,不要据此做多租户区分;多用户区分应通过authorized_keys中每把公钥的 comment 实现(服务端会把 comment 作为User回填)。
掌握以上内容后,读者即可在不部署 frpc 的约束下,仅凭一条 ssh -R 命令完成内网服务暴露,并能在出现问题时顺着 pkg/ssh 与 pkg/virtual 的源码链路快速定位 SSH 握手、参数解析或虚拟客户端认证环节的原因。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00