diff --git a/CHANGELOG.md b/CHANGELOG.md index 4824d5a..d2f0ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### 修复 diff --git a/VERSION b/VERSION index d4c4950..88c5fb8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.9 +1.4.0 diff --git a/cmd/fonu-agent/main.go b/cmd/fonu-agent/main.go index 943e2c2..bb17fa7 100644 --- a/cmd/fonu-agent/main.go +++ b/cmd/fonu-agent/main.go @@ -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 + } +} diff --git a/docs/README.md b/docs/README.md index 21acdbe..0923f64 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 三项不做 | diff --git a/docs/agent-listen-strategy-plan.md b/docs/agent-listen-strategy-plan.md new file mode 100644 index 0000000..95452df --- /dev/null +++ b/docs/agent-listen-strategy-plan.md @@ -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://: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:` +- https-pin:`https://:` +- http:`http://:7700` + +--- + +## 六、默认模式:SSH 隧道 + +### 6.1 为什么默认走 SSH + +安装 Agent 已经必须使用 SSH,因此: + +- 不新增用户操作; +- 不需要公网开放 7700; +- 不需要证书和域名; +- 不依赖云安全组修改; +- SSH 主机密钥已经使用 TOFU 校验; +- Token 继续保留,形成双层认证。 + +### 6.2 本地端口分配 + +每个 VPS 服务端分配一个稳定的本地回环端口: + +```text +127.0.0.1: +``` + +分配方式建议: + +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 的安全目标。 diff --git a/docs/improvement-roadmap-2026-09-27.md b/docs/improvement-roadmap-2026-09-27.md index ba41b9b..a0a9e90 100644 --- a/docs/improvement-roadmap-2026-09-27.md +++ b/docs/improvement-roadmap-2026-09-27.md @@ -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`。 diff --git a/docs/system-upgrade.md b/docs/system-upgrade.md index 8e2ecce..54fe0d3 100644 --- a/docs/system-upgrade.md +++ b/docs/system-upgrade.md @@ -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`; - 页面显示独立的「本次升级验证」区块; - 「升级验证」显示为「通过」。 diff --git a/internal/agent/listen_test.go b/internal/agent/listen_test.go new file mode 100644 index 0000000..775fa69 --- /dev/null +++ b/internal/agent/listen_test.go @@ -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) + } + } +} diff --git a/internal/agent/server.go b/internal/agent/server.go index 57bed92..1f04198 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -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 @@ -67,8 +69,9 @@ type Config struct { // Server 暴露 agent HTTP 服务。 type Server struct { - cfg Config - mu sync.Mutex + cfg Config + listenLoopback bool + mu sync.Mutex // metrics 采样缓存:CPU 占用率需要两次采样,间隔内的重复请求直接复用上次结果 metricsMu sync.Mutex metricsAt time.Time @@ -128,7 +131,11 @@ var domainPattern = regexp.MustCompile(`^(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9]) func NewServer(cfg Config) (*Server, error) { 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)) @@ -242,28 +279,32 @@ func (s *Server) status(w http.ResponseWriter, _ *http.Request) { nginxProbe := s.probeNginx() frpsActive := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil writeJSON(w, http.StatusOK, map[string]any{ - "service": "fonu-agent", - "agent_version": AgentVersion, - "nginx_binary": s.cfg.NginxBinary, - "nginx_conf_dir": s.cfg.NginxConfDir, - "nginx_ok": nginxProbe.OK, - "nginx_missing": nginxProbe.Missing, - "nginx_running": nginxProbe.Running, - "nginx_path": nginxProbe.Path, - "nginx_version": nginxProbe.Version, - "nginx_error": nginxProbe.Error, - "frps_binary": s.cfg.FrpsBinary, - "frps_config_path": s.cfg.FrpsConfigPath, - "ports": ports, - "frps_active": frpsActive, - "frps_vhost_hint": "curl -H 'Host: 你的域名' http://127.0.0.1:8080 可测试 frps vhost 是否通", - "frps_unit_exec": strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", "-p", "ExecStart", "--value")), - "frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil, - "frps_version": strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)), - "frps_log_tail": strings.TrimSpace(journalTailDesc("frps", frpsLogTailLines)), - "dashboard_addr": strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)), - "routes": s.routeNames(), - "checked_at": time.Now().UTC().Format(time.RFC3339), + "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, + "nginx_missing": nginxProbe.Missing, + "nginx_running": nginxProbe.Running, + "nginx_path": nginxProbe.Path, + "nginx_version": nginxProbe.Version, + "nginx_error": nginxProbe.Error, + "frps_binary": s.cfg.FrpsBinary, + "frps_config_path": s.cfg.FrpsConfigPath, + "ports": ports, + "frps_active": frpsActive, + "frps_vhost_hint": "curl -H 'Host: 你的域名' http://127.0.0.1:8080 可测试 frps vhost 是否通", + "frps_unit_exec": strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", "-p", "ExecStart", "--value")), + "frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil, + "frps_version": strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)), + "frps_log_tail": strings.TrimSpace(journalTailDesc("frps", frpsLogTailLines)), + "dashboard_addr": strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)), + "routes": s.routeNames(), + "checked_at": time.Now().UTC().Format(time.RFC3339), }) } diff --git a/internal/api/frpmulti_handler.go b/internal/api/frpmulti_handler.go index 1b15df9..9319043 100644 --- a/internal/api/frpmulti_handler.go +++ b/internal/api/frpmulti_handler.go @@ -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 { diff --git a/internal/api/router.go b/internal/api/router.go index 41dcd38..b60a60e 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) diff --git a/internal/app/app.go b/internal/app/app.go index 562aab2..66d4b25 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 diff --git a/internal/frp/agent_installer.go b/internal/frp/agent_installer.go index 66d9967..d8f852a 100644 --- a/internal/frp/agent_installer.go +++ b/internal/frp/agent_installer.go @@ -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 diff --git a/internal/frp/agent_tunnel.go b/internal/frp/agent_tunnel.go new file mode 100644 index 0000000..177e2a8 --- /dev/null +++ b/internal/frp/agent_tunnel.go @@ -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() +} diff --git a/internal/frp/agent_tunnel_test.go b/internal/frp/agent_tunnel_test.go new file mode 100644 index 0000000..bffbd0b --- /dev/null +++ b/internal/frp/agent_tunnel_test.go @@ -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() + } +} diff --git a/internal/frp/agentbin/fonu-agent-linux-amd64 b/internal/frp/agentbin/fonu-agent-linux-amd64 index f2ce97f..99ee52f 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-amd64 and b/internal/frp/agentbin/fonu-agent-linux-amd64 differ diff --git a/internal/frp/agentbin/fonu-agent-linux-arm64 b/internal/frp/agentbin/fonu-agent-linux-arm64 index accf68f..97abb80 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-arm64 and b/internal/frp/agentbin/fonu-agent-linux-arm64 differ diff --git a/internal/frp/model.go b/internal/frp/model.go index b857647..0a6833f 100644 --- a/internal/frp/model.go +++ b/internal/frp/model.go @@ -84,38 +84,44 @@ type DownloadTask struct { } type Server struct { - ID int64 `json:"id"` - Name string `json:"name"` - AgentURL string `json:"agent_url,omitempty"` - AgentConfigured bool `json:"agent_configured"` - SSHConfigured bool `json:"ssh_configured"` - MgmtEnabled bool `json:"mgmt_enabled"` - SSHHost string `json:"ssh_host,omitempty"` - SSHPort int `json:"ssh_port,omitempty"` - SSHUser string `json:"ssh_user,omitempty"` - SSHAuth string `json:"ssh_auth,omitempty"` - ServerAddr string `json:"server_addr"` - ServerPort int `json:"server_port"` - TLS bool `json:"tls_enabled"` - TLSServerName string `json:"tls_server_name,omitempty"` - Enabled bool `json:"enabled"` - HasToken bool `json:"has_token"` - HasOIDCSecret bool `json:"has_oidc_client_secret"` - DashboardHasPwd bool `json:"dashboard_has_pwd"` - Options ServerOptions `json:"options"` - BootStatus string `json:"boot_status"` - DesiredState string `json:"desired_state"` - ProcessState string `json:"process_state"` - ConnectionState string `json:"connection_state"` - StateSource string `json:"state_source"` - ProcessPID int `json:"process_pid,omitempty"` - ExitCode int `json:"exit_code,omitempty"` - LastConnectedAt string `json:"last_connected_at,omitempty"` - LastDisconnectedAt string `json:"last_disconnected_at,omitempty"` - LastError string `json:"last_error,omitempty"` - LastStartedAt string `json:"last_started_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + 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"` + SSHHost string `json:"ssh_host,omitempty"` + SSHPort int `json:"ssh_port,omitempty"` + SSHUser string `json:"ssh_user,omitempty"` + SSHAuth string `json:"ssh_auth,omitempty"` + ServerAddr string `json:"server_addr"` + ServerPort int `json:"server_port"` + TLS bool `json:"tls_enabled"` + TLSServerName string `json:"tls_server_name,omitempty"` + Enabled bool `json:"enabled"` + HasToken bool `json:"has_token"` + HasOIDCSecret bool `json:"has_oidc_client_secret"` + DashboardHasPwd bool `json:"dashboard_has_pwd"` + Options ServerOptions `json:"options"` + BootStatus string `json:"boot_status"` + DesiredState string `json:"desired_state"` + ProcessState string `json:"process_state"` + ConnectionState string `json:"connection_state"` + StateSource string `json:"state_source"` + ProcessPID int `json:"process_pid,omitempty"` + ExitCode int `json:"exit_code,omitempty"` + LastConnectedAt string `json:"last_connected_at,omitempty"` + LastDisconnectedAt string `json:"last_disconnected_at,omitempty"` + LastError string `json:"last_error,omitempty"` + LastStartedAt string `json:"last_started_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ServerRuntimeStatus struct { diff --git a/internal/frp/service.go b/internal/frp/service.go index 589f4b1..c6fe839 100644 --- a/internal/frp/service.go +++ b/internal/frp/service.go @@ -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 { diff --git a/internal/frp/store.go b/internal/frp/store.go index 2edf2a2..63982d8 100644 --- a/internal/frp/store.go +++ b/internal/frp/store.go @@ -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 } diff --git a/migrations/028_agent_transport.sql b/migrations/028_agent_transport.sql new file mode 100644 index 0000000..0fd7028 --- /dev/null +++ b/migrations/028_agent_transport.sql @@ -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 ''; diff --git a/scripts/agent-install.sh b/scripts/agent-install.sh index b719a67..a7b7a0b 100644 --- a/scripts/agent-install.sh +++ b/scripts/agent-install.sh @@ -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" diff --git a/web/package-lock.json b/web/package-lock.json index 3a41c37..bccbdca 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index b06de39..2cdf69a 100644 --- a/web/package.json +++ b/web/package.json @@ -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": { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2173f26..1363442 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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(`/api/frpmulti/servers/${id}/agent/tunnel/restart`, { method: 'POST' }), probeFrpAgent: (id: number) => request>(`/api/frpmulti/servers/${id}/agent/probe`, { method: 'POST' }), installFrpAgent: (id: number) => request(`/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' }), diff --git a/web/src/api/types.ts b/web/src/api/types.ts index eaf1e40..980e692 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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 diff --git a/web/src/views/AgentView.vue b/web/src/views/AgentView.vue index 1fc662c..fbcf6ab 100644 --- a/web/src/views/AgentView.vue +++ b/web/src/views/AgentView.vue @@ -241,11 +241,22 @@

- 首次安装、升级、卸载需要 SSH(在「内网穿透」页服务端表单的「公网 Agent 管理」区填写);安装成功后自动回填 Agent 地址与 Token。 - 日常管理走 Agent API,无需 SSH。SSH 信息仅用于生命周期操作,凭据加密存储、不回显。 + 首次安装、升级、卸载需要 SSH(在「内网穿透」页服务端表单的「公网 Agent 管理」区填写);安装成功后自动建立 SSH 隧道,不会默认暴露公网 7700。 + SSH 信息仅用于生命周期和隧道,凭据加密存储、不回显。

+
+
+
{{ agentTransportStateText(server) }}
+
{{ server.agent_transport === 'tunnel' && server.agent_local_port ? `127.0.0.1:${server.agent_local_port}` : '—' }}
+
{{ server.agent_listen_addr || '—' }}
+
+ + 当前仍是公网明文 HTTP。点击「升级 Agent」可自动迁移到 SSH 隧道安全模式。 + +

{{ server.agent_transport_error }}

{{ server.agent_configured ? '升级 Agent' : '安装 Agent' }} + 重启隧道 SSH 诊断 卸载(保留路由/证书) @@ -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)[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; } diff --git a/web/src/views/ExampleView.vue b/web/src/views/ExampleView.vue index c84aa36..3122075 100644 --- a/web/src/views/ExampleView.vue +++ b/web/src/views/ExampleView.vue @@ -8,7 +8,7 @@ - + 当前页面由 v{{ version }} 提供,验证标识为 {{ verificationMarker }}。 如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。 @@ -19,7 +19,7 @@ {{ verificationRound }}
- 看到这个新区块,说明前端资源已经从旧版本切换到 v1.3.9。 + 看到这个新区块,说明前端资源已经从旧版本切换到 v1.4.0。
@@ -33,16 +33,16 @@ - +
    -
  • 页面版本提示更新为 v1.3.9。
  • -
  • 「本次升级验证」区块和页面状态同步更新为 v1.3.9。
  • -
  • 验证标识更新为 v1.3.9-example-page。
  • +
  • 页面版本提示更新为 v1.4.0。
  • +
  • 「本次升级验证」区块和页面状态同步更新为 v1.4.0。
  • +
  • 验证标识更新为 v1.4.0-example-page。
@@ -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