diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f0ace..6f5c162 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ 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 ### 新增 @@ -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`,无需新增公网端口、证书或防火墙规则 - **隧道生命周期管理**:应用启动后自动恢复 tunnel transport;应用退出时统一关闭;每个服务端支持单独的「重启隧道」操作 - **旧公网 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/frp` / `internal/app`:隧道生命周期、transport 存储与启动恢复编译验证 +- `internal/frp` / `internal/app`:隧道生命周期、transport 存储、证书生成与 pin 校验测试 ## [v1.3.9] - 2026-09-28 diff --git a/VERSION b/VERSION index 88c5fb8..347f583 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.0 +1.4.1 diff --git a/docs/README.md b/docs/README.md index 0923f64..9319ee9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # 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、前端、链路、风险与改进路线 | 当前版本快照 | | `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 高级模式待后续 | +| `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 实施中 | | `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 index 95452df..08ca82a 100644 --- a/docs/agent-listen-strategy-plan.md +++ b/docs/agent-listen-strategy-plan.md @@ -5,7 +5,7 @@ > 当前 AgentVersion:0.13.0 > 目标:默认不暴露 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 兼容清理和可选主机防火墙辅助。 --- diff --git a/docs/improvement-roadmap-2026-09-27.md b/docs/improvement-roadmap-2026-09-27.md index a0a9e90..ceb61c8 100644 --- a/docs/improvement-roadmap-2026-09-27.md +++ b/docs/improvement-roadmap-2026-09-27.md @@ -460,7 +460,7 @@ func (s *Server) clearLimitState(domain string) error > 方案文档:`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 现状 diff --git a/docs/system-upgrade.md b/docs/system-upgrade.md index 54fe0d3..3a8bb20 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.4.0 的验证流程: +示例页面用于验证升级结果。v1.4.1 的验证流程: 1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。 -2. 确认远端 `main` 分支的 `VERSION` 为 `1.4.0`。 +2. 确认远端 `main` 分支的 `VERSION` 为 `1.4.1`。 3. 在侧栏看到「一键升级」后点击并确认。 4. 等待升级弹窗显示成功,页面自动刷新。 5. 打开侧栏「示例页面」,确认: - 页面路由为 `/example`; - - 页面显示的版本为 `1.4.0`; - - 页面显示验证标识 `v1.4.0-example-page`; + - 页面显示的版本为 `1.4.1`; + - 页面显示验证标识 `v1.4.1-example-page`; - 页面显示独立的「本次升级验证」区块; - 「升级验证」显示为「通过」。 diff --git a/internal/agent/server.go b/internal/agent/server.go index 1f04198..ff2075d 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -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("PUT /api/v1/china-cidr", s.auth(s.putChinaCIDR)) 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("PUT /api/v1/frps/config", s.auth(s.putFRPSConfig)) mux.HandleFunc("POST /api/v1/frps/install", s.auth(s.installFRPS)) @@ -278,33 +280,36 @@ 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)} nginxProbe := s.probeNginx() frpsActive := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil + httpsState, httpsConfigured := readHTTPSProxyState(s.cfg.DataDir) writeJSON(w, http.StatusOK, map[string]any{ - "service": "fonu-agent", - "agent_version": AgentVersion, - "listen_addr": s.cfg.ListenAddr, - "listen_scope": listenScope(s.listenLoopback), - "remote_access_enabled": s.cfg.AllowRemote, - "transport_security": transportSecurity(s.listenLoopback), - "nginx_binary": s.cfg.NginxBinary, - "nginx_conf_dir": s.cfg.NginxConfDir, - "nginx_ok": nginxProbe.OK, - "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), + "https_proxy_configured": httpsConfigured, + "https_proxy_port": httpsState.Port, + "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/agent/transport.go b/internal/agent/transport.go new file mode 100644 index 0000000..ace5add --- /dev/null +++ b/internal/agent/transport.go @@ -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 +} diff --git a/internal/api/frpmulti_handler.go b/internal/api/frpmulti_handler.go index 9319043..ed12e8e 100644 --- a/internal/api/frpmulti_handler.go +++ b/internal/api/frpmulti_handler.go @@ -338,6 +338,37 @@ func (h *FRPMultiHandler) AgentTunnelRestart(w http.ResponseWriter, r *http.Requ 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) { id, err := parseID(r.PathValue("id")) if err != nil { diff --git a/internal/api/router.go b/internal/api/router.go index b60a60e..f547419 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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/test", frpHandler.AgentTest) 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/routes", frpHandler.AgentRoutes) protect("GET /api/frpmulti/servers/{id}/agent/routes/health", frpHandler.AgentRouteHealth) diff --git a/internal/frp/agent_client.go b/internal/frp/agent_client.go index 612d8ba..7c31bb9 100644 --- a/internal/frp/agent_client.go +++ b/internal/frp/agent_client.go @@ -3,6 +3,11 @@ package frp import ( "bytes" "context" + "crypto/sha256" + "crypto/subtle" + "crypto/tls" + "crypto/x509" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -18,16 +23,56 @@ import ( type AgentClient struct { baseURL string token string + pin string http *http.Client } 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), "/"), token: token, + pin: strings.ToLower(strings.TrimSpace(pin)), // 路由写入链路含 nginx -T 自省 + -t 校验 + reload(宝塔带 lua/waf 模块时各步可达数秒),15s 会超时 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 { @@ -55,7 +100,10 @@ func (c *AgentClient) request(ctx context.Context, method, path string, payload return err } 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 { 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) } +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) { var result map[string]any err := c.request(ctx, http.MethodGet, "/api/v1/status", nil, &result) diff --git a/internal/frp/agent_https.go b/internal/frp/agent_https.go new file mode 100644 index 0000000..ecbba9a --- /dev/null +++ b/internal/frp/agent_https.go @@ -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 +} diff --git a/internal/frp/agent_https_test.go b/internal/frp/agent_https_test.go new file mode 100644 index 0000000..9da1dbc --- /dev/null +++ b/internal/frp/agent_https_test.go @@ -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 应校验失败") + } +} diff --git a/internal/frp/agent_tunnel.go b/internal/frp/agent_tunnel.go index 177e2a8..5b2cc71 100644 --- a/internal/frp/agent_tunnel.go +++ b/internal/frp/agent_tunnel.go @@ -30,6 +30,10 @@ type agentTunnel struct { } 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) return &agentTunnel{ serverID: serverID, @@ -37,18 +41,21 @@ func newAgentTunnel(parent context.Context, serverID int64, localPort int, insta ssh: installer, ctx: ctx, cancel: cancel, + listener: listener, 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) + if t.listener == nil { + 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.listener = listener } t.mu.Lock() - t.listener = listener t.state = "connected" t.lastError = "" t.mu.Unlock() diff --git a/internal/frp/agentbin/fonu-agent-linux-amd64 b/internal/frp/agentbin/fonu-agent-linux-amd64 index 99ee52f..b6953b5 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 97abb80..17e689d 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/service.go b/internal/frp/service.go index c6fe839..064f8da 100644 --- a/internal/frp/service.go +++ b/internal/frp/service.go @@ -744,15 +744,13 @@ 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 - } + localPort, listener, err := s.reserveAgentTunnelPort(ctx, serverID, server.AgentLocalPort) + if err != nil { + return Server{}, err } - tunnel := newAgentTunnel(s.tunnelContext(ctx), serverID, localPort, installer) + tunnel := newAgentTunnelWithListener(s.tunnelContext(ctx), serverID, localPort, installer, listener) if err := tunnel.Start(); err != nil { + _ = listener.Close() return Server{}, err } 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() } -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() defer s.tunnelMu.Unlock() servers, err := s.store.ListServers(ctx) if err != nil { - return 0, err + return 0, nil, err } used := map[int]bool{} for _, server := range servers { - if server.AgentLocalPort > 0 { + if server.ID != ownerID && server.AgentLocalPort > 0 { 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++ { if used[port] { continue @@ -824,10 +827,9 @@ func (s *Service) allocateAgentTunnelPort(ctx context.Context) (int, error) { if err != nil { continue } - _ = listener.Close() - return port, nil + return port, listener, 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) { @@ -842,15 +844,13 @@ func (s *Service) ensureAgentTunnel(ctx context.Context, serverID int64) (*agent if err != nil { return nil, err } - localPort := server.AgentLocalPort - if localPort == 0 { - localPort, err = s.allocateAgentTunnelPort(ctx) - if err != nil { - return nil, err - } + localPort, listener, err := s.reserveAgentTunnelPort(ctx, serverID, server.AgentLocalPort) + if err != nil { + return nil, err } - tunnel := newAgentTunnel(s.tunnelContext(ctx), serverID, localPort, installer) + tunnel := newAgentTunnelWithListener(s.tunnelContext(ctx), serverID, localPort, installer, listener) if err := tunnel.Start(); err != nil { + _ = listener.Close() return nil, err } s.tunnelMu.Lock() @@ -937,6 +937,90 @@ func (s *Service) RestartAgentTunnel(ctx context.Context, serverID int64) (Serve 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) { _, installer, err := s.sshInstaller(ctx, serverID) if err != nil { @@ -1515,17 +1599,23 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient return nil, false } baseURL := server.AgentURL + pin := "" 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 server.AgentTransport == "https-pin" { + pin = strings.TrimSpace(server.AgentTLSPin) + if baseURL == "" || pin == "" { + return nil, false + } } else if baseURL == "" { return nil, false } // 复用同一个 http.Client:批量同步 N 条路由时不再每次新建 Transport 与 TLS 握手 - fingerprint := baseURL + "|" + encoded + fingerprint := baseURL + "|" + encoded + "|" + pin s.agentMu.Lock() defer s.agentMu.Unlock() 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 { 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} return client, true } diff --git a/web/package-lock.json b/web/package-lock.json index bccbdca..bab7de2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "fonu-web", - "version": "1.4.0", + "version": "1.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fonu-web", - "version": "1.4.0", + "version": "1.4.1", "dependencies": { "@codemirror/commands": "^6.11.1", "@codemirror/lang-javascript": "^6.2.5", diff --git a/web/package.json b/web/package.json index 2cdf69a..02e783d 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "fonu-web", "private": true, - "version": "1.4.0", + "version": "1.4.1", "packageManager": "npm@10.9.4", "type": "module", "scripts": { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1363442..ec14cbb 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -307,6 +307,11 @@ export const api = { 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' }), + setFrpAgentTransport: (id: number, transport: 'tunnel' | 'https-pin', port = 7443) => + request(`/api/frpmulti/servers/${id}/agent/transport`, { + method: 'POST', + body: JSON.stringify({ transport, port }), + }), 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 980e692..d901b84 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -854,6 +854,12 @@ export interface FrpAgentRouteDetail { export interface FrpAgentStatus { 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 frps_active?: boolean frps_enabled?: boolean diff --git a/web/src/views/AgentView.vue b/web/src/views/AgentView.vue index fbcf6ab..b215f71 100644 --- a/web/src/views/AgentView.vue +++ b/web/src/views/AgentView.vue @@ -253,10 +253,25 @@ 当前仍是公网明文 HTTP。点击「升级 Agent」可自动迁移到 SSH 隧道安全模式。 + + HTTPS 直连使用证书指纹校验。请确认云安全组已放行配置的 TCP 端口,Fonu 不会自动修改云安全组。 +

{{ server.agent_transport_error }}

{{ server.agent_configured ? '升级 Agent' : '安装 Agent' }} 重启隧道 + + + 将使用自签名证书启用 TCP 7443。请确保云安全组已放行该端口,否则切换后 Agent 会不可达。继续? + + + + 将关闭 VPS 上的 HTTPS Agent 入口并恢复 SSH 隧道。继续? + SSH 诊断 卸载(保留路由/证书) @@ -685,7 +700,8 @@ use([CanvasRenderer, LineChart, ScatterChart, GridComponent, TooltipComponent]) // 操作动作:按动作粒度控制 loading,保证"只有被点的按钮转圈" type Action = | '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 } @@ -1413,6 +1429,18 @@ function restartAgentTunnel(target: FrpServer) { .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) { if (server.agent_transport === 'tunnel') return 'SSH 隧道' if (server.agent_transport === 'https-pin') return 'HTTPS + 证书固定' diff --git a/web/src/views/ExampleView.vue b/web/src/views/ExampleView.vue index 3122075..aab2950 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.4.0。 + 看到这个新区块,说明前端资源已经从旧版本切换到 v1.4.1。
@@ -33,16 +33,16 @@ - +
    -
  • 页面版本提示更新为 v1.4.0。
  • -
  • 「本次升级验证」区块和页面状态同步更新为 v1.4.0。
  • -
  • 验证标识更新为 v1.4.0-example-page。
  • +
  • 页面版本提示更新为 v1.4.1。
  • +
  • 「本次升级验证」区块和页面状态同步更新为 v1.4.1。
  • +
  • 验证标识更新为 v1.4.1-example-page。
@@ -87,8 +87,8 @@ const message = useMessage() const version = ref(__APP_VERSION__) const checkedAt = ref('') const refreshing = ref(false) -const verificationRound = 'v1.4.0' -const verificationMarker = 'v1.4.0-example-page' +const verificationRound = 'v1.4.1' +const verificationMarker = 'v1.4.1-example-page' async function refreshVersion() { refreshing.value = true