This commit is contained in:
@@ -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
|
||||
|
||||
### 修复
|
||||
|
||||
+13
-2
@@ -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
@@ -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 三项不做 |
|
||||
|
||||
@@ -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 的安全目标。
|
||||
@@ -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`。
|
||||
|
||||
@@ -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`;
|
||||
- 页面显示独立的「本次升级验证」区块;
|
||||
- 「升级验证」显示为「通过」。
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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.
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 '';
|
||||
@@ -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"
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user