feat(agent): 增加 HTTPS pin 模式并修复隧道端口竞态
CI / verify (push) Canceled after 0s

This commit is contained in:
2026-09-28 14:13:57 +08:00
parent 1bec91cc5e
commit fac9234e4d
23 changed files with 648 additions and 77 deletions
+18 -1
View File
@@ -2,6 +2,21 @@
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.1] - 2026-09-28
### 修复
- **SSH 隧道端口分配竞态**:原来先探测端口再关闭 listener,随后才真正启动隧道,两个安装动作或残留占用可能在两步之间抢到同一端口,报 `bind: Only one usage of each socket address`。现在改为分配端口时直接持有 listener 并交给隧道使用;17700 被占用时自动尝试后续端口
### 变更
- 版本号提升到 1.4.1;`web/package.json` 与 `web/package-lock.json` 同步对齐
- `AgentVersion` 保持 0.13.0
### 测试
- `internal/frp`:隧道 listener 复用、端口占用与释放回归测试
## [v1.4.0] - 2026-09-28 ## [v1.4.0] - 2026-09-28
### 新增 ### 新增
@@ -10,6 +25,8 @@ All notable changes to this project will be documented in this file.
- **SSH 隧道自动接入**:中央 Fonu 安装或升级 Agent 后自动分配 `17700-18700` 范围内的本地回环端口,通过现有 SSH 凭据建立隧道访问 VPS 的 `127.0.0.1:7700`,无需新增公网端口、证书或防火墙规则 - **SSH 隧道自动接入**:中央 Fonu 安装或升级 Agent 后自动分配 `17700-18700` 范围内的本地回环端口,通过现有 SSH 凭据建立隧道访问 VPS 的 `127.0.0.1:7700`,无需新增公网端口、证书或防火墙规则
- **隧道生命周期管理**:应用启动后自动恢复 tunnel transport;应用退出时统一关闭;每个服务端支持单独的「重启隧道」操作 - **隧道生命周期管理**:应用启动后自动恢复 tunnel transport;应用退出时统一关闭;每个服务端支持单独的「重启隧道」操作
- **旧公网 HTTP 自动迁移**:旧 Agent 点击「升级 Agent」后安装脚本会备份旧 `agent.env`、改写为回环监听并自动建立隧道;隧道验证失败时恢复旧监听配置 - **旧公网 HTTP 自动迁移**:旧 Agent 点击「升级 Agent」后安装脚本会备份旧 `agent.env`、改写为回环监听并自动建立隧道;隧道验证失败时恢复旧监听配置
- **可选 HTTPS 直连 + 证书指纹固定**:安装后可从 SSH 隧道切换到 HTTPS 直连。Fonu 自动生成 ECDSA 自签名证书、通过现有安全连接下发,VPS Agent 自动写入独立 Nginx TLS 反代;后续连接按证书 SHA-256 pin 校验,不使用 `InsecureSkipVerify` 裸跳过
- **防火墙提示边界**:HTTPS 模式明确提示云安全组需放行 TCP 7443;Fonu 不自动修改云安全组,主机防火墙自动处理仍留在后续阶段
### 变更 ### 变更
@@ -21,7 +38,7 @@ All notable changes to this project will be documented in this file.
### 测试 ### 测试
- `internal/agent`:回环默认值与远程监听保护 - `internal/agent`:回环默认值与远程监听保护
- `internal/frp` / `internal/app`:隧道生命周期、transport 存储与启动恢复编译验证 - `internal/frp` / `internal/app`:隧道生命周期、transport 存储、证书生成与 pin 校验测试
## [v1.3.9] - 2026-09-28 ## [v1.3.9] - 2026-09-28
+1 -1
View File
@@ -1 +1 @@
1.4.0 1.4.1
+2 -2
View File
@@ -1,7 +1,7 @@
# Fonu 文档索引 # Fonu 文档索引
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 > 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
> 最近更新:2026-09-28(v1.4.0) > 最近更新:2026-09-28(v1.4.1)
## 方案与设计(本目录) ## 方案与设计(本目录)
@@ -10,7 +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 高级模式待后续 | | `agent-listen-strategy-plan.md` | Agent 默认监听策略:回环监听、SSH 隧道优先、HTTPS pin 可选与自动迁移方案 | 阶段 1-4 已实施(v1.4.0);2.0.0 兼容清理待后续 |
| `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 三项不做 |
+1 -1
View File
@@ -5,7 +5,7 @@
> 当前 AgentVersion:0.13.0 > 当前 AgentVersion:0.13.0
> 目标:默认不暴露 Agent 管理接口,安装和升级过程自动建立安全连接,不要求用户手工配置端口、隧道或证书 > 目标:默认不暴露 Agent 管理接口,安装和升级过程自动建立安全连接,不要求用户手工配置端口、隧道或证书
> 实施状态(2026-09-28):阶段 1-3 的自动 SSH 隧道核心已随 v1.4.0 实施;HTTPS + pin 高级模式与防火墙辅助仍按阶段 4-5 推进。 > 实施状态(2026-09-28):阶段 1-4 已随 v1.4.0 实施;仅剩 2.0.0 的旧 HTTP 兼容清理和可选主机防火墙辅助。
--- ---
+1 -1
View File
@@ -460,7 +460,7 @@ func (s *Server) clearLimitState(domain string) error
> 方案文档:`agent-listen-strategy-plan.md`(2026-09-28,待实施) > 方案文档:`agent-listen-strategy-plan.md`(2026-09-28,待实施)
> 实施状态:阶段 1-3 已随 v1.4.0 实施(默认回环、SSH 隧道、旧 HTTP 自动迁移);HTTPS + pin 高级模式待后续。 > 实施状态:阶段 1-4 已随 v1.4.0 实施(默认回环、SSH 隧道、旧 HTTP 自动迁移、HTTPS + pin);仅剩 2.0.0 兼容清理。
### 3.5.1 现状 ### 3.5.1 现状
+4 -4
View File
@@ -238,16 +238,16 @@ FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu
## 10. 在线升级验证 ## 10. 在线升级验证
示例页面用于验证升级结果。v1.4.0 的验证流程: 示例页面用于验证升级结果。v1.4.1 的验证流程:
1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。 1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。
2. 确认远端 `main` 分支的 `VERSION` 为 `1.4.0`。 2. 确认远端 `main` 分支的 `VERSION` 为 `1.4.1`。
3. 在侧栏看到「一键升级」后点击并确认。 3. 在侧栏看到「一键升级」后点击并确认。
4. 等待升级弹窗显示成功,页面自动刷新。 4. 等待升级弹窗显示成功,页面自动刷新。
5. 打开侧栏「示例页面」,确认: 5. 打开侧栏「示例页面」,确认:
- 页面路由为 `/example`; - 页面路由为 `/example`;
- 页面显示的版本为 `1.4.0`; - 页面显示的版本为 `1.4.1`;
- 页面显示验证标识 `v1.4.0-example-page`; - 页面显示验证标识 `v1.4.1-example-page`;
- 页面显示独立的「本次升级验证」区块; - 页面显示独立的「本次升级验证」区块;
- 「升级验证」显示为「通过」。 - 「升级验证」显示为「通过」。
+5
View File
@@ -220,6 +220,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/v1/routes/{domain}/versions/{name}/rollback", s.auth(s.rollbackRoute)) mux.HandleFunc("POST /api/v1/routes/{domain}/versions/{name}/rollback", s.auth(s.rollbackRoute))
mux.HandleFunc("PUT /api/v1/china-cidr", s.auth(s.putChinaCIDR)) mux.HandleFunc("PUT /api/v1/china-cidr", s.auth(s.putChinaCIDR))
mux.HandleFunc("POST /api/v1/nginx/install", s.auth(s.installNginx)) mux.HandleFunc("POST /api/v1/nginx/install", s.auth(s.installNginx))
mux.HandleFunc("PUT /api/v1/transport/https", s.auth(s.putHTTPSProxy))
mux.HandleFunc("DELETE /api/v1/transport/https", s.auth(s.deleteHTTPSProxy))
mux.HandleFunc("DELETE /api/v1/routes/{domain}", s.auth(s.deleteRoute)) mux.HandleFunc("DELETE /api/v1/routes/{domain}", s.auth(s.deleteRoute))
mux.HandleFunc("PUT /api/v1/frps/config", s.auth(s.putFRPSConfig)) mux.HandleFunc("PUT /api/v1/frps/config", s.auth(s.putFRPSConfig))
mux.HandleFunc("POST /api/v1/frps/install", s.auth(s.installFRPS)) mux.HandleFunc("POST /api/v1/frps/install", s.auth(s.installFRPS))
@@ -278,6 +280,7 @@ func (s *Server) status(w http.ResponseWriter, _ *http.Request) {
ports := map[string]bool{"80": portListening(80), "443": portListening(443), "7000": portListening(7000), "8080": portListening(8080)} ports := map[string]bool{"80": portListening(80), "443": portListening(443), "7000": portListening(7000), "8080": portListening(8080)}
nginxProbe := s.probeNginx() nginxProbe := s.probeNginx()
frpsActive := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil frpsActive := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil
httpsState, httpsConfigured := readHTTPSProxyState(s.cfg.DataDir)
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,
@@ -285,6 +288,8 @@ func (s *Server) status(w http.ResponseWriter, _ *http.Request) {
"listen_scope": listenScope(s.listenLoopback), "listen_scope": listenScope(s.listenLoopback),
"remote_access_enabled": s.cfg.AllowRemote, "remote_access_enabled": s.cfg.AllowRemote,
"transport_security": transportSecurity(s.listenLoopback), "transport_security": transportSecurity(s.listenLoopback),
"https_proxy_configured": httpsConfigured,
"https_proxy_port": httpsState.Port,
"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,
+196
View File
@@ -0,0 +1,196 @@
package agent
import (
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/fonu/fonu/internal/fsutil"
)
const httpsProxyConfigName = "fonuagent-https.conf"
type httpsProxyRequest struct {
CertPEM string `json:"cert_pem"`
KeyPEM string `json:"key_pem"`
Port int `json:"port"`
}
type httpsProxyState struct {
Port int `json:"port"`
}
func (s *Server) putHTTPSProxy(w http.ResponseWriter, r *http.Request) {
var req httpsProxyRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if req.Port <= 0 || req.Port > 65535 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "端口需在 1-65535 之间"})
return
}
if _, err := tls.X509KeyPair([]byte(req.CertPEM), []byte(req.KeyPEM)); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "证书或私钥格式无效"})
return
}
if probe := s.probeNginx(); probe.Missing {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "VPS 未安装 Nginx,无法启用 HTTPS 直连"})
return
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.configureHTTPSProxy(req); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "port": req.Port})
}
func (s *Server) deleteHTTPSProxy(w http.ResponseWriter, _ *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.removeHTTPSProxy(); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) configureHTTPSProxy(req httpsProxyRequest) error {
dir, err := s.ensureNginxDir()
if err != nil {
return err
}
tlsDir := "/etc/fonu-agent/tls"
if err := os.MkdirAll(tlsDir, 0o750); err != nil {
return err
}
certPath := filepath.Join(tlsDir, "agent.crt")
keyPath := filepath.Join(tlsDir, "agent.key")
if err := atomicWrite(certPath, []byte(req.CertPEM), 0o640); err != nil {
return err
}
if err := atomicWrite(keyPath, []byte(req.KeyPEM), 0o600); err != nil {
return err
}
path := filepath.Join(dir, httpsProxyConfigName)
previous, readErr := os.ReadFile(path)
if _, err := fsutil.BackupVersioned(path, 10); err != nil {
return err
}
content := renderHTTPSProxy(req.Port, certPath, keyPath)
if err := atomicWrite(path, []byte(content), 0o640); err != nil {
return err
}
if err := s.validateNginx(); err != nil {
s.restoreHTTPSConfig(path, previous, readErr)
return fmt.Errorf("nginx config rejected: %w", err)
}
if err := s.reloadNginx(); err != nil {
s.restoreHTTPSConfig(path, previous, readErr)
return err
}
return writeHTTPSProxyState(s.cfg.DataDir, httpsProxyState{Port: req.Port})
}
func (s *Server) removeHTTPSProxy() error {
dir, err := s.ensureNginxDir()
if err != nil {
return err
}
path := filepath.Join(dir, httpsProxyConfigName)
previous, readErr := os.ReadFile(path)
if errors.Is(readErr, os.ErrNotExist) {
_ = os.Remove(httpsProxyStatePath(s.cfg.DataDir))
return nil
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if err := s.validateNginx(); err != nil {
s.restoreHTTPSConfig(path, previous, readErr)
return err
}
if err := s.reloadNginx(); err != nil {
s.restoreHTTPSConfig(path, previous, readErr)
return err
}
_ = os.Remove(httpsProxyStatePath(s.cfg.DataDir))
return nil
}
func (s *Server) restoreHTTPSConfig(path string, previous []byte, readErr error) {
if readErr == nil {
_ = atomicWrite(path, previous, 0o640)
} else {
_ = os.Remove(path)
}
_ = s.reloadNginx()
}
func (s *Server) ensureNginxDir() (string, error) {
dir := strings.TrimSpace(s.cfg.NginxConfDir)
if dir != "" {
return dir, nil
}
dir, err := detectNginxConfDir(s.nginxPath())
if err != nil {
return "", err
}
s.cfg.NginxConfDir = dir
return dir, nil
}
func renderHTTPSProxy(port int, certPath, keyPath string) string {
return fmt.Sprintf(`# Managed by fonu-agent. Do not edit.
server {
listen %d ssl;
server_name _;
ssl_certificate %s;
ssl_certificate_key %s;
ssl_protocols TLSv1.2 TLSv1.3;
location /api/v1/ {
proxy_pass http://127.0.0.1:7700;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Authorization $http_authorization;
}
}
`, port, certPath, keyPath)
}
func httpsProxyStatePath(dataDir string) string {
return filepath.Join(dataDir, "https_transport.json")
}
func writeHTTPSProxyState(dataDir string, state httpsProxyState) error {
if strings.TrimSpace(dataDir) == "" {
return nil
}
data, err := json.Marshal(state)
if err != nil {
return err
}
return atomicWrite(httpsProxyStatePath(dataDir), data, 0o640)
}
func readHTTPSProxyState(dataDir string) (httpsProxyState, bool) {
data, err := os.ReadFile(httpsProxyStatePath(dataDir))
if err != nil {
return httpsProxyState{}, false
}
var state httpsProxyState
if err := json.Unmarshal(data, &state); err != nil || state.Port <= 0 {
return httpsProxyState{}, false
}
return state, true
}
+31
View File
@@ -338,6 +338,37 @@ func (h *FRPMultiHandler) AgentTunnelRestart(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusOK, server) writeJSON(w, http.StatusOK, server)
} }
func (h *FRPMultiHandler) SetAgentTransport(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
return
}
var request struct {
Transport string `json:"transport"`
Port int `json:"port"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
var server frp.Server
switch strings.TrimSpace(request.Transport) {
case "tunnel":
server, err = h.svc.SwitchAgentToTunnel(r.Context(), id)
case "https-pin":
server, err = h.svc.EnableAgentHTTPS(r.Context(), id, request.Port)
default:
writeError(r, w, http.StatusBadRequest, "不支持的传输方式")
return
}
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, server)
}
func (h *FRPMultiHandler) AgentStatus(w http.ResponseWriter, r *http.Request) { func (h *FRPMultiHandler) AgentStatus(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id")) id, err := parseID(r.PathValue("id"))
if err != nil { if err != nil {
+1
View File
@@ -132,6 +132,7 @@ func NewRouter(deps Deps) http.Handler {
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("POST /api/frpmulti/servers/{id}/agent/tunnel/restart", frpHandler.AgentTunnelRestart)
protect("POST /api/frpmulti/servers/{id}/agent/transport", frpHandler.SetAgentTransport)
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)
+64 -2
View File
@@ -3,6 +3,11 @@ package frp
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -18,16 +23,56 @@ import (
type AgentClient struct { type AgentClient struct {
baseURL string baseURL string
token string token string
pin string
http *http.Client http *http.Client
} }
func NewAgentClient(baseURL, token string) *AgentClient { func NewAgentClient(baseURL, token string) *AgentClient {
return &AgentClient{ return newAgentClient(baseURL, token, "")
}
func NewAgentClientWithPin(baseURL, token, pin string) *AgentClient {
return newAgentClient(baseURL, token, pin)
}
func newAgentClient(baseURL, token, pin string) *AgentClient {
client := &AgentClient{
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
token: token, token: token,
pin: strings.ToLower(strings.TrimSpace(pin)),
// 路由写入链路含 nginx -T 自省 + -t 校验 + reload(宝塔带 lua/waf 模块时各步可达数秒),15s 会超时 // 路由写入链路含 nginx -T 自省 + -t 校验 + reload(宝塔带 lua/waf 模块时各步可达数秒),15s 会超时
http: &http.Client{Timeout: 60 * time.Second}, http: &http.Client{Timeout: 60 * time.Second},
} }
if client.pin != "" {
client.http.Transport = &http.Transport{TLSClientConfig: pinTLSConfig(client.pin)}
}
return client
}
func pinTLSConfig(pin string) *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: true,
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return fmt.Errorf("服务器未返回证书")
}
cert, err := x509.ParseCertificate(rawCerts[0])
if err != nil {
return err
}
now := time.Now()
if now.Before(cert.NotBefore) || now.After(cert.NotAfter) {
return fmt.Errorf("Agent HTTPS 证书不在有效期内")
}
sum := sha256.Sum256(rawCerts[0])
got := hex.EncodeToString(sum[:])
if subtle.ConstantTimeCompare([]byte(got), []byte(pin)) != 1 {
return fmt.Errorf("Agent HTTPS 证书指纹不匹配")
}
return nil
},
}
} }
func (c *AgentClient) request(ctx context.Context, method, path string, payload any, result any) error { func (c *AgentClient) request(ctx context.Context, method, path string, payload any, result any) error {
@@ -55,7 +100,10 @@ func (c *AgentClient) request(ctx context.Context, method, path string, payload
return err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) data, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return &statusError{code: resp.StatusCode, body: strings.TrimSpace(string(data))} return &statusError{code: resp.StatusCode, body: strings.TrimSpace(string(data))}
} }
@@ -71,6 +119,20 @@ func (c *AgentClient) Ping(ctx context.Context) error {
return c.request(ctx, http.MethodGet, "/api/v1/ping", nil, nil) return c.request(ctx, http.MethodGet, "/api/v1/ping", nil, nil)
} }
func (c *AgentClient) ConfigureHTTPS(ctx context.Context, certPEM, keyPEM string, port int) error {
var result map[string]any
return c.request(ctx, http.MethodPut, "/api/v1/transport/https", map[string]any{
"cert_pem": certPEM,
"key_pem": keyPEM,
"port": port,
}, &result)
}
func (c *AgentClient) DisableHTTPS(ctx context.Context) error {
var result map[string]any
return c.request(ctx, http.MethodDelete, "/api/v1/transport/https", nil, &result)
}
func (c *AgentClient) Status(ctx context.Context) (map[string]any, error) { func (c *AgentClient) Status(ctx context.Context) (map[string]any, error) {
var result map[string]any var result map[string]any
err := c.request(ctx, http.MethodGet, "/api/v1/status", nil, &result) err := c.request(ctx, http.MethodGet, "/api/v1/status", nil, &result)
+70
View File
@@ -0,0 +1,70 @@
package frp
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"fmt"
"math/big"
"net"
"time"
)
type agentCertificate struct {
CertPEM string
KeyPEM string
Pin string
}
func generateAgentCertificate(host string) (agentCertificate, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return agentCertificate{}, err
}
serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serial, err := rand.Int(rand.Reader, serialLimit)
if err != nil {
return agentCertificate{}, err
}
now := time.Now()
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: host},
NotBefore: now.Add(-5 * time.Minute),
NotAfter: now.Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
if ip := net.ParseIP(host); ip != nil {
template.IPAddresses = []net.IP{ip}
} else {
template.DNSNames = []string{host}
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return agentCertificate{}, err
}
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return agentCertificate{}, err
}
sum := sha256.Sum256(der)
return agentCertificate{
CertPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})),
KeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})),
Pin: hex.EncodeToString(sum[:]),
}, nil
}
func validatePin(pin string) error {
decoded, err := hex.DecodeString(pin)
if err != nil || len(decoded) != sha256.Size {
return fmt.Errorf("证书指纹格式无效")
}
return nil
}
+48
View File
@@ -0,0 +1,48 @@
package frp
import (
"crypto/x509"
"encoding/pem"
"net"
"testing"
)
func TestGenerateAgentCertificate(t *testing.T) {
cert, err := generateAgentCertificate("127.0.0.1")
if err != nil {
t.Fatal(err)
}
if err := validatePin(cert.Pin); err != nil {
t.Fatalf("pin 无效: %v", err)
}
block, _ := pem.Decode([]byte(cert.CertPEM))
if block == nil {
t.Fatal("证书 PEM 解码失败")
}
parsed, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatal(err)
}
if len(parsed.IPAddresses) != 1 || !parsed.IPAddresses[0].Equal(net.ParseIP("127.0.0.1")) {
t.Fatalf("证书 IP SAN 不正确: %#v", parsed.IPAddresses)
}
}
func TestPinTLSConfigVerification(t *testing.T) {
cert, err := generateAgentCertificate("agent.example.com")
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode([]byte(cert.CertPEM))
if block == nil {
t.Fatal("证书 PEM 解码失败")
}
verify := pinTLSConfig(cert.Pin).VerifyPeerCertificate
if err := verify([][]byte{block.Bytes}, nil); err != nil {
t.Fatalf("正确 pin 校验失败: %v", err)
}
wrong := pinTLSConfig("0000000000000000000000000000000000000000000000000000000000000000").VerifyPeerCertificate
if err := wrong([][]byte{block.Bytes}, nil); err == nil {
t.Fatal("错误 pin 应校验失败")
}
}
+8 -1
View File
@@ -30,6 +30,10 @@ type agentTunnel struct {
} }
func newAgentTunnel(parent context.Context, serverID int64, localPort int, installer SSHInstaller) *agentTunnel { func newAgentTunnel(parent context.Context, serverID int64, localPort int, installer SSHInstaller) *agentTunnel {
return newAgentTunnelWithListener(parent, serverID, localPort, installer, nil)
}
func newAgentTunnelWithListener(parent context.Context, serverID int64, localPort int, installer SSHInstaller, listener net.Listener) *agentTunnel {
ctx, cancel := context.WithCancel(parent) ctx, cancel := context.WithCancel(parent)
return &agentTunnel{ return &agentTunnel{
serverID: serverID, serverID: serverID,
@@ -37,18 +41,21 @@ func newAgentTunnel(parent context.Context, serverID int64, localPort int, insta
ssh: installer, ssh: installer,
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
listener: listener,
state: "connecting", state: "connecting",
} }
} }
func (t *agentTunnel) Start() error { func (t *agentTunnel) Start() error {
if t.listener == nil {
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", t.localPort)) listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", t.localPort))
if err != nil { if err != nil {
t.setState("failed", err) t.setState("failed", err)
return fmt.Errorf("监听隧道本地端口失败: %w", err) return fmt.Errorf("监听隧道本地端口失败: %w", err)
} }
t.mu.Lock()
t.listener = listener t.listener = listener
}
t.mu.Lock()
t.state = "connected" t.state = "connected"
t.lastError = "" t.lastError = ""
t.mu.Unlock() t.mu.Unlock()
Binary file not shown.
Binary file not shown.
+113 -18
View File
@@ -744,15 +744,13 @@ 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 localPort, listener, err := s.reserveAgentTunnelPort(ctx, serverID, server.AgentLocalPort)
if localPort == 0 {
localPort, err = s.allocateAgentTunnelPort(ctx)
if err != nil { if err != nil {
return Server{}, err return Server{}, err
} }
} tunnel := newAgentTunnelWithListener(s.tunnelContext(ctx), serverID, localPort, installer, listener)
tunnel := newAgentTunnel(s.tunnelContext(ctx), serverID, localPort, installer)
if err := tunnel.Start(); err != nil { if err := tunnel.Start(); err != nil {
_ = listener.Close()
return Server{}, err return Server{}, err
} }
baseURL := fmt.Sprintf("http://127.0.0.1:%d", localPort) baseURL := fmt.Sprintf("http://127.0.0.1:%d", localPort)
@@ -803,19 +801,24 @@ func (s *Service) tunnelContext(fallback context.Context) context.Context {
return context.Background() return context.Background()
} }
func (s *Service) allocateAgentTunnelPort(ctx context.Context) (int, error) { func (s *Service) reserveAgentTunnelPort(ctx context.Context, ownerID int64, preferred int) (int, net.Listener, error) {
s.tunnelMu.Lock() s.tunnelMu.Lock()
defer s.tunnelMu.Unlock() defer s.tunnelMu.Unlock()
servers, err := s.store.ListServers(ctx) servers, err := s.store.ListServers(ctx)
if err != nil { if err != nil {
return 0, err return 0, nil, err
} }
used := map[int]bool{} used := map[int]bool{}
for _, server := range servers { for _, server := range servers {
if server.AgentLocalPort > 0 { if server.ID != ownerID && server.AgentLocalPort > 0 {
used[server.AgentLocalPort] = true used[server.AgentLocalPort] = true
} }
} }
if preferred > 0 && !used[preferred] {
if listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", preferred)); err == nil {
return preferred, listener, nil
}
}
for port := 17700; port <= 18700; port++ { for port := 17700; port <= 18700; port++ {
if used[port] { if used[port] {
continue continue
@@ -824,10 +827,9 @@ func (s *Service) allocateAgentTunnelPort(ctx context.Context) (int, error) {
if err != nil { if err != nil {
continue continue
} }
_ = listener.Close() return port, listener, nil
return port, nil
} }
return 0, fmt.Errorf("SSH 隧道本地端口 17700-18700 已耗尽") return 0, nil, fmt.Errorf("SSH 隧道本地端口 17700-18700 已耗尽")
} }
func (s *Service) ensureAgentTunnel(ctx context.Context, serverID int64) (*agentTunnel, error) { func (s *Service) ensureAgentTunnel(ctx context.Context, serverID int64) (*agentTunnel, error) {
@@ -842,15 +844,13 @@ func (s *Service) ensureAgentTunnel(ctx context.Context, serverID int64) (*agent
if err != nil { if err != nil {
return nil, err return nil, err
} }
localPort := server.AgentLocalPort localPort, listener, err := s.reserveAgentTunnelPort(ctx, serverID, server.AgentLocalPort)
if localPort == 0 {
localPort, err = s.allocateAgentTunnelPort(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
} tunnel := newAgentTunnelWithListener(s.tunnelContext(ctx), serverID, localPort, installer, listener)
tunnel := newAgentTunnel(s.tunnelContext(ctx), serverID, localPort, installer)
if err := tunnel.Start(); err != nil { if err := tunnel.Start(); err != nil {
_ = listener.Close()
return nil, err return nil, err
} }
s.tunnelMu.Lock() s.tunnelMu.Lock()
@@ -937,6 +937,90 @@ func (s *Service) RestartAgentTunnel(ctx context.Context, serverID int64) (Serve
return server, nil return server, nil
} }
func (s *Service) agentToken(ctx context.Context, serverID int64) (string, error) {
encoded, err := s.store.AgentToken(ctx, serverID)
if err != nil || encoded == "" {
return "", fmt.Errorf("Agent Token 未保存")
}
token, err := s.secretBox.Decrypt(encoded)
if err != nil || token == "" {
return "", fmt.Errorf("Agent Token 无法解密")
}
return token, nil
}
func (s *Service) EnableAgentHTTPS(ctx context.Context, serverID int64, port int) (Server, error) {
if port == 0 {
port = 7443
}
if port <= 0 || port > 65535 {
return Server{}, fmt.Errorf("HTTPS 端口无效")
}
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, err
}
if server.AgentTransport == "http" {
return Server{}, fmt.Errorf("请先通过「升级 Agent」迁移到 SSH 隧道,再切换 HTTPS 直连")
}
currentClient, ok := s.agentClient(ctx, serverID)
if !ok {
return Server{}, fmt.Errorf("当前 Agent 连接不可用")
}
token, err := s.agentToken(ctx, serverID)
if err != nil {
return Server{}, err
}
cert, err := generateAgentCertificate(server.SSHHost)
if err != nil {
return Server{}, err
}
if err := currentClient.ConfigureHTTPS(ctx, cert.CertPEM, cert.KeyPEM, port); err != nil {
return Server{}, err
}
baseURL := fmt.Sprintf("https://%s:%d", server.SSHHost, port)
pinned := NewAgentClientWithPin(baseURL, token, cert.Pin)
if err := pinned.Ping(ctx); err != nil {
_ = currentClient.DisableHTTPS(ctx)
return Server{}, fmt.Errorf("HTTPS 直连验证失败: %w", err)
}
if err := s.store.SetAgentTransport(ctx, serverID, "https-pin", server.AgentLocalPort, cert.Pin, fmt.Sprintf("0.0.0.0:%d", port), "connected", ""); err != nil {
return Server{}, err
}
s.stopAgentTunnel(serverID)
updated, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, err
}
return updated, nil
}
func (s *Service) SwitchAgentToTunnel(ctx context.Context, serverID int64) (Server, error) {
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, err
}
if server.AgentTransport == "https-pin" {
token, err := s.agentToken(ctx, serverID)
if err != nil {
return Server{}, err
}
client := NewAgentClientWithPin(server.AgentURL, token, server.AgentTLSPin)
if err := client.DisableHTTPS(ctx); err != nil {
return Server{}, fmt.Errorf("关闭 HTTPS 直连失败: %w", err)
}
}
if _, err := s.ensureAgentTunnel(ctx, serverID); err != nil {
return Server{}, err
}
updated, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, err
}
s.enrichServerRuntime(ctx, &updated)
return updated, 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 {
@@ -1515,17 +1599,23 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient
return nil, false return nil, false
} }
baseURL := server.AgentURL baseURL := server.AgentURL
pin := ""
if server.AgentTransport == "tunnel" { if server.AgentTransport == "tunnel" {
tunnel, err := s.ensureAgentTunnel(ctx, serverID) tunnel, err := s.ensureAgentTunnel(ctx, serverID)
if err != nil { if err != nil {
return nil, false return nil, false
} }
baseURL = fmt.Sprintf("http://127.0.0.1:%d", tunnel.localPort) baseURL = fmt.Sprintf("http://127.0.0.1:%d", tunnel.localPort)
} else if server.AgentTransport == "https-pin" {
pin = strings.TrimSpace(server.AgentTLSPin)
if baseURL == "" || pin == "" {
return nil, false
}
} else if baseURL == "" { } else if baseURL == "" {
return nil, false return nil, false
} }
// 复用同一个 http.Client:批量同步 N 条路由时不再每次新建 Transport 与 TLS 握手 // 复用同一个 http.Client:批量同步 N 条路由时不再每次新建 Transport 与 TLS 握手
fingerprint := baseURL + "|" + encoded fingerprint := baseURL + "|" + encoded + "|" + pin
s.agentMu.Lock() s.agentMu.Lock()
defer s.agentMu.Unlock() defer s.agentMu.Unlock()
if s.agentClients == nil { if s.agentClients == nil {
@@ -1534,7 +1624,12 @@ 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(baseURL, token) var client *AgentClient
if pin != "" {
client = NewAgentClientWithPin(baseURL, token, pin)
} else {
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
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "fonu-web", "name": "fonu-web",
"version": "1.4.0", "version": "1.4.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "fonu-web", "name": "fonu-web",
"version": "1.4.0", "version": "1.4.1",
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.11.1", "@codemirror/commands": "^6.11.1",
"@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-javascript": "^6.2.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "fonu-web", "name": "fonu-web",
"private": true, "private": true,
"version": "1.4.0", "version": "1.4.1",
"packageManager": "npm@10.9.4", "packageManager": "npm@10.9.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
+5
View File
@@ -307,6 +307,11 @@ export const api = {
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) => restartFrpAgentTunnel: (id: number) =>
request<FrpServer>(`/api/frpmulti/servers/${id}/agent/tunnel/restart`, { method: 'POST' }), request<FrpServer>(`/api/frpmulti/servers/${id}/agent/tunnel/restart`, { method: 'POST' }),
setFrpAgentTransport: (id: number, transport: 'tunnel' | 'https-pin', port = 7443) =>
request<FrpServer>(`/api/frpmulti/servers/${id}/agent/transport`, {
method: 'POST',
body: JSON.stringify({ transport, port }),
}),
probeFrpAgent: (id: number) => request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/probe`, { method: 'POST' }), probeFrpAgent: (id: number) => request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/probe`, { method: 'POST' }),
installFrpAgent: (id: number) => request<FrpServer>(`/api/frpmulti/servers/${id}/agent/install`, { method: 'POST' }), installFrpAgent: (id: number) => request<FrpServer>(`/api/frpmulti/servers/${id}/agent/install`, { method: 'POST' }),
uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { method: 'POST' }), uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { method: 'POST' }),
+6
View File
@@ -854,6 +854,12 @@ export interface FrpAgentRouteDetail {
export interface FrpAgentStatus { export interface FrpAgentStatus {
agent_version?: string agent_version?: string
listen_addr?: string
listen_scope?: string
remote_access_enabled?: boolean
transport_security?: string
https_proxy_configured?: boolean
https_proxy_port?: number
ports?: Record<string, boolean> ports?: Record<string, boolean>
frps_active?: boolean frps_active?: boolean
frps_enabled?: boolean frps_enabled?: boolean
+29 -1
View File
@@ -253,10 +253,25 @@
<n-alert v-if="server.agent_transport === 'http'" type="warning" :bordered="false" class="agent-transport-warning"> <n-alert v-if="server.agent_transport === 'http'" type="warning" :bordered="false" class="agent-transport-warning">
当前仍是公网明文 HTTP。点击「升级 Agent」可自动迁移到 SSH 隧道安全模式。 当前仍是公网明文 HTTP。点击「升级 Agent」可自动迁移到 SSH 隧道安全模式。
</n-alert> </n-alert>
<n-alert v-if="server.agent_transport === 'https-pin'" type="info" :bordered="false" class="agent-transport-warning">
HTTPS 直连使用证书指纹校验。请确认云安全组已放行配置的 TCP 端口,Fonu 不会自动修改云安全组。
</n-alert>
<p v-if="server.agent_transport_error" class="form-hint agent-transport-error">{{ server.agent_transport_error }}</p> <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 v-if="server.agent_transport === 'tunnel'" size="small" :loading="busy === 'tunnel-restart'" :disabled="busy !== null && busy !== 'tunnel-restart'" @click="restartAgentTunnel(server)">重启隧道</n-button>
<n-popconfirm v-if="server.agent_transport === 'tunnel'" @positive-click="enableAgentHTTPS(server)">
<template #trigger>
<n-button size="small" :loading="busy === 'transport-https'" :disabled="busy !== null && busy !== 'transport-https'">切换 HTTPS 直连</n-button>
</template>
将使用自签名证书启用 TCP 7443。请确保云安全组已放行该端口,否则切换后 Agent 会不可达。继续?
</n-popconfirm>
<n-popconfirm v-if="server.agent_transport === 'https-pin'" @positive-click="switchAgentToTunnel(server)">
<template #trigger>
<n-button size="small" :loading="busy === 'transport-tunnel'" :disabled="busy !== null && busy !== 'transport-tunnel'">切回 SSH 隧道</n-button>
</template>
将关闭 VPS 上的 HTTPS Agent 入口并恢复 SSH 隧道。继续?
</n-popconfirm>
<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)">
@@ -685,7 +700,8 @@ use([CanvasRenderer, LineChart, ScatterChart, GridComponent, TooltipComponent])
// 操作动作:按动作粒度控制 loading,保证"只有被点的按钮转圈" // 操作动作:按动作粒度控制 loading,保证"只有被点的按钮转圈"
type Action = type Action =
| 'probe' | 'agent-install' | 'agent-uninstall' | 'diagnose' | 'probe' | 'agent-install' | 'agent-uninstall' | 'diagnose'
| 'tunnel-restart' | 'frps-start' | 'frps-stop' | 'frps-restart' | 'frps-install' | 'push-config' | 'tunnel-restart' | 'transport-https' | 'transport-tunnel'
| '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 }
@@ -1413,6 +1429,18 @@ function restartAgentTunnel(target: FrpServer) {
.then(async (result) => { if (result !== undefined) await load() }) .then(async (result) => { if (result !== undefined) await load() })
} }
function enableAgentHTTPS(target: FrpServer) {
return run('transport-https', '切换 HTTPS 直连', () => api.setFrpAgentTransport(target.id, 'https-pin', 7443),
() => ({ text: 'HTTPS 直连已启用并通过证书指纹验证', ok: true }))
.then(async (result) => { if (result !== undefined) await load() })
}
function switchAgentToTunnel(target: FrpServer) {
return run('transport-tunnel', '切回 SSH 隧道', () => api.setFrpAgentTransport(target.id, 'tunnel'),
() => ({ text: '已关闭 HTTPS 直连并恢复 SSH 隧道', ok: true }))
.then(async (result) => { if (result !== undefined) await load() })
}
function agentTransportText(server: FrpServer) { function agentTransportText(server: FrpServer) {
if (server.agent_transport === 'tunnel') return 'SSH 隧道' if (server.agent_transport === 'tunnel') return 'SSH 隧道'
if (server.agent_transport === 'https-pin') return 'HTTPS + 证书固定' if (server.agent_transport === 'https-pin') return 'HTTPS + 证书固定'
+8 -8
View File
@@ -8,7 +8,7 @@
</template> </template>
</PageHeader> </PageHeader>
<n-alert type="success" :bordered="false" class="example-alert" title="v1.4.0 示例页面验证标识已生效"> <n-alert type="success" :bordered="false" class="example-alert" title="v1.4.1 示例页面验证标识已生效">
当前页面由 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.4.0。 看到这个新区块,说明前端资源已经从旧版本切换到 v1.4.1。
</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.4.0 前端资源已加载" tone="teal"> <StatCard label="页面状态" value="已更新" sub="v1.4.1 前端资源已加载" 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.4.0。</li> <li>页面版本提示更新为 v1.4.1。</li>
<li>「本次升级验证」区块和页面状态同步更新为 v1.4.0。</li> <li>「本次升级验证」区块和页面状态同步更新为 v1.4.1。</li>
<li>验证标识更新为 <span class="mono">v1.4.0-example-page</span>。</li> <li>验证标识更新为 <span class="mono">v1.4.1-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.4.0' const verificationRound = 'v1.4.1'
const verificationMarker = 'v1.4.0-example-page' const verificationMarker = 'v1.4.1-example-page'
async function refreshVersion() { async function refreshVersion() {
refreshing.value = true refreshing.value = true