This commit is contained in:
@@ -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
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
+13
-2
@@ -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
@@ -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 三项不做 |
|
||||||
|
|||||||
@@ -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 默认监听策略
|
## 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`。
|
||||||
|
|||||||
@@ -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`;
|
||||||
- 页面显示独立的「本次升级验证」区块;
|
- 页面显示独立的「本次升级验证」区块;
|
||||||
- 「升级验证」显示为「通过」。
|
- 「升级验证」显示为「通过」。
|
||||||
|
|
||||||
|
|||||||
@@ -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.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
|
||||||
@@ -68,6 +70,7 @@ type Config struct {
|
|||||||
// Server 暴露 agent HTTP 服务。
|
// Server 暴露 agent HTTP 服务。
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
|
listenLoopback bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
// metrics 采样缓存:CPU 占用率需要两次采样,间隔内的重复请求直接复用上次结果
|
// metrics 采样缓存:CPU 占用率需要两次采样,间隔内的重复请求直接复用上次结果
|
||||||
metricsMu sync.Mutex
|
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) {
|
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))
|
||||||
@@ -244,6 +281,10 @@ func (s *Server) status(w http.ResponseWriter, _ *http.Request) {
|
|||||||
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,
|
||||||
|
"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_binary": s.cfg.NginxBinary,
|
||||||
"nginx_conf_dir": s.cfg.NginxConfDir,
|
"nginx_conf_dir": s.cfg.NginxConfDir,
|
||||||
"nginx_ok": nginxProbe.OK,
|
"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})
|
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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
AgentURL string `json:"agent_url,omitempty"`
|
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"`
|
AgentConfigured bool `json:"agent_configured"`
|
||||||
SSHConfigured bool `json:"ssh_configured"`
|
SSHConfigured bool `json:"ssh_configured"`
|
||||||
MgmtEnabled bool `json:"mgmt_enabled"`
|
MgmtEnabled bool `json:"mgmt_enabled"`
|
||||||
|
|||||||
+212
-5
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
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"
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
Reference in New Issue
Block a user