feat(agent): 默认回环监听并自动建立 SSH 隧道并发布 v1.4.0

This commit is contained in:
2026-09-28 13:42:21 +08:00
parent 49d4af27ca
commit 1bec91cc5e
28 changed files with 1493 additions and 88 deletions
+21
View File
@@ -2,6 +2,27 @@
All notable changes to this project will be documented in this file.
## [v1.4.0] - 2026-09-28
### 新增
- **Agent 默认监听回环**:`fonu-agent` 默认改为 `127.0.0.1:7700`;监听非回环地址必须显式设置 `FONU_AGENT_ALLOW_REMOTE=1`,状态接口新增监听地址、监听范围和传输安全字段
- **SSH 隧道自动接入**:中央 Fonu 安装或升级 Agent 后自动分配 `17700-18700` 范围内的本地回环端口,通过现有 SSH 凭据建立隧道访问 VPS 的 `127.0.0.1:7700`,无需新增公网端口、证书或防火墙规则
- **隧道生命周期管理**:应用启动后自动恢复 tunnel transport;应用退出时统一关闭;每个服务端支持单独的「重启隧道」操作
- **旧公网 HTTP 自动迁移**:旧 Agent 点击「升级 Agent」后安装脚本会备份旧 `agent.env`、改写为回环监听并自动建立隧道;隧道验证失败时恢复旧监听配置
### 变更
- 数据库新增 Agent transport / 本地端口 / TLS pin / 监听地址 / 连接状态 / 连接错误字段(迁移 028)
- 前端「公网 Agent」页显示传输方式、隧道状态、本地入口和 Agent 监听地址;旧公网 HTTP 显示迁移警告
- 版本号提升到 1.4.0;`web/package.json` 与 `web/package-lock.json` 同步对齐
- `AgentVersion` 0.12.9 → **0.13.0**,内嵌 amd64 / arm64 二进制已重编;VPS 上需升级 Agent
### 测试
- `internal/agent`:回环默认值与远程监听保护
- `internal/frp` / `internal/app`:隧道生命周期、transport 存储与启动恢复编译验证
## [v1.3.9] - 2026-09-28
### 修复
+1 -1
View File
@@ -1 +1 @@
1.3.9
1.4.0
+13 -2
View File
@@ -7,13 +7,15 @@ import (
"log"
"os"
"os/signal"
"strings"
"syscall"
"github.com/fonu/fonu/internal/agent"
)
func main() {
listen := flag.String("listen", envOr("FONU_AGENT_LISTEN", ":7700"), "agent listen address")
listen := flag.String("listen", envOr("FONU_AGENT_LISTEN", "127.0.0.1:7700"), "agent listen address")
allowRemote := flag.Bool("allow-remote", envBool("FONU_AGENT_ALLOW_REMOTE"), "allow non-loopback listen address")
token := flag.String("token", os.Getenv("FONU_AGENT_TOKEN"), "agent bearer token")
nginxDir := flag.String("nginx-conf-dir", envOr("FONU_AGENT_NGINX_CONF_DIR", ""), "managed nginx config directory(留空自动探测 Nginx 实际加载的 vhost 目录)")
certsDir := flag.String("certs-dir", envOr("FONU_AGENT_CERTS_DIR", "/var/lib/fonu-agent/certs"), "certificate directory")
@@ -30,7 +32,7 @@ func main() {
log.Fatal("FONU_AGENT_TOKEN or -token is required")
}
server, err := agent.NewServer(agent.Config{
ListenAddr: *listen, Token: *token, NginxConfDir: *nginxDir,
ListenAddr: *listen, AllowRemote: *allowRemote, Token: *token, NginxConfDir: *nginxDir,
CertsDir: *certsDir, FrpsConfigPath: *frpsConfig, FrpsBinary: *frpsBin, DataDir: *dataDir,
})
if err != nil {
@@ -49,3 +51,12 @@ func envOr(key, fallback string) string {
}
return fallback
}
func envBool(key string) bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
+2 -1
View File
@@ -1,7 +1,7 @@
# Fonu 文档索引
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
> 最近更新:2026-09-28(v1.3.9)
> 最近更新:2026-09-28(v1.4.0)
## 方案与设计(本目录)
@@ -10,6 +10,7 @@
| `project-analysis-2026-09-27.md` | 全项目详细分析(v1.2.19):架构、模块、数据、API、前端、链路、风险与改进路线 | 当前版本快照 |
| `improvement-roadmap-2026-09-27.md` | P0-P3 改进路线实施方案:逐项改法、测试、验收、依赖、发布与回滚 | P0-1、P0-3、P0-4 已完成,其余待实施 |
| `system-upgrade.md` | Docker Compose 一键升级:sidecar 架构、首次部署、安全边界、回滚、故障排查与验证页 | 已实施(v1.3.8) |
| `agent-listen-strategy-plan.md` | Agent 默认监听策略:回环监听、SSH 隧道优先、HTTPS pin 可选与自动迁移方案 | 核心自动隧道已实施(v1.4.0);HTTPS pin 高级模式待后续 |
| `project-audit-2026-09-24.md` | 全项目审查:P0/P1/P2 分级,含精确行号与逐条修法 | **进行中**——P0 已随 v1.2.7 完成,P1 实施中 |
| `frp-server-list-merge-plan.md` | FRP 服务端列表并入「公网服务端」页(方案 A,四步) | 步 1/2 已完成(v1.2.6);步 3 文案收尾、步 4 死样式清理未做 |
| `agent-capability-plan.md` | fonu-agent 能力扩展(P0/P1/P2/P3) | 已实施(agent 0.11.0);P3 三项不做 |
+755
View File
@@ -0,0 +1,755 @@
# Agent 默认监听策略与自动安全接入方案
> 方案编号:P0-5
> 制定日期:2026-09-28
> 当前 AgentVersion:0.13.0
> 目标:默认不暴露 Agent 管理接口,安装和升级过程自动建立安全连接,不要求用户手工配置端口、隧道或证书
> 实施状态(2026-09-28):阶段 1-3 的自动 SSH 隧道核心已随 v1.4.0 实施;HTTPS + pin 高级模式与防火墙辅助仍按阶段 4-5 推进。
---
## 一、结论
推荐采用**自动为主、安全优先、旧模式显式兼容**的三层策略:
1. **默认模式:SSH 隧道 + 回环监听**
- `fonu-agent` 默认只监听 `127.0.0.1:7700`。
- 中央 Fonu 使用现有 SSH 凭据建立本地转发:
```text
中央 Fonu 127.0.0.1:<随机本地端口>
→ SSH 加密连接
→ VPS 127.0.0.1:7700
→ fonu-agent
```
- 不开放公网 Agent 端口,不需要域名、证书、云安全组或防火墙修改。
2. **可选模式:自动 HTTPS 反代 + 证书固定**
- Agent 仍只监听回环地址。
- HTTPS 不作为首次安装选项;Agent 安全安装完成后,用户可在「公网 Agent」页切换到该高级模式。
- 切换时自动配置 Nginx HTTPS 反代,并使用自签名证书。
- 中央 Fonu 首次安装时记录证书指纹,后续按指纹校验。
- 适用于希望免维护常驻 SSH 隧道、且愿意开放一个 HTTPS 端口的用户。
3. **兼容模式:显式公网明文 HTTP**
- 保留旧部署能力,但必须显式开启。
- UI 持续显示高危警告。
- 不允许自动降级到该模式。
默认安装与升级过程不需要用户设置 `FONU_AGENT_LISTEN`、TLS 证书或防火墙规则。
---
## 二、现状
### 2.1 Agent 监听
当前入口:
```go
listenv := flag.String("listen", envOr("FONU_AGENT_LISTEN", ":7700"), ...)
```
`internal/agent.NewServer` 空配置时同样回落到:
```go
cfg.ListenAddr = ":7700"
```
`:7700` 等价于监听所有 IPv4 / IPv6 网卡。
### 2.2 传输安全
当前 Agent 使用明文 HTTP:
- Bearer Token 在网络上直接传输;
- Token 已采用常量时间比较;
- Agent 默认没有 TLS;
- 安装脚本打印:
```text
API: http://<VPS-IP>:7700
```
### 2.3 中央 Fonu 连接
安装完成后,`AgentInstall` 写入:
```go
AgentURL = fmt.Sprintf("http://%s:%d", server.SSHHost, 7700)
```
中央 Fonu 后续直接访问 VPS 公网 IP 的 7700 端口。
### 2.4 当前问题
- 用户需要额外放行或确认 7700;
- 如果防火墙忘记放行,Agent 显示不可达;
- 如果放行,Token 暴露在明文网络中;
- 部署工具和容器环境可能改变监听行为;
- “默认安全”没有形成产品行为;
- 安全策略主要依赖用户理解和手工操作。
---
## 三、设计目标
### 3.1 必须实现
1. 新安装默认不监听公网网卡。
2. 安装过程自动建立中央 Fonu 到 Agent 的安全连接。
3. 不需要用户手工配置网络或证书。
4. Agent 升级时可自动迁移旧监听配置。
5. 旧部署仍可显式使用公网 HTTP。
6. 传输失败时给出可执行的诊断信息。
7. 中央 Fonu 重启后可自动恢复连接。
8. Agent 与中央 Fonu 的版本兼容策略保持清晰。
### 3.2 非目标
- 不尝试自动修改云厂商安全组;
- 不要求用户申请域名或公网 CA 证书;
- 不引入多租户权限模型;
- 不把 Agent 管理 API 暴露给家庭用户的普通浏览器;
- 不提供一个“自动回退到明文 HTTP”的危险路径。
---
## 四、方案对比
### 4.1 方案 A:公网 HTTP + Bearer Token(现状)
| 项目 | 评价 |
| --- | --- |
| 自动化 | 高 |
| 安全性 | 低 |
| 防火墙 | 需要放行 7700 |
| 证书 | 不需要 |
| 复杂度 | 低 |
| 主要风险 | Token 明文、端口暴露、被动扫描 |
结论:只保留为显式兼容模式,不再作为默认。
### 4.2 方案 B:SSH 隧道 + Agent 回环监听(推荐默认)
| 项目 | 评价 |
| --- | --- |
| 自动化 | 很高 |
| 安全性 | 高 |
| 防火墙 | 不需要新增端口 |
| 证书 | 不需要 |
| 复杂度 | 中高,需要隧道生命周期管理 |
| 依赖 | SSH 可达;本功能安装 Agent 时已经要求 SSH |
| 主要优势 | 不暴露端口、不依赖域名和证书 |
结论:作为默认模式。
### 4.3 方案 C:Nginx HTTPS + 自签名证书固定
| 项目 | 评价 |
| --- | --- |
| 自动化 | 高 |
| 安全性 | 高 |
| 防火墙 | 需要放行一个 HTTPS 端口 |
| 证书 | 自动生成和固定,不需要公网 CA |
| 复杂度 | 中 |
| 主要优势 | 中央 Fonu 可直接连接,不依赖常驻 SSH |
| 主要限制 | 云安全组仍需用户放行端口 |
结论:作为可选直连模式。
### 4.4 方案 D:公网域名 + ACME 证书
| 项目 | 评价 |
| --- | --- |
| 自动化 | 中 |
| 安全性 | 高 |
| 防火墙 | 需要 443 或 DNS-01 |
| 证书 | 需要域名、DNS 与 CA |
| 复杂度 | 高 |
| 用户成本 | 高 |
结论:不作为默认。可作为未来面向高级用户的能力。
---
## 五、推荐架构
### 5.1 默认数据流
```mermaid
flowchart LR
Web[Fonu Web] --> API[Fonu 后端]
API --> Tunnel[SSH 隧道管理器]
Tunnel -->|127.0.0.1:自动分配端口| SSH[SSH 加密连接]
SSH --> VPSLoopback[VPS 127.0.0.1:7700]
VPSLoopback --> Agent[fonu-agent]
```
### 5.2 角色职责
| 组件 | 职责 |
| --- | --- |
| Fonu 后端 | 决定 transport、启动隧道、调用 AgentClient |
| 隧道管理器 | 建立 SSH 本地转发、保持连接、重连、健康检查 |
| fonu-agent | 默认只监听回环,不再负责公网暴露 |
| 安装脚本 | 写入 `FONU_AGENT_LISTEN=127.0.0.1:7700`,禁止默认可公开 |
| UI | 显示安全模式、连接状态、可选迁移和显式旧模式警告 |
### 5.3 连接抽象
中央 Fonu 不再把“公网 IP + 7700”当作 Agent 的唯一定义。新增 transport 概念:
```text
tunnel SSH 本地转发,默认
https-pin Nginx HTTPS + 证书指纹固定,可选
http 公网明文,仅显式兼容
```
`AgentClient` 仍使用普通 HTTP 客户端接口,但 baseURL 由 transport 管理:
- tunnel:`http://127.0.0.1:<localPort>`
- https-pin:`https://<host>:<port>`
- http:`http://<host>:7700`
---
## 六、默认模式:SSH 隧道
### 6.1 为什么默认走 SSH
安装 Agent 已经必须使用 SSH,因此:
- 不新增用户操作;
- 不需要公网开放 7700;
- 不需要证书和域名;
- 不依赖云安全组修改;
- SSH 主机密钥已经使用 TOFU 校验;
- Token 继续保留,形成双层认证。
### 6.2 本地端口分配
每个 VPS 服务端分配一个稳定的本地回环端口:
```text
127.0.0.1:<agent_local_port>
```
分配方式建议:
1. 首次安装时在 `frp_servers.agent_local_port` 中保存;
2. 可在 `17700-18700` 范围内分配;
3. 分配时检查:
- 未被其他服务端占用;
- 未被本机其他 SSH 隧道占用;
- 可以正常绑定。
4. 保存后不再变化,便于重启恢复。
### 6.3 隧道管理器
新增:
```text
internal/frp/agent_tunnel.go
```
核心能力:
- `Ensure(serverID)`:确保隧道已启动;
- `Stop(serverID)`:停止隧道;
- `Restart(serverID)`:重建;
- `State(serverID)`:返回 connected / reconnecting / failed;
- 应用退出时统一关闭。
实现建议:
- 使用现有 `golang.org/x/crypto/ssh`;
- 复用 `SSHInstaller` 的 known_hosts 校验;
- SSH 连接使用 keepalive;
- 本地监听使用 `127.0.0.1`,不绑定 `0.0.0.0`;
- 连接断开后指数退避重连;
- 每个服务端最多一个隧道实例;
- 同一 `AgentClient` 复用 HTTP 连接。
### 6.4 健康检查
隧道建立后执行:
1. 本地端口可以连接;
2. `GET /api/v1/ping` 返回 200;
3. Bearer Token 验证通过;
4. `GET /api/v1/status` 返回 Agent 版本。
只有四项都通过,才把 transport 标记为 connected。
### 6.5 连接状态
前端需要显示:
```text
传输方式:SSH 隧道
本地端口:127.0.0.1:177xx
Agent 地址:127.0.0.1:7700(VPS 回环)
隧道状态:已连接 / 重连中 / 失败
最后错误
```
---
## 七、Agent 默认监听与防误开
### 7.1 默认值
修改:
```go
const defaultListenAddr = "127.0.0.1:7700"
```
以下位置同步:
- `cmd/fonu-agent/main.go`
- `internal/agent.NewServer`
- `scripts/agent-install.sh`
- `internal/frp/agent_installer.go` 内嵌安装脚本
### 7.2 远程监听保护
如果监听地址不是回环:
```text
0.0.0.0:7700
:7700
[::]:7700
VPS公网IP:7700
```
则必须显式设置:
```dotenv
FONU_AGENT_ALLOW_REMOTE=1
```
否则 Agent 拒绝启动并输出:
```text
检测到 Agent 将监听非回环地址,但未显式允许远程访问。
默认安全模式只监听 127.0.0.1:7700。
如确认需要公网明文访问,请设置 FONU_AGENT_ALLOW_REMOTE=1。
```
### 7.3 旧配置迁移
旧版本 `agent.env` 可能没有 `FONU_AGENT_LISTEN`,此时:
1. 新 Agent 默认监听回环;
2. 安装脚本显式写入:
```dotenv
FONU_AGENT_LISTEN=127.0.0.1:7700
```
3. 中央 Fonu 在执行「升级 Agent」时同步建立隧道;
4. 升级完成后切换 transport;
5. 旧公网地址从 `agent_url` 中移除;
6. 不停留手工维护窗口。
### 7.4 状态接口
Agent `/api/v1/status` 新增:
```json
{
"listen_addr": "127.0.0.1:7700",
"listen_scope": "loopback",
"remote_access_enabled": false,
"transport_security": "loopback"
}
```
---
## 八、自动安装流程
### 8.1 新安装
1. 用户填写 VPS SSH 信息。
2. 中央 Fonu 通过 SSH 探测系统与 Nginx。
3. 分配 `agent_local_port`。
4. 上传 Agent 二进制。
5. 安装脚本写入:
- Token;
- `FONU_AGENT_LISTEN=127.0.0.1:7700`;
- systemd 服务。
6. 启动 Agent。
7. 通过 SSH 本地转发建立隧道。
8. 调用:
- `GET /api/v1/ping`
- `GET /api/v1/status`
9. 保存:
- token 密文;
- transport=`tunnel`;
- local port;
- AgentVersion。
10. UI 显示“安全模式已启用”。
### 8.2 自动迁移旧安装
1. 检测现有 `agent_url` 是否为 `http://公网IP:7700`。
2. 显示“可升级到安全模式”,但不强制立即迁移。
3. 用户点击「升级 Agent」后:
- 安装新二进制;
- 重写 `agent.env` 为回环监听;
- 重启服务;
- 建立隧道;
- 验证连通;
- 切换 central transport。
4. 只有隧道验证成功后,才关闭旧公网连接。
5. 失败时保留旧 Agent 配置和旧连接,自动回滚。
### 8.3 应用重启恢复
1. 读取启用管理且 transport=tunnel 的服务端。
2. 逐个调用 `Tunnel.Ensure`。
3. 启动失败不阻塞整个 Fonu 启动。
4. UI 显示重连状态。
5. 后台按指数退避继续重试。
---
## 九、可选模式:HTTPS + 证书固定
### 9.1 适用场景
- 不希望长期维护 SSH 隧道;
- 可以接受开放一个 HTTPS 端口;
- 没有域名或不想申请公网证书;
- 希望 Agent API 可被中央 Fonu直接访问。
### 9.2 安装后切换流程
首次安装默认仍使用 SSH 隧道。只有用户在「公网 Agent」页明确切换到 HTTPS 直连时,才执行以下步骤:
1. Agent 仍监听 `127.0.0.1:7700`。
2. 切换流程生成自签名证书:
```text
/etc/fonu-agent/tls/agent.crt
/etc/fonu-agent/tls/agent.key
```
3. 自动寻找可用端口:
- 优先 7443;
- 其次 8443、9443;
- 若 443 空闲且未占用可提供选项,但不默认抢占。
4. 生成独立 Nginx server block:
```nginx
server {
listen 7443 ssl;
ssl_certificate /etc/fonu-agent/tls/agent.crt;
ssl_certificate_key /etc/fonu-agent/tls/agent.key;
location /api/v1/ {
proxy_pass http://127.0.0.1:7700;
}
}
```
5. 返回证书 SHA-256 指纹。
6. 中央 Fonu 保存 pin,并使用自定义校验。
### 9.3 TLS 固定
不能使用:
```go
InsecureSkipVerify: true
```
应实现:
```go
VerifyPeerCertificate(rawCerts [][]byte, _ [][]*x509.Certificate) error {
// 计算证书 DER 的 SHA-256
// 与数据库中保存的 pin 做常量时间比较
// 同时校验有效期与 SAN(如可校验)
}
```
首次安装必须通过 SSH 获取指纹,避免首次连接被中间人截获。
### 9.4 防火墙
首版不自动修改主机防火墙。阶段 4 之后,在用户明确确认时可以辅助生成并执行主机防火墙命令:
- ufw;
- firewalld;
- nftables / iptables。
云厂商安全组始终不能自动修改。UI 必须提示:
```text
请在云控制台放行 TCP 7443。主机防火墙需要你确认后由 Fonu 执行,Fonu 不会修改云安全组。
```
---
## 十、兼容模式:显式公网 HTTP
仅用于旧部署迁移或用户明确要求:
```dotenv
FONU_AGENT_LISTEN=:7700
FONU_AGENT_ALLOW_REMOTE=1
```
中央 Fonu UI 必须:
- 显示“公网明文传输”红色状态;
- 不在新安装中默认选择;
- 不支持由自动模式静默回退;
- 显示迁移到 SSH 隧道或 HTTPS 的按钮;
- 在变更时记录审计事件。
旧 transport 允许读取和继续连接,但所有新增安装默认使用 tunnel。
---
## 十一、数据模型变更
建议新增迁移:
```sql
ALTER TABLE frp_servers ADD COLUMN agent_transport TEXT NOT NULL DEFAULT 'http';
ALTER TABLE frp_servers ADD COLUMN agent_local_port INTEGER NOT NULL DEFAULT 0;
ALTER TABLE frp_servers ADD COLUMN agent_tls_pin TEXT NOT NULL DEFAULT '';
ALTER TABLE frp_servers ADD COLUMN agent_listen_addr TEXT NOT NULL DEFAULT '';
ALTER TABLE frp_servers ADD COLUMN agent_transport_state TEXT NOT NULL DEFAULT 'unknown';
ALTER TABLE frp_servers ADD COLUMN agent_transport_error TEXT NOT NULL DEFAULT '';
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `agent_transport` | `tunnel` / `https-pin` / `http` |
| `agent_local_port` | 隧道本地回环端口 |
| `agent_tls_pin` | HTTPS 证书 DER SHA-256 |
| `agent_listen_addr` | Agent 实际监听地址缓存 |
| `agent_transport_state` | `connected` / `reconnecting` / `failed` / `unknown` |
| `agent_transport_error` | 最近一次连接错误 |
### 11.1 兼容迁移
旧数据:
```text
agent_transport = http
```
新安装:
```text
agent_transport = tunnel
```
---
## 十二、API 与前端变更
### 12.1 API
`FrpServer` JSON 增加:
```json
{
"agent_transport": "tunnel",
"agent_transport_state": "connected",
"agent_local_port": 17701,
"agent_listen_addr": "127.0.0.1:7700",
"agent_transport_error": ""
}
```
新增管理端点:
| 方法 | 路径 | 作用 |
| --- | --- | --- |
| POST | `/api/frpmulti/servers/{id}/agent/transport` | 切换 tunnel / https-pin / http |
| POST | `/api/frpmulti/servers/{id}/agent/tunnel/restart` | 重启隧道 |
| GET | `/api/frpmulti/servers/{id}/agent/transport` | 读取详细状态 |
### 12.2 前端
「公网 Agent」页新增传输模式卡:
- SSH 隧道,默认;
- HTTPS + 证书固定,可选;
- 公网 HTTP 明文,兼容。
显示:
```text
传输方式:SSH 隧道
隧道状态:已连接
本地入口:127.0.0.1:17701
Agent 监听:127.0.0.1:7700
安全等级:安全
```
旧模式显示:
```text
公网明文 HTTP
风险:Token 会通过网络明文传输
建议迁移到 SSH 隧道
```
---
## 十三、自动回滚
迁移或切换 transport 失败时:
1. 保留旧 transport 与旧 Agent 配置;
2. 不覆盖旧 Token;
3. 不删除旧公网端口配置;
4. 如果已经重写 `agent.env`,恢复旧文件;
5. 如果 Agent 已重启但新连接失败,重新启动旧服务;
6. 向 UI 返回完整错误和回滚结果。
任何自动迁移都必须满足:
```text
新连接验证成功
→ 才提交数据库 transport
→ 才停止旧监听
```
---
## 十四、测试计划
### 14.1 单元测试
- 默认监听地址是回环;
- 非回环监听缺少 `FONU_AGENT_ALLOW_REMOTE` 时拒绝启动;
- 回环监听无需额外变量;
- `agent_transport` 序列化与旧数据兼容;
- 隧道本地端口分配、冲突与释放;
- 隧道重连、退避和关闭;
- 证书 pin 正确、证书变化、pin 不匹配时拒绝;
- 旧 HTTP transport 继续可用并产生警告。
### 14.2 集成测试
- 在测试 SSH 服务上建立本地转发;
- Agent 只监听 127.0.0.1;
- 应用重启后隧道自动恢复;
- SSH 断开后重连;
- 多服务端并发隧道;
- 迁移前失败回滚;
- 迁移成功后旧公网端口关闭。
### 14.3 真机验证
至少覆盖:
- Debian / Ubuntu / CentOS;
- fnOS 中的 Fonu;
- 宝塔 Nginx;
- 已存在 80 / 443 / 7443 占用的 VPS;
- 云安全组只开放 SSH 的 VPS;
- 云安全组开放 7443 的可选 HTTPS 模式。
### 14.4 验收条件
- 新安装后 `ss -tlnp` 只能看到 Agent 监听 `127.0.0.1:7700`;
- 公网扫描不到 7700;
- 无需新增云安全组规则;
- UI 显示隧道已连接;
- Fonu 重启后自动恢复;
- 「升级 Agent」可以自动迁移旧配置;
- 失败时不会丢失旧连接。
---
## 十五、实施阶段
### 阶段 1:默认回环与防误开
- Agent 默认监听改为 `127.0.0.1:7700`;
- 非回环监听要求 `FONU_AGENT_ALLOW_REMOTE=1`;
- 安装脚本显式写入监听地址;
- UI 显示监听范围。
该阶段暂不自动切换中央连接,避免破坏旧部署。
### 阶段 2:SSH 隧道管理器
- 新增数据库字段;
- 新增隧道管理器和 AgentClient transport;
- 安装后自动建立隧道;
- 应用重启恢复;
- UI 显示 transport 状态。
### 阶段 3:自动迁移旧 Agent
- 检测旧公网 HTTP;
- 「升级 Agent」时执行安全迁移;
- 成功切换后关闭旧入口;
- 失败自动回滚。
### 阶段 4:可选 HTTPS + pin
- 自动生成证书;
- Nginx server block;
- TLS pin;
- 防火墙辅助;
- UI 可选切换。
### 阶段 5:收紧默认
- 新安装不再接受公网 HTTP;
- 兼容模式需要明确的高风险确认;
- 文档和通知引导迁移。
---
## 十六、决策结论
以下决策已于 2026-09-28 确定,后续实施按此执行。
| 编号 | 决策 | 执行设定 | 理由 |
| --- | --- | --- | --- |
| 1 | SSH 隧道作为唯一默认模式 | 首次安装不提供模式选择,统一安装为回环监听 + SSH 隧道;HTTPS 直连只作为安装后的高级切换项 | 现有安装流程已经依赖 SSH;默认路径必须最少输入、最少网络要求 |
| 2 | 隧道本地端口使用固定范围 | 固定为 `17700-18700`,绑定 `127.0.0.1`;分配结果持久化;启动时先复用持久化端口,再在范围内顺序查找空闲端口;范围耗尽时明确报错,不使用临时端口 | 固定范围便于排查和审计;持久化避免每次重启变化;不回退临时端口避免地址漂移 |
| 3 | 隧道跟随主应用生命周期 | 使用应用内 `agent_tunnel` 管理器,在 Fonu 启动后按服务端恢复;应用退出时统一关闭;断线后指数退避重连;首版不新增独立 systemd 服务 | 当前项目是单进程控制面,调度器、FRP、监控均在同一生命周期内,新增守护进程会扩大部署复杂度 |
| 4 | 公网 HTTP 保留整个 1.x 兼容期 | 自 `1.4.0` 起不再允许新安装选择 HTTP;存量连接继续可用但显示高危警告并提供迁移;计划在 `2.0.0` 移除 | 需要给旧 Agent 和环境留出迁移窗口,同时从版本边界上明确技术债的截止点 |
| 5 | HTTPS 自签名证书使用短期自动轮换 | ECDSA P-256,有效期 365 天;剩余 30 天时自动轮换;轮换期间同时接受旧 pin 和新 pin,双 pin 窗口 7 天;优先通过现有 SSH 通道获取新指纹后再切换 | 避免长期证书失管;双 pin 防止轮换瞬间把所有中央连接锁死 |
| 6 | 首版不自动改主机防火墙 | 首先只检测、展示和提示;SSH 隧道模式本身不需要放行新端口;HTTPS 高级模式后续再提供显式的“一键放行主机防火墙”操作,并始终不修改云厂商安全组 | 防火墙和云安全组属于高风险外部状态;首版先把安全默认值落地,避免自动变更导致 SSH 或现有服务失联 |
### 16.1 决策对实施阶段的影响
- 阶段 1 只做 Agent 默认回环和防误开。
- 阶段 2 实现 `17700-18700` 的持久化端口分配与应用内隧道管理器。
- 阶段 3 完成旧 Agent 自动迁移;迁移失败必须回到旧 transport。
- 阶段 4 增加 HTTPS + pin 的高级模式、证书轮换和双 pin 窗口。
- `1.4.0` 起前端不再允许新建 HTTP transport。
- `2.0.0` 删除 `http` transport 和旧 Agent 兼容分支。
- 防火墙变更仅在阶段 4 之后按用户明确操作执行,首版不实现自动写规则。
---
## 十七、最终建议
优先实现前三个阶段:
1. Agent 默认回环;
2. SSH 隧道自动建立与恢复;
3. 旧 Agent 自动迁移并支持回滚。
这三步完成后,默认部署不再暴露公网 7700,也不需要用户修改 Composer 或防火墙。HTTPS + pin 与防火墙辅助放在后续阶段,作为需要直连用户的可选增强。这样可以在不扩大用户操作成本的前提下,完成 P0-5 的安全目标。
+4
View File
@@ -458,6 +458,10 @@ func (s *Server) clearLimitState(domain string) error
## 3.5 P0-5:明确 Agent 默认监听策略
> 方案文档:`agent-listen-strategy-plan.md`(2026-09-28,待实施)
> 实施状态:阶段 1-3 已随 v1.4.0 实施(默认回环、SSH 隧道、旧 HTTP 自动迁移);HTTPS + pin 高级模式待后续。
### 3.5.1 现状
- `cmd/fonu-agent/main.go` 默认 `:7700`。
+4 -4
View File
@@ -238,16 +238,16 @@ FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu
## 10. 在线升级验证
示例页面用于验证升级结果。v1.3.9 的验证流程:
示例页面用于验证升级结果。v1.4.0 的验证流程:
1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。
2. 确认远端 `main` 分支的 `VERSION` 为 `1.3.9`。
2. 确认远端 `main` 分支的 `VERSION` 为 `1.4.0`。
3. 在侧栏看到「一键升级」后点击并确认。
4. 等待升级弹窗显示成功,页面自动刷新。
5. 打开侧栏「示例页面」,确认:
- 页面路由为 `/example`;
- 页面显示的版本为 `1.3.9`;
- 页面显示验证标识 `v1.3.9-example-page`;
- 页面显示的版本为 `1.4.0`;
- 页面显示验证标识 `v1.4.0-example-page`;
- 页面显示独立的「本次升级验证」区块;
- 「升级验证」显示为「通过」。
+35
View File
@@ -0,0 +1,35 @@
package agent
import "testing"
func TestValidateListenAddr(t *testing.T) {
cases := []struct {
addr string
allowRemote bool
loopback bool
wantErr bool
}{
{"127.0.0.1:7700", false, true, false},
{"localhost:7700", false, true, false},
{"[::1]:7700", false, true, false},
{":7700", false, false, true},
{"0.0.0.0:7700", false, false, true},
{"0.0.0.0:7700", true, false, false},
{"bad-address", false, false, true},
}
for _, tc := range cases {
loopback, err := validateListenAddr(tc.addr, tc.allowRemote)
if tc.wantErr {
if err == nil {
t.Fatalf("%s 应拒绝", tc.addr)
}
continue
}
if err != nil {
t.Fatalf("%s 不应报错: %v", tc.addr, err)
}
if loopback != tc.loopback {
t.Fatalf("%s loopback=%v, want %v", tc.addr, loopback, tc.loopback)
}
}
}
+44 -3
View File
@@ -51,11 +51,13 @@ import (
// 并在失败信息里带上最后一次的具体原因与常见的显式指定目录。
// 0.12.8:Agent Bearer Token 改为常量时间比较,避免认证路径上的长度/内容时序泄漏。
// 0.12.9:限流状态文件损坏时拒绝覆盖,避免保存单条规则时清空其他域名的限流配置。
const AgentVersion = "0.12.9"
// 0.13.0:默认只监听回环地址,非回环监听必须显式允许;状态接口暴露监听范围与传输安全。
const AgentVersion = "0.13.0"
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
type Config struct {
ListenAddr string
AllowRemote bool
Token string
NginxConfDir string
NginxBinary string
@@ -68,6 +70,7 @@ type Config struct {
// Server 暴露 agent HTTP 服务。
type Server struct {
cfg Config
listenLoopback bool
mu sync.Mutex
// metrics 采样缓存:CPU 占用率需要两次采样,间隔内的重复请求直接复用上次结果
metricsMu sync.Mutex
@@ -128,7 +131,11 @@ var domainPattern = regexp.MustCompile(`^(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])
func NewServer(cfg Config) (*Server, error) {
if strings.TrimSpace(cfg.ListenAddr) == "" {
cfg.ListenAddr = ":7700"
cfg.ListenAddr = "127.0.0.1:7700"
}
loopback, err := validateListenAddr(cfg.ListenAddr, cfg.AllowRemote)
if err != nil {
return nil, err
}
if strings.TrimSpace(cfg.NginxConfDir) == "" {
// 目录探测失败不阻断启动:agent 照常提供状态接口,路由写入时重试探测(systemd PATH 下可能找不到 nginx,写入前会补全路径)
@@ -164,13 +171,43 @@ func NewServer(cfg Config) (*Server, error) {
return nil, fmt.Errorf("create agent directory: %w", err)
}
}
server := &Server{cfg: cfg}
server := &Server{cfg: cfg, listenLoopback: loopback}
// 启动后台预解析目录(不阻断监听);每次启动都会用最新探测逻辑重新解析并覆盖落盘缓存,
// 历史错误目录的缓存会被自动修正。
server.preloadNginxConfDir()
return server, nil
}
func validateListenAddr(addr string, allowRemote bool) (bool, error) {
host, _, err := net.SplitHostPort(strings.TrimSpace(addr))
if err != nil {
return false, fmt.Errorf("Agent 监听地址无效: %w", err)
}
host = strings.Trim(host, "[]")
loopback := strings.EqualFold(host, "localhost")
if ip := net.ParseIP(host); ip != nil {
loopback = ip.IsLoopback()
}
if !loopback && !allowRemote {
return false, fmt.Errorf("检测到 Agent 将监听非回环地址 %s;默认安全模式只监听 127.0.0.1:7700。如确认需要远程监听,请设置 FONU_AGENT_ALLOW_REMOTE=1", addr)
}
return loopback, nil
}
func listenScope(loopback bool) string {
if loopback {
return "loopback"
}
return "public"
}
func transportSecurity(loopback bool) string {
if loopback {
return "loopback"
}
return "plaintext-http"
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/ping", s.auth(s.ping))
@@ -244,6 +281,10 @@ func (s *Server) status(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"service": "fonu-agent",
"agent_version": AgentVersion,
"listen_addr": s.cfg.ListenAddr,
"listen_scope": listenScope(s.listenLoopback),
"remote_access_enabled": s.cfg.AllowRemote,
"transport_security": transportSecurity(s.listenLoopback),
"nginx_binary": s.cfg.NginxBinary,
"nginx_conf_dir": s.cfg.NginxConfDir,
"nginx_ok": nginxProbe.OK,
+14
View File
@@ -324,6 +324,20 @@ func (h *FRPMultiHandler) AgentTest(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *FRPMultiHandler) AgentTunnelRestart(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
return
}
server, err := h.svc.RestartAgentTunnel(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, server)
}
func (h *FRPMultiHandler) AgentStatus(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
+1
View File
@@ -131,6 +131,7 @@ func NewRouter(deps Deps) http.Handler {
protect("POST /api/frpmulti/servers/{id}/agent/uninstall", frpHandler.AgentUninstall)
protect("POST /api/frpmulti/servers/{id}/agent/ssh-diagnose", frpHandler.AgentSSHDiagnose)
protect("POST /api/frpmulti/servers/{id}/agent/test", frpHandler.AgentTest)
protect("POST /api/frpmulti/servers/{id}/agent/tunnel/restart", frpHandler.AgentTunnelRestart)
protect("GET /api/frpmulti/servers/{id}/agent/status", frpHandler.AgentStatus)
protect("GET /api/frpmulti/servers/{id}/agent/routes", frpHandler.AgentRoutes)
protect("GET /api/frpmulti/servers/{id}/agent/routes/health", frpHandler.AgentRouteHealth)
+1
View File
@@ -146,6 +146,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
ctx, cancel := context.WithCancel(context.Background())
frpMultiSvc.SetBaseContext(ctx)
go frpMultiSvc.StartAgentTunnels(ctx)
intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
if intervalMinutes <= 0 {
intervalMinutes = 5
+11
View File
@@ -84,6 +84,13 @@ func (i AgentInstaller) Uninstall(ctx context.Context, keepData bool) (string, e
return strings.TrimSpace(out), err
}
// RestorePreviousConfig 在安全迁移验证失败时恢复安装前的 agent.env,并重启旧监听配置。
func (i AgentInstaller) RestorePreviousConfig(ctx context.Context) error {
command := `if [ -f /etc/fonu-agent/agent.env.previous ]; then cp /etc/fonu-agent/agent.env.previous /etc/fonu-agent/agent.env && chmod 600 /etc/fonu-agent/agent.env && systemctl restart fonu-agent; fi`
_, err := i.SSH.Run(ctx, command)
return err
}
// Diagnose agent 失联时走 SSH 急救:进程、端口、服务日志尾部。
func (i AgentInstaller) Diagnose(ctx context.Context) (string, error) {
return i.SSH.Run(ctx, "systemctl status fonu-agent --no-pager -l 2>/dev/null | head -20; echo '---'; journalctl -u fonu-agent -n 15 --no-pager 2>/dev/null; echo '---'; ss -tlnp 2>/dev/null | grep ':7700 ' || echo '7700 not listening'")
@@ -144,6 +151,10 @@ if [ ! -f "$CONF_FILE" ]; then
# 用 /dev/urandom 生成 64 位 hex token,不依赖 openssl(缺失时会静默写入空 token,导致 Fonu 无法认证 agent)
printf 'FONU_AGENT_TOKEN=%s\n' "$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" > "$CONF_FILE"
fi
cp "$CONF_FILE" "$CONF_FILE.previous" 2>/dev/null || true
touch "$CONF_FILE"
sed -i '/^FONU_AGENT_LISTEN=/d; /^FONU_AGENT_ALLOW_REMOTE=/d' "$CONF_FILE"
printf 'FONU_AGENT_LISTEN=127.0.0.1:7700\nFONU_AGENT_ALLOW_REMOTE=0\n' >> "$CONF_FILE"
cat > "$UNIT" <<'UNIT'
[Unit]
Description=Fonu FRPS management agent
+182
View File
@@ -0,0 +1,182 @@
package frp
import (
"context"
"fmt"
"io"
"net"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
const agentRemoteAddr = "127.0.0.1:7700"
type agentTunnel struct {
serverID int64
localPort int
ssh SSHInstaller
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
listener net.Listener
client *ssh.Client
state string
lastError string
wg sync.WaitGroup
}
func newAgentTunnel(parent context.Context, serverID int64, localPort int, installer SSHInstaller) *agentTunnel {
ctx, cancel := context.WithCancel(parent)
return &agentTunnel{
serverID: serverID,
localPort: localPort,
ssh: installer,
ctx: ctx,
cancel: cancel,
state: "connecting",
}
}
func (t *agentTunnel) Start() error {
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", t.localPort))
if err != nil {
t.setState("failed", err)
return fmt.Errorf("监听隧道本地端口失败: %w", err)
}
t.mu.Lock()
t.listener = listener
t.state = "connected"
t.lastError = ""
t.mu.Unlock()
t.wg.Add(1)
go t.acceptLoop()
return nil
}
func (t *agentTunnel) acceptLoop() {
defer t.wg.Done()
for {
conn, err := t.listener.Accept()
if err != nil {
if t.ctx.Err() != nil {
return
}
t.setState("failed", err)
return
}
t.wg.Add(1)
go func() {
defer t.wg.Done()
t.handle(conn)
}()
}
}
func (t *agentTunnel) handle(local net.Conn) {
defer local.Close()
client, err := t.ensureClient()
if err != nil {
t.setState("reconnecting", err)
return
}
remote, err := client.Dial("tcp", agentRemoteAddr)
if err != nil {
t.invalidateClient(client)
t.setState("reconnecting", err)
return
}
defer remote.Close()
t.setState("connected", nil)
proxyTunnel(local, remote)
}
func (t *agentTunnel) ensureClient() (*ssh.Client, error) {
t.mu.Lock()
if t.client != nil {
client := t.client
t.mu.Unlock()
if _, _, err := client.SendRequest("keepalive@openssh.com", true, nil); err == nil {
return client, nil
}
t.invalidateClient(client)
} else {
t.mu.Unlock()
}
ctx, cancel := context.WithTimeout(t.ctx, 15*time.Second)
defer cancel()
client, err := t.ssh.client(ctx)
if err != nil {
return nil, err
}
t.mu.Lock()
if t.client != nil {
existing := t.client
t.mu.Unlock()
_ = client.Close()
return existing, nil
}
t.client = client
t.mu.Unlock()
return client, nil
}
func (t *agentTunnel) invalidateClient(client *ssh.Client) {
t.mu.Lock()
if t.client == client {
t.client = nil
}
t.mu.Unlock()
_ = client.Close()
}
func (t *agentTunnel) State() (string, string) {
t.mu.Lock()
defer t.mu.Unlock()
return t.state, t.lastError
}
func (t *agentTunnel) setState(state string, err error) {
t.mu.Lock()
defer t.mu.Unlock()
t.state = state
if err == nil {
t.lastError = ""
return
}
t.lastError = err.Error()
}
func (t *agentTunnel) Stop() {
t.cancel()
t.mu.Lock()
if t.listener != nil {
_ = t.listener.Close()
}
if t.client != nil {
_ = t.client.Close()
}
t.mu.Unlock()
t.wg.Wait()
}
func proxyTunnel(a, b net.Conn) {
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
_, _ = io.Copy(a, b)
_ = a.Close()
_ = b.Close()
}()
go func() {
defer wg.Done()
_, _ = io.Copy(b, a)
_ = a.Close()
_ = b.Close()
}()
wg.Wait()
}
+35
View File
@@ -0,0 +1,35 @@
package frp
import (
"context"
"fmt"
"net"
"testing"
)
func TestAgentTunnelBindsLoopbackAndReleasesPort(t *testing.T) {
probe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := probe.Addr().(*net.TCPAddr).Port
_ = probe.Close()
tunnel := newAgentTunnel(context.Background(), 1, port, SSHInstaller{})
if err := tunnel.Start(); err != nil {
t.Fatal(err)
}
if _, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)); err == nil {
t.Fatal("隧道启动后本地端口应已被占用")
}
state, _ := tunnel.State()
if state != "connected" {
t.Fatalf("state=%q, want connected", state)
}
tunnel.Stop()
if listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)); err != nil {
t.Fatalf("隧道停止后端口未释放: %v", err)
} else {
_ = listener.Close()
}
}
Binary file not shown.
Binary file not shown.
+6
View File
@@ -87,6 +87,12 @@ type Server struct {
ID int64 `json:"id"`
Name string `json:"name"`
AgentURL string `json:"agent_url,omitempty"`
AgentTransport string `json:"agent_transport"`
AgentLocalPort int `json:"agent_local_port,omitempty"`
AgentTLSPin string `json:"agent_tls_pin,omitempty"`
AgentListenAddr string `json:"agent_listen_addr,omitempty"`
AgentTransportState string `json:"agent_transport_state"`
AgentTransportError string `json:"agent_transport_error,omitempty"`
AgentConfigured bool `json:"agent_configured"`
SSHConfigured bool `json:"ssh_configured"`
MgmtEnabled bool `json:"mgmt_enabled"`
+212 -5
View File
@@ -49,6 +49,9 @@ type Service struct {
agentMu sync.Mutex
agentClients map[int64]cachedAgentClient
tunnelMu sync.Mutex
tunnels map[int64]*agentTunnel
// 诊断短 TTL 缓存与并发上限,避免列表页轮询叠加外部探测
diagMu sync.Mutex
diagCache map[int64]diagnosticsCacheEntry
@@ -79,6 +82,7 @@ func NewService(cfg config.Config, store *Store, secretBox *secret.Box, logger *
trafficClient: newFRPSTrafficClient(),
trafficLast: map[int64]trafficSample{},
diagCache: map[int64]diagnosticsCacheEntry{},
tunnels: map[int64]*agentTunnel{},
}
return service
}
@@ -356,6 +360,10 @@ func (s *Service) enrichServerRuntime(ctx context.Context, server *Server) {
server.LastDisconnectedAt = runtime.LastDisconnectedAt
server.LastError = runtime.LastError
server.LastStartedAt = runtime.LastStartedAt
if state, lastError, ok := s.tunnelState(server.ID); ok {
server.AgentTransportState = state
server.AgentTransportError = lastError
}
}
func (s *Service) setServerRuntime(ctx context.Context, serverID int64, status, lastError string) {
@@ -629,10 +637,14 @@ func (s *Service) UpdateServer(ctx context.Context, id int64, in ServerInput) (S
if !server.Enabled {
_ = s.managerForServer(id).Stop(ctx)
}
if !server.MgmtEnabled || server.AgentTransport != "tunnel" {
s.stopAgentTunnel(id)
}
return server, s.persistConfig(ctx)
}
func (s *Service) DeleteServer(ctx context.Context, id int64) error {
s.stopAgentTunnel(id)
if err := s.managerForServer(id).Stop(ctx); err != nil {
return err
}
@@ -732,10 +744,31 @@ func (s *Service) AgentInstall(ctx context.Context, serverID int64) (Server, err
if err != nil {
return Server{}, err
}
localPort := server.AgentLocalPort
if localPort == 0 {
localPort, err = s.allocateAgentTunnelPort(ctx)
if err != nil {
return Server{}, err
}
}
tunnel := newAgentTunnel(s.tunnelContext(ctx), serverID, localPort, installer)
if err := tunnel.Start(); err != nil {
return Server{}, err
}
baseURL := fmt.Sprintf("http://127.0.0.1:%d", localPort)
client := NewAgentClient(baseURL, token)
if err := client.Ping(ctx); err != nil {
tunnel.Stop()
rollbackErr := (AgentInstaller{SSH: installer}).RestorePreviousConfig(ctx)
if rollbackErr != nil {
return Server{}, fmt.Errorf("Agent 已安装,但 SSH 隧道验证失败: %w;恢复旧监听配置也失败: %v", err, rollbackErr)
}
return Server{}, fmt.Errorf("Agent 已安装,但 SSH 隧道验证失败,已恢复旧监听配置: %w", err)
}
input := ServerInput{
Name: server.Name, ServerAddr: server.ServerAddr, ServerPort: server.ServerPort,
TLS: server.TLS, TLSServerName: server.TLSServerName, Enabled: server.Enabled,
Options: server.Options, AgentURL: fmt.Sprintf("http://%s:%d", server.SSHHost, 7700),
Options: server.Options, AgentURL: baseURL,
AgentToken: token, SSHHost: server.SSHHost, SSHPort: server.SSHPort, SSHUser: server.SSHUser,
SSHAuth: server.SSHAuth, MgmtEnabled: true,
}
@@ -743,15 +776,178 @@ func (s *Service) AgentInstall(ctx context.Context, serverID int64) (Server, err
if err != nil {
return Server{}, err
}
s.tunnelMu.Lock()
if existing := s.tunnels[serverID]; existing != nil {
existing.Stop()
}
s.tunnels[serverID] = tunnel
s.tunnelMu.Unlock()
if err := s.store.SetAgentTransport(ctx, serverID, "tunnel", localPort, "", "127.0.0.1:7700", "connected", ""); err != nil {
tunnel.Stop()
_ = (AgentInstaller{SSH: installer}).RestorePreviousConfig(ctx)
return Server{}, err
}
return updated, nil
}
func (s *Service) tunnelContext(fallback context.Context) context.Context {
s.downloadMu.RLock()
base := s.baseCtx
s.downloadMu.RUnlock()
if base != nil {
return base
}
if fallback != nil {
return fallback
}
return context.Background()
}
func (s *Service) allocateAgentTunnelPort(ctx context.Context) (int, error) {
s.tunnelMu.Lock()
defer s.tunnelMu.Unlock()
servers, err := s.store.ListServers(ctx)
if err != nil {
return 0, err
}
used := map[int]bool{}
for _, server := range servers {
if server.AgentLocalPort > 0 {
used[server.AgentLocalPort] = true
}
}
for port := 17700; port <= 18700; port++ {
if used[port] {
continue
}
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
continue
}
_ = listener.Close()
return port, nil
}
return 0, fmt.Errorf("SSH 隧道本地端口 17700-18700 已耗尽")
}
func (s *Service) ensureAgentTunnel(ctx context.Context, serverID int64) (*agentTunnel, error) {
s.tunnelMu.Lock()
if tunnel := s.tunnels[serverID]; tunnel != nil {
s.tunnelMu.Unlock()
return tunnel, nil
}
s.tunnelMu.Unlock()
server, installer, err := s.sshInstaller(ctx, serverID)
if err != nil {
return nil, err
}
localPort := server.AgentLocalPort
if localPort == 0 {
localPort, err = s.allocateAgentTunnelPort(ctx)
if err != nil {
return nil, err
}
}
tunnel := newAgentTunnel(s.tunnelContext(ctx), serverID, localPort, installer)
if err := tunnel.Start(); err != nil {
return nil, err
}
s.tunnelMu.Lock()
if existing := s.tunnels[serverID]; existing != nil {
s.tunnelMu.Unlock()
tunnel.Stop()
return existing, nil
}
s.tunnels[serverID] = tunnel
s.tunnelMu.Unlock()
if err := s.store.SetAgentTransport(ctx, serverID, "tunnel", localPort, server.AgentTLSPin, "127.0.0.1:7700", "connected", ""); err != nil {
tunnel.Stop()
return nil, err
}
return tunnel, nil
}
// StartAgentTunnels 在应用启动后恢复所有 tunnel transport。
func (s *Service) StartAgentTunnels(ctx context.Context) {
servers, err := s.store.ListServers(ctx)
if err != nil {
s.logger.Warn("恢复 Agent 隧道失败", "error", err.Error())
return
}
for _, server := range servers {
if !server.MgmtEnabled || server.AgentTransport != "tunnel" {
continue
}
if _, err := s.ensureAgentTunnel(ctx, server.ID); err != nil {
s.logger.Warn("Agent 隧道启动失败", "server_id", server.ID, "error", err.Error())
}
}
}
func (s *Service) stopAgentTunnels() {
s.tunnelMu.Lock()
tunnels := make([]*agentTunnel, 0, len(s.tunnels))
for id, tunnel := range s.tunnels {
tunnels = append(tunnels, tunnel)
delete(s.tunnels, id)
}
s.tunnelMu.Unlock()
for _, tunnel := range tunnels {
tunnel.Stop()
}
}
func (s *Service) stopAgentTunnel(serverID int64) {
s.tunnelMu.Lock()
tunnel := s.tunnels[serverID]
delete(s.tunnels, serverID)
s.tunnelMu.Unlock()
if tunnel != nil {
tunnel.Stop()
}
}
func (s *Service) tunnelState(serverID int64) (string, string, bool) {
s.tunnelMu.Lock()
tunnel := s.tunnels[serverID]
s.tunnelMu.Unlock()
if tunnel == nil {
return "", "", false
}
state, lastError := tunnel.State()
return state, lastError, true
}
func (s *Service) RestartAgentTunnel(ctx context.Context, serverID int64) (Server, error) {
current, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, err
}
s.stopAgentTunnel(serverID)
if _, err := s.ensureAgentTunnel(ctx, serverID); err != nil {
_ = s.store.SetAgentTransport(ctx, serverID, "tunnel", current.AgentLocalPort, current.AgentTLSPin, current.AgentListenAddr, "failed", err.Error())
return Server{}, err
}
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, err
}
s.enrichServerRuntime(ctx, &server)
return server, nil
}
func (s *Service) AgentUninstall(ctx context.Context, serverID int64, keepData bool) (string, error) {
_, installer, err := s.sshInstaller(ctx, serverID)
if err != nil {
return "", err
}
return AgentInstaller{SSH: installer}.Uninstall(ctx, keepData)
output, err := AgentInstaller{SSH: installer}.Uninstall(ctx, keepData)
if err == nil {
s.stopAgentTunnel(serverID)
_ = s.store.SetAgentTransport(ctx, serverID, "tunnel", 0, "", "", "stopped", "")
}
return output, err
}
func (s *Service) AgentSSHDiagnose(ctx context.Context, serverID int64) (string, error) {
@@ -1307,7 +1503,7 @@ type cachedAgentClient struct {
func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient, bool) {
server, err := s.store.GetServer(ctx, serverID)
if err != nil || !server.MgmtEnabled || server.AgentURL == "" || !server.AgentConfigured {
if err != nil || !server.MgmtEnabled || !server.AgentConfigured {
return nil, false
}
encoded, err := s.store.AgentToken(ctx, serverID)
@@ -1318,8 +1514,18 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient
if err != nil || token == "" {
return nil, false
}
baseURL := server.AgentURL
if server.AgentTransport == "tunnel" {
tunnel, err := s.ensureAgentTunnel(ctx, serverID)
if err != nil {
return nil, false
}
baseURL = fmt.Sprintf("http://127.0.0.1:%d", tunnel.localPort)
} else if baseURL == "" {
return nil, false
}
// 复用同一个 http.Client:批量同步 N 条路由时不再每次新建 Transport 与 TLS 握手
fingerprint := server.AgentURL + "|" + encoded
fingerprint := baseURL + "|" + encoded
s.agentMu.Lock()
defer s.agentMu.Unlock()
if s.agentClients == nil {
@@ -1328,7 +1534,7 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient
if cached, ok := s.agentClients[serverID]; ok && cached.fingerprint == fingerprint {
return cached.client, true
}
client := NewAgentClient(server.AgentURL, token)
client := NewAgentClient(baseURL, token)
s.agentClients[serverID] = cachedAgentClient{fingerprint: fingerprint, client: client}
return client, true
}
@@ -1421,6 +1627,7 @@ func (s *Service) AutoStart(ctx context.Context) error {
}
func (s *Service) Stop(ctx context.Context) error {
s.stopAgentTunnels()
_ = s.ReconcileRuntime(ctx)
servers, err := s.store.ListServers(ctx)
if err != nil {
+21 -1
View File
@@ -279,6 +279,24 @@ func (s *Store) AgentToken(ctx context.Context, id int64) (string, error) {
return token, err
}
func (s *Store) SetAgentTransport(ctx context.Context, id int64, transport string, localPort int, tlsPin, listenAddr, state, lastError string) error {
result, err := s.db.ExecContext(ctx, `
UPDATE frp_servers
SET agent_transport = ?, agent_local_port = ?, agent_tls_pin = ?,
agent_listen_addr = ?, agent_transport_state = ?, agent_transport_error = ?,
updated_at = datetime('now')
WHERE id = ?
`, transport, localPort, tlsPin, listenAddr, state, lastError, id)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return fmt.Errorf("FRP 服务端不存在")
}
return nil
}
func (s *Store) Token(ctx context.Context, id int64) (string, error) {
var token string
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(auth_token_enc, '') FROM frp_servers WHERE id = ?`, id).Scan(&token)
@@ -428,6 +446,8 @@ SELECT id, name, server_addr, server_port, tls_enabled, tls_server_name, enabled
COALESCE(agent_url, ''), CASE WHEN COALESCE(agent_token_enc, '') != '' THEN 1 ELSE 0 END,
COALESCE(ssh_host, ''), COALESCE(ssh_port, 22), COALESCE(ssh_user, ''), COALESCE(ssh_auth, 'key'),
CASE WHEN COALESCE(ssh_secret_enc, '') != '' THEN 1 ELSE 0 END, COALESCE(mgmt_enabled, 0),
COALESCE(agent_transport, 'http'), COALESCE(agent_local_port, 0), COALESCE(agent_tls_pin, ''),
COALESCE(agent_listen_addr, ''), COALESCE(agent_transport_state, 'unknown'), COALESCE(agent_transport_error, ''),
COALESCE(config_json, '{}'), created_at, updated_at
FROM frp_servers`
@@ -439,7 +459,7 @@ func scanServer(row interface{ Scan(dest ...any) error }) (Server, error) {
var server Server
var tls, enabled, token, oidcSecret, dashboardPwd, agentConfigured, sshConfigured, mgmtEnabled int
var configJSON, created, updated string
err := row.Scan(&server.ID, &server.Name, &server.ServerAddr, &server.ServerPort, &tls, &server.TLSServerName, &enabled, &token, &oidcSecret, &dashboardPwd, &server.AgentURL, &agentConfigured, &server.SSHHost, &server.SSHPort, &server.SSHUser, &server.SSHAuth, &sshConfigured, &mgmtEnabled, &configJSON, &created, &updated)
err := row.Scan(&server.ID, &server.Name, &server.ServerAddr, &server.ServerPort, &tls, &server.TLSServerName, &enabled, &token, &oidcSecret, &dashboardPwd, &server.AgentURL, &agentConfigured, &server.SSHHost, &server.SSHPort, &server.SSHUser, &server.SSHAuth, &sshConfigured, &mgmtEnabled, &server.AgentTransport, &server.AgentLocalPort, &server.AgentTLSPin, &server.AgentListenAddr, &server.AgentTransportState, &server.AgentTransportError, &configJSON, &created, &updated)
if err != nil {
return Server{}, err
}
+7
View File
@@ -0,0 +1,7 @@
-- Agent 传输安全:默认支持 SSH 隧道回环访问,保留 http 作为升级兼容模式。
ALTER TABLE frp_servers ADD COLUMN agent_transport TEXT NOT NULL DEFAULT 'http';
ALTER TABLE frp_servers ADD COLUMN agent_local_port INTEGER NOT NULL DEFAULT 0;
ALTER TABLE frp_servers ADD COLUMN agent_tls_pin TEXT NOT NULL DEFAULT '';
ALTER TABLE frp_servers ADD COLUMN agent_listen_addr TEXT NOT NULL DEFAULT '';
ALTER TABLE frp_servers ADD COLUMN agent_transport_state TEXT NOT NULL DEFAULT 'unknown';
ALTER TABLE frp_servers ADD COLUMN agent_transport_error TEXT NOT NULL DEFAULT '';
+7 -1
View File
@@ -37,6 +37,12 @@ if [ ! -f "$CONF_FILE" ]; then
printf 'FONU_AGENT_TOKEN=%s\n' "$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" > "$CONF_FILE"
fi
# 默认只监听回环;显式删除旧公网监听,由中央 Fonu 通过 SSH 隧道访问。
cp "$CONF_FILE" "$CONF_FILE.previous" 2>/dev/null || true
touch "$CONF_FILE"
sed -i '/^FONU_AGENT_LISTEN=/d; /^FONU_AGENT_ALLOW_REMOTE=/d' "$CONF_FILE"
printf 'FONU_AGENT_LISTEN=127.0.0.1:7700\nFONU_AGENT_ALLOW_REMOTE=0\n' >> "$CONF_FILE"
cat > "$UNIT" <<'UNIT'
[Unit]
Description=Fonu FRPS management agent
@@ -60,5 +66,5 @@ systemctl restart fonu-agent
systemctl enable fonu-agent
TOKEN=$(sed -n 's/^FONU_AGENT_TOKEN=//p' "$CONF_FILE")
echo "fonu-agent 已安装并启动"
echo "API: http://$(hostname -I 2>/dev/null | awk '{print $1}'):7700"
echo "API: SSH tunnel -> 127.0.0.1:7700"
echo "Token: $TOKEN"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "fonu-web",
"version": "1.3.9",
"version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fonu-web",
"version": "1.3.9",
"version": "1.4.0",
"dependencies": {
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-javascript": "^6.2.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "fonu-web",
"private": true,
"version": "1.3.9",
"version": "1.4.0",
"packageManager": "npm@10.9.4",
"type": "module",
"scripts": {
+2
View File
@@ -305,6 +305,8 @@ export const api = {
exportFrpConfig: () => request<{ content: string }>('/api/frpmulti/config/export'),
getFrpFRPSConfig: (id: number) => request<{ content: string }>(`/api/frpmulti/servers/${id}/frps-config`),
testFrpAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/test`, { method: 'POST' }),
restartFrpAgentTunnel: (id: number) =>
request<FrpServer>(`/api/frpmulti/servers/${id}/agent/tunnel/restart`, { method: 'POST' }),
probeFrpAgent: (id: number) => request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/probe`, { method: 'POST' }),
installFrpAgent: (id: number) => request<FrpServer>(`/api/frpmulti/servers/${id}/agent/install`, { method: 'POST' }),
uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { method: 'POST' }),
+6
View File
@@ -396,6 +396,12 @@ export interface FRPSavePayload {
export interface FrpServer {
agent_url?: string
agent_transport: 'tunnel' | 'https-pin' | 'http' | string
agent_local_port?: number
agent_tls_pin?: string
agent_listen_addr?: string
agent_transport_state: string
agent_transport_error?: string
agent_configured?: boolean
ssh_configured?: boolean
mgmt_enabled?: boolean
+42 -3
View File
@@ -241,11 +241,22 @@
<FonuCard class="section-card" title="Agent 生命周期(SSH)">
<div class="section-body">
<p class="form-hint">
首次安装、升级、卸载需要 SSH(在「内网穿透」页服务端表单的「公网 Agent 管理」区填写);安装成功后自动回填 Agent 地址与 Token。
日常管理走 Agent API,无需 SSH。SSH 信息仅用于生命周期操作,凭据加密存储、不回显。
首次安装、升级、卸载需要 SSH(在「内网穿透」页服务端表单的「公网 Agent 管理」区填写);安装成功后自动建立 SSH 隧道,不会默认暴露公网 7700。
SSH 信息仅用于生命周期和隧道,凭据加密存储、不回显。
</p>
<div class="field-grid agent-transport-grid">
<div><label>传输方式</label><span class="field-tags"><StatusBadge :kind="agentTransportKind(server)" :text="agentTransportText(server)" /></span></div>
<div><label>隧道状态</label><span>{{ agentTransportStateText(server) }}</span></div>
<div><label>本地入口</label><span class="mono">{{ server.agent_transport === 'tunnel' && server.agent_local_port ? `127.0.0.1:${server.agent_local_port}` : '—' }}</span></div>
<div><label>Agent 监听</label><span class="mono">{{ server.agent_listen_addr || '—' }}</span></div>
</div>
<n-alert v-if="server.agent_transport === 'http'" type="warning" :bordered="false" class="agent-transport-warning">
当前仍是公网明文 HTTP。点击「升级 Agent」可自动迁移到 SSH 隧道安全模式。
</n-alert>
<p v-if="server.agent_transport_error" class="form-hint agent-transport-error">{{ server.agent_transport_error }}</p>
<div class="agent-actions">
<n-button size="small" :loading="busy === 'agent-install'" :disabled="busy !== null && busy !== 'agent-install'" @click="installAgent(server)">{{ server.agent_configured ? '升级 Agent' : '安装 Agent' }}</n-button>
<n-button v-if="server.agent_transport === 'tunnel'" size="small" :loading="busy === 'tunnel-restart'" :disabled="busy !== null && busy !== 'tunnel-restart'" @click="restartAgentTunnel(server)">重启隧道</n-button>
<n-button size="small" :loading="busy === 'diagnose'" :disabled="busy !== null && busy !== 'diagnose'" @click="diagnoseAgent(server)">SSH 诊断</n-button>
<n-button size="small" quaternary :loading="busy === 'agent-uninstall'" :disabled="busy !== null && busy !== 'agent-uninstall'" @click="uninstallAgent(server, true)">卸载(保留路由/证书)</n-button>
<n-popconfirm @positive-click="uninstallAgent(server, false)">
@@ -674,7 +685,7 @@ use([CanvasRenderer, LineChart, ScatterChart, GridComponent, TooltipComponent])
// 操作动作:按动作粒度控制 loading,保证"只有被点的按钮转圈"
type Action =
| 'probe' | 'agent-install' | 'agent-uninstall' | 'diagnose'
| 'frps-start' | 'frps-stop' | 'frps-restart' | 'frps-install' | 'push-config'
| 'tunnel-restart' | 'frps-start' | 'frps-stop' | 'frps-restart' | 'frps-install' | 'push-config'
interface OpOutput { at: string; source: string; text: string; ok: boolean }
@@ -1396,6 +1407,31 @@ function serverStartupKind(server: FrpServer): StatusKind {
return 'unknown'
}
function restartAgentTunnel(target: FrpServer) {
return run('tunnel-restart', '重启 SSH 隧道', () => api.restartFrpAgentTunnel(target.id),
() => ({ text: 'SSH 隧道已重建并完成连接检查', ok: true }))
.then(async (result) => { if (result !== undefined) await load() })
}
function agentTransportText(server: FrpServer) {
if (server.agent_transport === 'tunnel') return 'SSH 隧道'
if (server.agent_transport === 'https-pin') return 'HTTPS + 证书固定'
return '公网 HTTP(兼容)'
}
function agentTransportKind(server: FrpServer): StatusKind {
if (server.agent_transport === 'tunnel' || server.agent_transport === 'https-pin') return 'success'
return 'warning'
}
function agentTransportStateText(server: FrpServer) {
if (server.agent_transport !== 'tunnel') return '不适用'
if (server.agent_transport_state === 'connected') return '已连接'
if (server.agent_transport_state === 'reconnecting') return '重连中'
if (server.agent_transport_state === 'failed') return '连接失败'
return '未建立'
}
function processStateText(server: FrpServer) {
return ({ starting: '启动中', running: '运行中', stopped: '已停止', error: '异常', unknown: '未知' } as Record<string, string>)[server.process_state] ?? '未知'
}
@@ -1594,6 +1630,9 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
.field-grid label { color: var(--fonu-text-secondary); font-size: 12px; }
.field-tags { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.agent-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: var(--fonu-space-3); }
.agent-transport-grid { margin-top: var(--fonu-space-3); }
.agent-transport-warning { margin-top: var(--fonu-space-3); }
.agent-transport-error { color: var(--fonu-error); }
.tooltip-trigger { display: inline-flex; }
.frps-install-row { display: flex; align-items: center; gap: 10px; margin-top: var(--fonu-space-3); flex-wrap: wrap; }
.frps-version-select { width: 200px; }
+8 -8
View File
@@ -8,7 +8,7 @@
</template>
</PageHeader>
<n-alert type="success" :bordered="false" class="example-alert" title="v1.3.9 示例页面验证标识已生效">
<n-alert type="success" :bordered="false" class="example-alert" title="v1.4.0 示例页面验证标识已生效">
当前页面由 v{{ version }} 提供,验证标识为 <span class="mono">{{ verificationMarker }}</span>。
如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。
</n-alert>
@@ -19,7 +19,7 @@
<strong class="example-proof__value">{{ verificationRound }}</strong>
</div>
<div class="example-proof__hint">
看到这个新区块,说明前端资源已经从旧版本切换到 v1.3.9。
看到这个新区块,说明前端资源已经从旧版本切换到 v1.4.0。
</div>
</div>
@@ -33,16 +33,16 @@
<StatCard label="本次验证标识" :value="verificationMarker" sub="用于区分升级前后的前端资源" tone="blue" value-small>
<template #icon><n-icon :component="FlashOutline" /></template>
</StatCard>
<StatCard label="页面状态" value="已更新" sub="v1.3.9 前端资源已加载" tone="teal">
<StatCard label="页面状态" value="已更新" sub="v1.4.0 前端资源已加载" tone="teal">
<template #icon><n-icon :component="CheckmarkCircleOutline" /></template>
</StatCard>
</div>
<FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化">
<ul class="example-changes">
<li>页面版本提示更新为 v1.3.9。</li>
<li>「本次升级验证」区块和页面状态同步更新为 v1.3.9。</li>
<li>验证标识更新为 <span class="mono">v1.3.9-example-page</span>。</li>
<li>页面版本提示更新为 v1.4.0。</li>
<li>「本次升级验证」区块和页面状态同步更新为 v1.4.0。</li>
<li>验证标识更新为 <span class="mono">v1.4.0-example-page</span>。</li>
</ul>
</FonuCard>
@@ -87,8 +87,8 @@ const message = useMessage()
const version = ref(__APP_VERSION__)
const checkedAt = ref('')
const refreshing = ref(false)
const verificationRound = 'v1.3.9'
const verificationMarker = 'v1.3.9-example-page'
const verificationRound = 'v1.4.0'
const verificationMarker = 'v1.4.0-example-page'
async function refreshVersion() {
refreshing.value = true