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. 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 ## [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" "log"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"github.com/fonu/fonu/internal/agent" "github.com/fonu/fonu/internal/agent"
) )
func main() { 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") 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 目录)") 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") 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") log.Fatal("FONU_AGENT_TOKEN or -token is required")
} }
server, err := agent.NewServer(agent.Config{ 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, CertsDir: *certsDir, FrpsConfigPath: *frpsConfig, FrpsBinary: *frpsBin, DataDir: *dataDir,
}) })
if err != nil { if err != nil {
@@ -49,3 +51,12 @@ func envOr(key, fallback string) string {
} }
return fallback 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 文档索引 # 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、前端、链路、风险与改进路线 | 当前版本快照 | | `project-analysis-2026-09-27.md` | 全项目详细分析(v1.2.19):架构、模块、数据、API、前端、链路、风险与改进路线 | 当前版本快照 |
| `improvement-roadmap-2026-09-27.md` | P0-P3 改进路线实施方案:逐项改法、测试、验收、依赖、发布与回滚 | P0-1、P0-3、P0-4 已完成,其余待实施 | | `improvement-roadmap-2026-09-27.md` | P0-P3 改进路线实施方案:逐项改法、测试、验收、依赖、发布与回滚 | P0-1、P0-3、P0-4 已完成,其余待实施 |
| `system-upgrade.md` | Docker Compose 一键升级:sidecar 架构、首次部署、安全边界、回滚、故障排查与验证页 | 已实施(v1.3.8) | | `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 实施中 | | `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 死样式清理未做 | | `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 三项不做 | | `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 默认监听策略 ## 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 现状 ### 3.5.1 现状
- `cmd/fonu-agent/main.go` 默认 `:7700`。 - `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. 在线升级验证 ## 10. 在线升级验证
示例页面用于验证升级结果。v1.3.9 的验证流程: 示例页面用于验证升级结果。v1.4.0 的验证流程:
1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。 1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。
2. 确认远端 `main` 分支的 `VERSION` 为 `1.3.9`。 2. 确认远端 `main` 分支的 `VERSION` 为 `1.4.0`。
3. 在侧栏看到「一键升级」后点击并确认。 3. 在侧栏看到「一键升级」后点击并确认。
4. 等待升级弹窗显示成功,页面自动刷新。 4. 等待升级弹窗显示成功,页面自动刷新。
5. 打开侧栏「示例页面」,确认: 5. 打开侧栏「示例页面」,确认:
- 页面路由为 `/example`; - 页面路由为 `/example`;
- 页面显示的版本为 `1.3.9`; - 页面显示的版本为 `1.4.0`;
- 页面显示验证标识 `v1.3.9-example-page`; - 页面显示验证标识 `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)
}
}
}
+68 -27
View File
@@ -51,11 +51,13 @@ import (
// 并在失败信息里带上最后一次的具体原因与常见的显式指定目录。 // 并在失败信息里带上最后一次的具体原因与常见的显式指定目录。
// 0.12.8:Agent Bearer Token 改为常量时间比较,避免认证路径上的长度/内容时序泄漏。 // 0.12.8:Agent Bearer Token 改为常量时间比较,避免认证路径上的长度/内容时序泄漏。
// 0.12.9:限流状态文件损坏时拒绝覆盖,避免保存单条规则时清空其他域名的限流配置。 // 0.12.9:限流状态文件损坏时拒绝覆盖,避免保存单条规则时清空其他域名的限流配置。
const AgentVersion = "0.12.9" // 0.13.0:默认只监听回环地址,非回环监听必须显式允许;状态接口暴露监听范围与传输安全。
const AgentVersion = "0.13.0"
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。 // Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
type Config struct { type Config struct {
ListenAddr string ListenAddr string
AllowRemote bool
Token string Token string
NginxConfDir string NginxConfDir string
NginxBinary string NginxBinary string
@@ -67,8 +69,9 @@ type Config struct {
// Server 暴露 agent HTTP 服务。 // Server 暴露 agent HTTP 服务。
type Server struct { type Server struct {
cfg Config cfg Config
mu sync.Mutex listenLoopback bool
mu sync.Mutex
// metrics 采样缓存:CPU 占用率需要两次采样,间隔内的重复请求直接复用上次结果 // metrics 采样缓存:CPU 占用率需要两次采样,间隔内的重复请求直接复用上次结果
metricsMu sync.Mutex metricsMu sync.Mutex
metricsAt time.Time metricsAt time.Time
@@ -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) { func NewServer(cfg Config) (*Server, error) {
if strings.TrimSpace(cfg.ListenAddr) == "" { 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) == "" { if strings.TrimSpace(cfg.NginxConfDir) == "" {
// 目录探测失败不阻断启动:agent 照常提供状态接口,路由写入时重试探测(systemd PATH 下可能找不到 nginx,写入前会补全路径) // 目录探测失败不阻断启动:agent 照常提供状态接口,路由写入时重试探测(systemd PATH 下可能找不到 nginx,写入前会补全路径)
@@ -164,13 +171,43 @@ func NewServer(cfg Config) (*Server, error) {
return nil, fmt.Errorf("create agent directory: %w", err) return nil, fmt.Errorf("create agent directory: %w", err)
} }
} }
server := &Server{cfg: cfg} server := &Server{cfg: cfg, listenLoopback: loopback}
// 启动后台预解析目录(不阻断监听);每次启动都会用最新探测逻辑重新解析并覆盖落盘缓存, // 启动后台预解析目录(不阻断监听);每次启动都会用最新探测逻辑重新解析并覆盖落盘缓存,
// 历史错误目录的缓存会被自动修正。 // 历史错误目录的缓存会被自动修正。
server.preloadNginxConfDir() server.preloadNginxConfDir()
return server, nil 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 { func (s *Server) Handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/ping", s.auth(s.ping)) mux.HandleFunc("GET /api/v1/ping", s.auth(s.ping))
@@ -242,28 +279,32 @@ func (s *Server) status(w http.ResponseWriter, _ *http.Request) {
nginxProbe := s.probeNginx() nginxProbe := s.probeNginx()
frpsActive := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil frpsActive := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"service": "fonu-agent", "service": "fonu-agent",
"agent_version": AgentVersion, "agent_version": AgentVersion,
"nginx_binary": s.cfg.NginxBinary, "listen_addr": s.cfg.ListenAddr,
"nginx_conf_dir": s.cfg.NginxConfDir, "listen_scope": listenScope(s.listenLoopback),
"nginx_ok": nginxProbe.OK, "remote_access_enabled": s.cfg.AllowRemote,
"nginx_missing": nginxProbe.Missing, "transport_security": transportSecurity(s.listenLoopback),
"nginx_running": nginxProbe.Running, "nginx_binary": s.cfg.NginxBinary,
"nginx_path": nginxProbe.Path, "nginx_conf_dir": s.cfg.NginxConfDir,
"nginx_version": nginxProbe.Version, "nginx_ok": nginxProbe.OK,
"nginx_error": nginxProbe.Error, "nginx_missing": nginxProbe.Missing,
"frps_binary": s.cfg.FrpsBinary, "nginx_running": nginxProbe.Running,
"frps_config_path": s.cfg.FrpsConfigPath, "nginx_path": nginxProbe.Path,
"ports": ports, "nginx_version": nginxProbe.Version,
"frps_active": frpsActive, "nginx_error": nginxProbe.Error,
"frps_vhost_hint": "curl -H 'Host: 你的域名' http://127.0.0.1:8080 可测试 frps vhost 是否通", "frps_binary": s.cfg.FrpsBinary,
"frps_unit_exec": strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", "-p", "ExecStart", "--value")), "frps_config_path": s.cfg.FrpsConfigPath,
"frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil, "ports": ports,
"frps_version": strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)), "frps_active": frpsActive,
"frps_log_tail": strings.TrimSpace(journalTailDesc("frps", frpsLogTailLines)), "frps_vhost_hint": "curl -H 'Host: 你的域名' http://127.0.0.1:8080 可测试 frps vhost 是否通",
"dashboard_addr": strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)), "frps_unit_exec": strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", "-p", "ExecStart", "--value")),
"routes": s.routeNames(), "frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil,
"checked_at": time.Now().UTC().Format(time.RFC3339), "frps_version": strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)),
"frps_log_tail": strings.TrimSpace(journalTailDesc("frps", frpsLogTailLines)),
"dashboard_addr": strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)),
"routes": s.routeNames(),
"checked_at": time.Now().UTC().Format(time.RFC3339),
}) })
} }
+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}) 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) { func (h *FRPMultiHandler) AgentStatus(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id")) id, err := parseID(r.PathValue("id"))
if err != nil { 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/uninstall", frpHandler.AgentUninstall)
protect("POST /api/frpmulti/servers/{id}/agent/ssh-diagnose", frpHandler.AgentSSHDiagnose) 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/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/status", frpHandler.AgentStatus)
protect("GET /api/frpmulti/servers/{id}/agent/routes", frpHandler.AgentRoutes) protect("GET /api/frpmulti/servers/{id}/agent/routes", frpHandler.AgentRoutes)
protect("GET /api/frpmulti/servers/{id}/agent/routes/health", frpHandler.AgentRouteHealth) 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()) ctx, cancel := context.WithCancel(context.Background())
frpMultiSvc.SetBaseContext(ctx) frpMultiSvc.SetBaseContext(ctx)
go frpMultiSvc.StartAgentTunnels(ctx)
intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval) intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
if intervalMinutes <= 0 { if intervalMinutes <= 0 {
intervalMinutes = 5 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 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 急救:进程、端口、服务日志尾部。 // Diagnose agent 失联时走 SSH 急救:进程、端口、服务日志尾部。
func (i AgentInstaller) Diagnose(ctx context.Context) (string, error) { 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'") 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) # 用 /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" printf 'FONU_AGENT_TOKEN=%s\n' "$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" > "$CONF_FILE"
fi 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' cat > "$UNIT" <<'UNIT'
[Unit] [Unit]
Description=Fonu FRPS management agent 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.
+38 -32
View File
@@ -84,38 +84,44 @@ type DownloadTask struct {
} }
type Server struct { type Server struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
AgentURL string `json:"agent_url,omitempty"` AgentURL string `json:"agent_url,omitempty"`
AgentConfigured bool `json:"agent_configured"` AgentTransport string `json:"agent_transport"`
SSHConfigured bool `json:"ssh_configured"` AgentLocalPort int `json:"agent_local_port,omitempty"`
MgmtEnabled bool `json:"mgmt_enabled"` AgentTLSPin string `json:"agent_tls_pin,omitempty"`
SSHHost string `json:"ssh_host,omitempty"` AgentListenAddr string `json:"agent_listen_addr,omitempty"`
SSHPort int `json:"ssh_port,omitempty"` AgentTransportState string `json:"agent_transport_state"`
SSHUser string `json:"ssh_user,omitempty"` AgentTransportError string `json:"agent_transport_error,omitempty"`
SSHAuth string `json:"ssh_auth,omitempty"` AgentConfigured bool `json:"agent_configured"`
ServerAddr string `json:"server_addr"` SSHConfigured bool `json:"ssh_configured"`
ServerPort int `json:"server_port"` MgmtEnabled bool `json:"mgmt_enabled"`
TLS bool `json:"tls_enabled"` SSHHost string `json:"ssh_host,omitempty"`
TLSServerName string `json:"tls_server_name,omitempty"` SSHPort int `json:"ssh_port,omitempty"`
Enabled bool `json:"enabled"` SSHUser string `json:"ssh_user,omitempty"`
HasToken bool `json:"has_token"` SSHAuth string `json:"ssh_auth,omitempty"`
HasOIDCSecret bool `json:"has_oidc_client_secret"` ServerAddr string `json:"server_addr"`
DashboardHasPwd bool `json:"dashboard_has_pwd"` ServerPort int `json:"server_port"`
Options ServerOptions `json:"options"` TLS bool `json:"tls_enabled"`
BootStatus string `json:"boot_status"` TLSServerName string `json:"tls_server_name,omitempty"`
DesiredState string `json:"desired_state"` Enabled bool `json:"enabled"`
ProcessState string `json:"process_state"` HasToken bool `json:"has_token"`
ConnectionState string `json:"connection_state"` HasOIDCSecret bool `json:"has_oidc_client_secret"`
StateSource string `json:"state_source"` DashboardHasPwd bool `json:"dashboard_has_pwd"`
ProcessPID int `json:"process_pid,omitempty"` Options ServerOptions `json:"options"`
ExitCode int `json:"exit_code,omitempty"` BootStatus string `json:"boot_status"`
LastConnectedAt string `json:"last_connected_at,omitempty"` DesiredState string `json:"desired_state"`
LastDisconnectedAt string `json:"last_disconnected_at,omitempty"` ProcessState string `json:"process_state"`
LastError string `json:"last_error,omitempty"` ConnectionState string `json:"connection_state"`
LastStartedAt string `json:"last_started_at,omitempty"` StateSource string `json:"state_source"`
CreatedAt time.Time `json:"created_at"` ProcessPID int `json:"process_pid,omitempty"`
UpdatedAt time.Time `json:"updated_at"` ExitCode int `json:"exit_code,omitempty"`
LastConnectedAt string `json:"last_connected_at,omitempty"`
LastDisconnectedAt string `json:"last_disconnected_at,omitempty"`
LastError string `json:"last_error,omitempty"`
LastStartedAt string `json:"last_started_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
type ServerRuntimeStatus struct { type ServerRuntimeStatus struct {
+212 -5
View File
@@ -49,6 +49,9 @@ type Service struct {
agentMu sync.Mutex agentMu sync.Mutex
agentClients map[int64]cachedAgentClient agentClients map[int64]cachedAgentClient
tunnelMu sync.Mutex
tunnels map[int64]*agentTunnel
// 诊断短 TTL 缓存与并发上限,避免列表页轮询叠加外部探测 // 诊断短 TTL 缓存与并发上限,避免列表页轮询叠加外部探测
diagMu sync.Mutex diagMu sync.Mutex
diagCache map[int64]diagnosticsCacheEntry diagCache map[int64]diagnosticsCacheEntry
@@ -79,6 +82,7 @@ func NewService(cfg config.Config, store *Store, secretBox *secret.Box, logger *
trafficClient: newFRPSTrafficClient(), trafficClient: newFRPSTrafficClient(),
trafficLast: map[int64]trafficSample{}, trafficLast: map[int64]trafficSample{},
diagCache: map[int64]diagnosticsCacheEntry{}, diagCache: map[int64]diagnosticsCacheEntry{},
tunnels: map[int64]*agentTunnel{},
} }
return service return service
} }
@@ -356,6 +360,10 @@ func (s *Service) enrichServerRuntime(ctx context.Context, server *Server) {
server.LastDisconnectedAt = runtime.LastDisconnectedAt server.LastDisconnectedAt = runtime.LastDisconnectedAt
server.LastError = runtime.LastError server.LastError = runtime.LastError
server.LastStartedAt = runtime.LastStartedAt 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) { 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 { if !server.Enabled {
_ = s.managerForServer(id).Stop(ctx) _ = s.managerForServer(id).Stop(ctx)
} }
if !server.MgmtEnabled || server.AgentTransport != "tunnel" {
s.stopAgentTunnel(id)
}
return server, s.persistConfig(ctx) return server, s.persistConfig(ctx)
} }
func (s *Service) DeleteServer(ctx context.Context, id int64) error { func (s *Service) DeleteServer(ctx context.Context, id int64) error {
s.stopAgentTunnel(id)
if err := s.managerForServer(id).Stop(ctx); err != nil { if err := s.managerForServer(id).Stop(ctx); err != nil {
return err return err
} }
@@ -732,10 +744,31 @@ func (s *Service) AgentInstall(ctx context.Context, serverID int64) (Server, err
if err != nil { if err != nil {
return Server{}, err 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{ input := ServerInput{
Name: server.Name, ServerAddr: server.ServerAddr, ServerPort: server.ServerPort, Name: server.Name, ServerAddr: server.ServerAddr, ServerPort: server.ServerPort,
TLS: server.TLS, TLSServerName: server.TLSServerName, Enabled: server.Enabled, 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, AgentToken: token, SSHHost: server.SSHHost, SSHPort: server.SSHPort, SSHUser: server.SSHUser,
SSHAuth: server.SSHAuth, MgmtEnabled: true, SSHAuth: server.SSHAuth, MgmtEnabled: true,
} }
@@ -743,15 +776,178 @@ func (s *Service) AgentInstall(ctx context.Context, serverID int64) (Server, err
if err != nil { if err != nil {
return Server{}, err 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 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) { func (s *Service) AgentUninstall(ctx context.Context, serverID int64, keepData bool) (string, error) {
_, installer, err := s.sshInstaller(ctx, serverID) _, installer, err := s.sshInstaller(ctx, serverID)
if err != nil { if err != nil {
return "", err 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) { 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) { func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient, bool) {
server, err := s.store.GetServer(ctx, serverID) 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 return nil, false
} }
encoded, err := s.store.AgentToken(ctx, serverID) 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 == "" { if err != nil || token == "" {
return nil, false 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 握手 // 复用同一个 http.Client:批量同步 N 条路由时不再每次新建 Transport 与 TLS 握手
fingerprint := server.AgentURL + "|" + encoded fingerprint := baseURL + "|" + encoded
s.agentMu.Lock() s.agentMu.Lock()
defer s.agentMu.Unlock() defer s.agentMu.Unlock()
if s.agentClients == nil { 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 { if cached, ok := s.agentClients[serverID]; ok && cached.fingerprint == fingerprint {
return cached.client, true return cached.client, true
} }
client := NewAgentClient(server.AgentURL, token) client := NewAgentClient(baseURL, token)
s.agentClients[serverID] = cachedAgentClient{fingerprint: fingerprint, client: client} s.agentClients[serverID] = cachedAgentClient{fingerprint: fingerprint, client: client}
return client, true return client, true
} }
@@ -1421,6 +1627,7 @@ func (s *Service) AutoStart(ctx context.Context) error {
} }
func (s *Service) Stop(ctx context.Context) error { func (s *Service) Stop(ctx context.Context) error {
s.stopAgentTunnels()
_ = s.ReconcileRuntime(ctx) _ = s.ReconcileRuntime(ctx)
servers, err := s.store.ListServers(ctx) servers, err := s.store.ListServers(ctx)
if err != nil { 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 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) { func (s *Store) Token(ctx context.Context, id int64) (string, error) {
var token string var token string
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(auth_token_enc, '') FROM frp_servers WHERE id = ?`, id).Scan(&token) 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(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'), 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), 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 COALESCE(config_json, '{}'), created_at, updated_at
FROM frp_servers` FROM frp_servers`
@@ -439,7 +459,7 @@ func scanServer(row interface{ Scan(dest ...any) error }) (Server, error) {
var server Server var server Server
var tls, enabled, token, oidcSecret, dashboardPwd, agentConfigured, sshConfigured, mgmtEnabled int var tls, enabled, token, oidcSecret, dashboardPwd, agentConfigured, sshConfigured, mgmtEnabled int
var configJSON, created, updated string 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 { if err != nil {
return Server{}, err 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" printf 'FONU_AGENT_TOKEN=%s\n' "$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" > "$CONF_FILE"
fi 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' cat > "$UNIT" <<'UNIT'
[Unit] [Unit]
Description=Fonu FRPS management agent Description=Fonu FRPS management agent
@@ -60,5 +66,5 @@ systemctl restart fonu-agent
systemctl enable fonu-agent systemctl enable fonu-agent
TOKEN=$(sed -n 's/^FONU_AGENT_TOKEN=//p' "$CONF_FILE") TOKEN=$(sed -n 's/^FONU_AGENT_TOKEN=//p' "$CONF_FILE")
echo "fonu-agent 已安装并启动" 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" echo "Token: $TOKEN"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "fonu-web", "name": "fonu-web",
"version": "1.3.9", "version": "1.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "fonu-web", "name": "fonu-web",
"version": "1.3.9", "version": "1.4.0",
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.11.1", "@codemirror/commands": "^6.11.1",
"@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-javascript": "^6.2.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "fonu-web", "name": "fonu-web",
"private": true, "private": true,
"version": "1.3.9", "version": "1.4.0",
"packageManager": "npm@10.9.4", "packageManager": "npm@10.9.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
+2
View File
@@ -305,6 +305,8 @@ export const api = {
exportFrpConfig: () => request<{ content: string }>('/api/frpmulti/config/export'), exportFrpConfig: () => request<{ content: string }>('/api/frpmulti/config/export'),
getFrpFRPSConfig: (id: number) => request<{ content: string }>(`/api/frpmulti/servers/${id}/frps-config`), 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' }), 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' }), 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' }), 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' }), 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 { export interface FrpServer {
agent_url?: string 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 agent_configured?: boolean
ssh_configured?: boolean ssh_configured?: boolean
mgmt_enabled?: boolean mgmt_enabled?: boolean
+42 -3
View File
@@ -241,11 +241,22 @@
<FonuCard class="section-card" title="Agent 生命周期(SSH)"> <FonuCard class="section-card" title="Agent 生命周期(SSH)">
<div class="section-body"> <div class="section-body">
<p class="form-hint"> <p class="form-hint">
首次安装、升级、卸载需要 SSH(在「内网穿透」页服务端表单的「公网 Agent 管理」区填写);安装成功后自动回填 Agent 地址与 Token。 首次安装、升级、卸载需要 SSH(在「内网穿透」页服务端表单的「公网 Agent 管理」区填写);安装成功后自动建立 SSH 隧道,不会默认暴露公网 7700。
日常管理走 Agent API,无需 SSH。SSH 信息仅用于生命周期操作,凭据加密存储、不回显。 SSH 信息仅用于生命周期和隧道,凭据加密存储、不回显。
</p> </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"> <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 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" :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-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)"> <n-popconfirm @positive-click="uninstallAgent(server, false)">
@@ -674,7 +685,7 @@ use([CanvasRenderer, LineChart, ScatterChart, GridComponent, TooltipComponent])
// 操作动作:按动作粒度控制 loading,保证"只有被点的按钮转圈" // 操作动作:按动作粒度控制 loading,保证"只有被点的按钮转圈"
type Action = type Action =
| 'probe' | 'agent-install' | 'agent-uninstall' | 'diagnose' | '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 } interface OpOutput { at: string; source: string; text: string; ok: boolean }
@@ -1396,6 +1407,31 @@ function serverStartupKind(server: FrpServer): StatusKind {
return 'unknown' 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) { function processStateText(server: FrpServer) {
return ({ starting: '启动中', running: '运行中', stopped: '已停止', error: '异常', unknown: '未知' } as Record<string, string>)[server.process_state] ?? '未知' 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-grid label { color: var(--fonu-text-secondary); font-size: 12px; }
.field-tags { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .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-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; } .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-install-row { display: flex; align-items: center; gap: 10px; margin-top: var(--fonu-space-3); flex-wrap: wrap; }
.frps-version-select { width: 200px; } .frps-version-select { width: 200px; }
+8 -8
View File
@@ -8,7 +8,7 @@
</template> </template>
</PageHeader> </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>。 当前页面由 v{{ version }} 提供,验证标识为 <span class="mono">{{ verificationMarker }}</span>。
如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。 如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。
</n-alert> </n-alert>
@@ -19,7 +19,7 @@
<strong class="example-proof__value">{{ verificationRound }}</strong> <strong class="example-proof__value">{{ verificationRound }}</strong>
</div> </div>
<div class="example-proof__hint"> <div class="example-proof__hint">
看到这个新区块,说明前端资源已经从旧版本切换到 v1.3.9。 看到这个新区块,说明前端资源已经从旧版本切换到 v1.4.0。
</div> </div>
</div> </div>
@@ -33,16 +33,16 @@
<StatCard label="本次验证标识" :value="verificationMarker" sub="用于区分升级前后的前端资源" tone="blue" value-small> <StatCard label="本次验证标识" :value="verificationMarker" sub="用于区分升级前后的前端资源" tone="blue" value-small>
<template #icon><n-icon :component="FlashOutline" /></template> <template #icon><n-icon :component="FlashOutline" /></template>
</StatCard> </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> <template #icon><n-icon :component="CheckmarkCircleOutline" /></template>
</StatCard> </StatCard>
</div> </div>
<FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化"> <FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化">
<ul class="example-changes"> <ul class="example-changes">
<li>页面版本提示更新为 v1.3.9。</li> <li>页面版本提示更新为 v1.4.0。</li>
<li>「本次升级验证」区块和页面状态同步更新为 v1.3.9。</li> <li>「本次升级验证」区块和页面状态同步更新为 v1.4.0。</li>
<li>验证标识更新为 <span class="mono">v1.3.9-example-page</span>。</li> <li>验证标识更新为 <span class="mono">v1.4.0-example-page</span>。</li>
</ul> </ul>
</FonuCard> </FonuCard>
@@ -87,8 +87,8 @@ const message = useMessage()
const version = ref(__APP_VERSION__) const version = ref(__APP_VERSION__)
const checkedAt = ref('') const checkedAt = ref('')
const refreshing = ref(false) const refreshing = ref(false)
const verificationRound = 'v1.3.9' const verificationRound = 'v1.4.0'
const verificationMarker = 'v1.3.9-example-page' const verificationMarker = 'v1.4.0-example-page'
async function refreshVersion() { async function refreshVersion() {
refreshing.value = true refreshing.value = true