diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bcdbbc..cf59aa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to this project will be documented in this file. +## [v1.5.0] - 2026-09-28 + +### 新增 + +- **HTTPS 直连端口自动选择**:Agent 在 `7443` / `8443` / `9443` 中逐个尝试可用端口,每轮都会写配置、reload 并做本机证书自检;全部失败时返回每个端口的具体原因。显式传入端口时仍按指定值执行,`443` 可通过接口显式指定 +- **证书自动轮换**:证书有效期 365 天,剩余 30 天时后台自动生成新证书并通过当前连接安装;过渡期 7 天内客户端同时接受新旧 pin,避免轮换瞬间连接失败。旧记录缺少到期信息时,先从 Agent 读取当前证书到期时间再决定是否轮换 +- **主机防火墙辅助**:Agent 新增防火墙检测与显式放行接口,支持 `ufw` / `firewalld` 自动放行;`nftables` / `iptables` 只检测和提示,不自动修改规则。Agent 页增加「检测主机防火墙 / 放行主机防火墙」 +- **隧道后台保活与重连**:隧道启动后每 30 秒保活,断线按 1 秒到 60 秒指数退避重建,不再只在有流量时才尝试重连 +- **传输详细状态接口**:`GET /api/frpmulti/servers/{id}/agent/transport` 返回 transport、状态、端口、pin、证书到期时间与防火墙状态 +- **审计记录**:transport 切换、证书轮换、防火墙放行写入 AUDIT 日志,包含 admin_id、server_id 与来源地址 +- **证书 SAN 校验**:pin 校验同时校验证书 SAN 与连接主机一致 + +### 变更 + +- 版本号提升到 1.5.0;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 0.13.3 → **0.14.0**,内嵌 amd64 / arm64 二进制已重编;VPS 上需点一次「升级 Agent」才会生效 +- Agent 页轮询改用 `useVisibilityPolling`,页面隐藏时暂停 + +### 测试 + +- `internal/frp`:新增多 pin 过渡、SAN 拒绝、端口解析与轮换兼容测试 +- `internal/agent`:新增 ufw 解析与候选端口测试 +- `internal/frp`:隧道退避策略单元测试 + ## [v1.4.6] - 2026-09-28 ### 修复 diff --git a/VERSION b/VERSION index c514bd8..bc80560 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.6 +1.5.0 diff --git a/docs/README.md b/docs/README.md index 573eb05..af0e252 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # Fonu 文档索引 > 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 -> 最近更新:2026-09-28(v1.4.6) +> 最近更新:2026-09-28(v1.5.0) ## 方案与设计(本目录) diff --git a/docs/agent-listen-strategy-plan.md b/docs/agent-listen-strategy-plan.md index 5465d07..2b45856 100644 --- a/docs/agent-listen-strategy-plan.md +++ b/docs/agent-listen-strategy-plan.md @@ -2,10 +2,10 @@ > 方案编号:P0-5 > 制定日期:2026-09-28 -> 当前 AgentVersion:0.13.3 +> 当前 AgentVersion:0.14.0 > 目标:默认不暴露 Agent 管理接口,安装和升级过程自动建立安全连接,不要求用户手工配置端口、隧道或证书 -> 实施状态(2026-09-28):阶段 1-4 已随 v1.4.0 实施;0.13.1 / 0.13.2 修复 nginx 版本目录被 `include *` 误加载及探测阶段无法自愈的问题,0.13.3 增加 HTTPS reload 后本机证书自检;仅剩 2.0.0 的旧 HTTP 兼容清理和可选主机防火墙辅助。 +> 实施状态(2026-09-28):阶段 1-5 主体已在 v1.5.0 完成,包括回环监听、SSH 隧道与后台重连、旧 HTTP 迁移、HTTPS 端口自动选择、证书自动轮换与双 pin 过渡、主机防火墙辅助、传输状态接口与审计记录。剩余:2.0.0 移除旧 HTTP 兼容分支,以及 Debian/Ubuntu/CentOS、宝塔、端口占用、云安全组受限等真机矩阵验证。 --- @@ -483,11 +483,13 @@ VerifyPeerCertificate(rawCerts [][]byte, _ [][]*x509.Certificate) error { ### 9.4 防火墙 -首版不自动修改主机防火墙。阶段 4 之后,在用户明确确认时可以辅助生成并执行主机防火墙命令: +> 实施更新(v1.5.0 / Agent 0.14.0):已支持检测 ufw / firewalld / nftables / iptables;对 ufw 与 firewalld 可在用户明确确认后自动放行,nftables / iptables 仅检测和提示,不会自动改规则。 + +自动放行仅在用户明确确认后执行,支持的工具: - ufw; - firewalld; -- nftables / iptables。 +- nftables / iptables(只提示,不自动改规则)。 云厂商安全组始终不能自动修改。UI 必须提示: diff --git a/docs/system-upgrade.md b/docs/system-upgrade.md index 17bea0e..61867d5 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.6 的验证流程: +示例页面用于验证升级结果。v1.5.0 的验证流程: 1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。 -2. 确认远端 `main` 分支的 `VERSION` 为 `1.4.6`。 +2. 确认远端 `main` 分支的 `VERSION` 为 `1.5.0`。 3. 在侧栏看到「一键升级」后点击并确认。 4. 等待升级弹窗显示成功,页面自动刷新。 5. 打开侧栏「示例页面」,确认: - 页面路由为 `/example`; - - 页面显示的版本为 `1.4.6`; - - 页面显示验证标识 `v1.4.6-example-page`; + - 页面显示的版本为 `1.5.0`; + - 页面显示验证标识 `v1.5.0-example-page`; - 页面显示独立的「本次升级验证」区块; - 「升级验证」显示为「通过」。 diff --git a/internal/agent/firewall.go b/internal/agent/firewall.go new file mode 100644 index 0000000..c2aee1c --- /dev/null +++ b/internal/agent/firewall.go @@ -0,0 +1,127 @@ +package agent + +import ( + "encoding/json" + "errors" + "net/http" + "os/exec" + "strconv" + "strings" +) + +type firewallStatus struct { + Tool string `json:"tool"` + Active bool `json:"active"` + PortOpen bool `json:"port_open"` + Message string `json:"message,omitempty"` +} + +// firewallStatus 只检测主机防火墙,不修改规则;云安全组无法从 VPS 内部感知。 +func (s *Server) firewallStatus(w http.ResponseWriter, r *http.Request) { + port := 7443 + if raw := strings.TrimSpace(r.URL.Query().Get("port")); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 65535 { + port = parsed + } + } + writeJSON(w, http.StatusOK, detectFirewall(port)) +} + +// firewallAllow 仅在用户显式确认后调用;只自动处理 ufw 与 firewalld。 +func (s *Server) firewallAllow(w http.ResponseWriter, r *http.Request) { + var req struct { + Port int `json:"port"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).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": "端口无效"}) + return + } + output, err := allowFirewallPort(req.Port) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "output": output, "status": detectFirewall(req.Port)}) +} + +func detectFirewall(port int) firewallStatus { + if path, err := exec.LookPath("ufw"); err == nil { + if out, err := exec.Command(path, "status").Output(); err == nil { + active, open := parseUFWStatus(string(out), port) + if active { + return firewallStatus{Tool: "ufw", Active: true, PortOpen: open} + } + } + } + if path, err := exec.LookPath("firewall-cmd"); err == nil { + if out, err := exec.Command(path, "--state").Output(); err == nil && strings.TrimSpace(string(out)) == "running" { + return firewallStatus{Tool: "firewalld", Active: true, PortOpen: firewalldPortOpen(path, port)} + } + } + if _, err := exec.LookPath("nft"); err == nil { + return firewallStatus{Tool: "nftables", Active: true, Message: "nftables 需要手工放行,Agent 不会自动修改规则"} + } + if _, err := exec.LookPath("iptables"); err == nil { + return firewallStatus{Tool: "iptables", Active: true, Message: "iptables 需要手工放行,Agent 不会自动修改规则"} + } + return firewallStatus{Tool: "none", Message: "未检测到主机防火墙"} +} + +// parseUFWStatus 纯解析 ufw status 输出:是否启用、目标端口是否已放行。 +func parseUFWStatus(output string, port int) (bool, bool) { + active := false + open := false + needle := strconv.Itoa(port) + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Status:") { + active = strings.EqualFold(strings.TrimSpace(strings.TrimPrefix(line, "Status:")), "active") + continue + } + if !strings.Contains(line, needle) || !strings.Contains(line, "ALLOW") { + continue + } + fields := strings.Fields(line) + if len(fields) > 0 && (fields[0] == needle || strings.HasPrefix(fields[0], needle+"/")) { + open = true + } + } + return active, open +} + +func firewalldPortOpen(path string, port int) bool { + out, err := exec.Command(path, "--list-ports").Output() + if err != nil { + return false + } + for _, item := range strings.Fields(string(out)) { + if strings.HasPrefix(item, strconv.Itoa(port)+"/") { + return true + } + } + return false +} + +func allowFirewallPort(port int) (string, error) { + status := detectFirewall(port) + switch status.Tool { + case "ufw": + out, err := exec.Command("ufw", "allow", strconv.Itoa(port)+"/tcp").CombinedOutput() + return strings.TrimSpace(string(out)), err + case "firewalld": + first, err := exec.Command("firewall-cmd", "--permanent", "--add-port="+strconv.Itoa(port)+"/tcp").CombinedOutput() + if err != nil { + return strings.TrimSpace(string(first)), err + } + second, err := exec.Command("firewall-cmd", "--reload").CombinedOutput() + return strings.TrimSpace(string(first) + "\n" + string(second)), err + case "nftables", "iptables": + return "", errors.New("当前防火墙需要手工放行,Agent 不会自动修改 nftables / iptables 规则") + default: + return "", errors.New("未检测到可自动放行的主机防火墙(仅支持 ufw / firewalld)") + } +} diff --git a/internal/agent/firewall_test.go b/internal/agent/firewall_test.go new file mode 100644 index 0000000..e56e877 --- /dev/null +++ b/internal/agent/firewall_test.go @@ -0,0 +1,38 @@ +package agent + +import "testing" + +func TestParseUFWStatus(t *testing.T) { + output := `Status: active + +To Action From +-- ------ ---- +22/tcp ALLOW Anywhere +7443/tcp ALLOW Anywhere +` + active, open := parseUFWStatus(output, 7443) + if !active || !open { + t.Fatalf("应识别出 ufw 已启用且 7443 已放行:active=%v open=%v", active, open) + } + _, open = parseUFWStatus(output, 9443) + if open { + t.Fatal("未放行的端口不应判定为已放行") + } +} + +func TestParseUFWStatusInactive(t *testing.T) { + active, open := parseUFWStatus("Status: inactive\n", 7443) + if active || open { + t.Fatalf("inactive 时不应判定为启用:active=%v open=%v", active, open) + } +} + +func TestCandidateHTTPSPorts(t *testing.T) { + if got := candidateHTTPSPorts(9443); len(got) != 1 || got[0] != 9443 { + t.Fatalf("显式端口应只返回该端口:%v", got) + } + got := candidateHTTPSPorts(0) + if len(got) != 3 || got[0] != 7443 || got[1] != 8443 || got[2] != 9443 { + t.Fatalf("自动模式应返回 7443/8443/9443:%v", got) + } +} diff --git a/internal/agent/server.go b/internal/agent/server.go index 2808db2..98fce8d 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -58,7 +58,8 @@ import ( // 修掉「配置目录还没探测出来、迁移逻辑没机会执行」导致 HTTPS 直连持续失败的问题。 // 0.13.3:HTTPS 反代改用 default_server 并在 reload 后做本机证书指纹自检, // 证书不匹配时回滚并回报实际指纹与端口占用进程,区分「nginx 未生效」与「公网链路被接管」。 -const AgentVersion = "0.13.3" +// 0.14.0:HTTPS 端口自动选择(7443/8443/9443)、当前证书信息查询、主机防火墙检测与 ufw/firewalld 显式放行。 +const AgentVersion = "0.14.0" // Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。 type Config struct { @@ -227,7 +228,10 @@ func (s *Server) Handler() http.Handler { 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("GET /api/v1/transport/https", s.auth(s.getHTTPSProxy)) mux.HandleFunc("DELETE /api/v1/transport/https", s.auth(s.deleteHTTPSProxy)) + mux.HandleFunc("GET /api/v1/firewall", s.auth(s.firewallStatus)) + mux.HandleFunc("POST /api/v1/firewall/allow", s.auth(s.firewallAllow)) 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)) diff --git a/internal/agent/transport.go b/internal/agent/transport.go index e34d0e8..1ffc470 100644 --- a/internal/agent/transport.go +++ b/internal/agent/transport.go @@ -20,7 +20,11 @@ import ( "github.com/fonu/fonu/internal/fsutil" ) -const httpsProxyConfigName = "fonuagent-https.conf" +const ( + httpsProxyConfigName = "fonuagent-https.conf" + httpsProxyCertPath = "/etc/fonu-agent/tls/agent.crt" + httpsProxyKeyPath = "/etc/fonu-agent/tls/agent.key" +) type httpsProxyRequest struct { CertPEM string `json:"cert_pem"` @@ -38,8 +42,8 @@ func (s *Server) putHTTPSProxy(w http.ResponseWriter, r *http.Request) { 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 之间"}) + if req.Port < 0 || req.Port > 65535 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "端口需在 0-65535 之间,0 表示自动选择"}) return } if _, err := tls.X509KeyPair([]byte(req.CertPEM), []byte(req.KeyPEM)); err != nil { @@ -52,11 +56,12 @@ func (s *Server) putHTTPSProxy(w http.ResponseWriter, r *http.Request) { } s.mu.Lock() defer s.mu.Unlock() - if err := s.configureHTTPSProxy(req); err != nil { + port, err := s.configureHTTPSProxy(req) + if 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, "self_check": "ok"}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "port": port, "self_check": "ok"}) } func (s *Server) deleteHTTPSProxy(w http.ResponseWriter, _ *http.Request) { @@ -69,7 +74,46 @@ func (s *Server) deleteHTTPSProxy(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } -func (s *Server) configureHTTPSProxy(req httpsProxyRequest) error { +// getHTTPSProxy 返回当前 HTTPS 反代状态与证书到期时间,供中央端同步轮换计划。 +func (s *Server) getHTTPSProxy(w http.ResponseWriter, _ *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + state, enabled := readHTTPSProxyState(s.cfg.DataDir) + notAfter := "" + if t, err := readCertificateNotAfter(httpsProxyCertPath); err == nil { + notAfter = t.UTC().Format(time.RFC3339) + } + writeJSON(w, http.StatusOK, map[string]any{ + "enabled": enabled, + "port": state.Port, + "not_after": notAfter, + }) +} + +// configureHTTPSProxy 按候选端口逐个尝试;请求端口为 0 时自动选择 7443 / 8443 / 9443。 +func (s *Server) configureHTTPSProxy(req httpsProxyRequest) (int, error) { + var lastErr error + for _, port := range candidateHTTPSPorts(req.Port) { + if err := s.applyHTTPSProxy(req, port); err != nil { + lastErr = fmt.Errorf("端口 %d 不可用: %w", port, err) + continue + } + return port, nil + } + if lastErr == nil { + lastErr = errors.New("没有可用的 HTTPS 端口") + } + return 0, lastErr +} + +func candidateHTTPSPorts(requested int) []int { + if requested > 0 { + return []int{requested} + } + return []int{7443, 8443, 9443} +} + +func (s *Server) applyHTTPSProxy(req httpsProxyRequest, port int) error { dir, err := s.ensureNginxDir() if err != nil { return err @@ -78,12 +122,10 @@ func (s *Server) configureHTTPSProxy(req httpsProxyRequest) error { 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 { + if err := atomicWrite(httpsProxyCertPath, []byte(req.CertPEM), 0o640); err != nil { return err } - if err := atomicWrite(keyPath, []byte(req.KeyPEM), 0o600); err != nil { + if err := atomicWrite(httpsProxyKeyPath, []byte(req.KeyPEM), 0o600); err != nil { return err } path := filepath.Join(dir, httpsProxyConfigName) @@ -91,7 +133,7 @@ func (s *Server) configureHTTPSProxy(req httpsProxyRequest) error { if _, err := fsutil.BackupVersioned(path, 10); err != nil { return err } - content := renderHTTPSProxy(req.Port, certPath, keyPath) + content := renderHTTPSProxy(port, httpsProxyCertPath, httpsProxyKeyPath) if err := atomicWrite(path, []byte(content), 0o640); err != nil { return err } @@ -107,11 +149,11 @@ func (s *Server) configureHTTPSProxy(req httpsProxyRequest) error { if err != nil { return err } - if err := s.verifyHTTPSProxy(req.Port, pin); err != nil { + if err := s.verifyHTTPSProxy(port, pin); err != nil { s.restoreHTTPSConfig(path, previous, readErr) return err } - return writeHTTPSProxyState(s.cfg.DataDir, httpsProxyState{Port: req.Port}) + return writeHTTPSProxyState(s.cfg.DataDir, httpsProxyState{Port: port}) } func (s *Server) removeHTTPSProxy() error { diff --git a/internal/api/frpmulti_handler.go b/internal/api/frpmulti_handler.go index ed12e8e..466d683 100644 --- a/internal/api/frpmulti_handler.go +++ b/internal/api/frpmulti_handler.go @@ -4,18 +4,21 @@ import ( "encoding/json" "errors" "io" + "log/slog" "net/http" "strconv" "strings" "time" + "github.com/fonu/fonu/internal/auth" "github.com/fonu/fonu/internal/frp" ) // FRPMultiHandler 承载多服务端 FRP 模块的 HTTP 接口(/api/frpmulti/*), // 包装 internal/frp 的 Service(0.1.25 核心迁移版)。 type FRPMultiHandler struct { - svc *frp.Service + svc *frp.Service + auth *auth.Service } type frpServerRequest struct { @@ -68,8 +71,8 @@ type frpDownloadProxyRequest struct { Proxy string `json:"proxy"` } -func NewFRPMultiHandler(svc *frp.Service) *FRPMultiHandler { - return &FRPMultiHandler{svc: svc} +func NewFRPMultiHandler(svc *frp.Service, authSvc *auth.Service) *FRPMultiHandler { + return &FRPMultiHandler{svc: svc, auth: authSvc} } func (h *FRPMultiHandler) Status(w http.ResponseWriter, r *http.Request) { @@ -366,9 +369,94 @@ func (h *FRPMultiHandler) SetAgentTransport(w http.ResponseWriter, r *http.Reque writeError(r, w, http.StatusBadGateway, err.Error()) return } + h.audit(r, id, "transport", "切换为 "+server.AgentTransport) writeJSON(w, http.StatusOK, server) } +func (h *FRPMultiHandler) AgentTransportStatus(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r.PathValue("id")) + if err != nil { + writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效") + return + } + status, err := h.svc.AgentTransportStatus(r.Context(), id) + if err != nil { + writeError(r, w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusOK, status) +} + +func (h *FRPMultiHandler) AgentTransportRotateCert(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.RotateAgentCertificate(r.Context(), id) + if err != nil { + writeError(r, w, http.StatusBadGateway, err.Error()) + return + } + h.audit(r, id, "rotate-tls-cert", server.AgentTLSPin) + writeJSON(w, http.StatusOK, server) +} + +func (h *FRPMultiHandler) AgentFirewallStatus(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r.PathValue("id")) + if err != nil { + writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效") + return + } + port, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("port"))) + status, err := h.svc.AgentFirewallStatus(r.Context(), id, port) + if err != nil { + writeError(r, w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusOK, status) +} + +func (h *FRPMultiHandler) AgentFirewallAllow(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 { + Port int `json:"port"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + writeError(r, w, http.StatusBadRequest, "请求格式无效") + return + } + result, err := h.svc.AllowAgentFirewallPort(r.Context(), id, request.Port) + if err != nil { + writeError(r, w, http.StatusBadGateway, err.Error()) + return + } + h.audit(r, id, "firewall-allow", strconv.Itoa(request.Port)) + writeJSON(w, http.StatusOK, result) +} + +// audit 记录 Agent 传输配置变更;admin_id 取自登录会话,API Token 调用没有会话时记为 0。 +func (h *FRPMultiHandler) audit(r *http.Request, serverID int64, action, detail string) { + adminID := 0 + if sessionID, ok := r.Context().Value(sessionContextKey).(string); ok && sessionID != "" && h.auth != nil { + if id, err := h.auth.AdminIDForSession(r.Context(), sessionID); err == nil { + adminID = id + } + } + slog.Default().Info("审计:Agent 配置变更", + "module", "AUDIT", + "action", action, + "server_id", serverID, + "admin_id", adminID, + "detail", detail, + "remote_addr", r.RemoteAddr, + ) +} + 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 f547419..5468cd5 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -26,7 +26,7 @@ func NewRouter(deps Deps) http.Handler { statusPageHandler: NewStatusPageHandler(deps.Settings, deps.FRPMulti), metricsHandler: NewMetricsHandler(deps.Metrics), discoveryHandler: NewDiscoveryHandler(deps.Discovery), - frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti), + frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti, deps.Auth), updateHandler: NewUpdateHandler(deps.Updater), authSvc: deps.Auth, staticFS: deps.StaticFS, @@ -133,6 +133,10 @@ func NewRouter(deps Deps) http.Handler { 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/transport", frpHandler.AgentTransportStatus) + protect("POST /api/frpmulti/servers/{id}/agent/transport/rotate", frpHandler.AgentTransportRotateCert) + protect("GET /api/frpmulti/servers/{id}/agent/firewall", frpHandler.AgentFirewallStatus) + protect("POST /api/frpmulti/servers/{id}/agent/firewall/allow", frpHandler.AgentFirewallAllow) 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 66d4b25..4336f63 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -147,6 +147,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) + go frpMultiSvc.StartAgentCertificateRenewal(ctx) intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval) if intervalMinutes <= 0 { intervalMinutes = 5 diff --git a/internal/frp/agent_client.go b/internal/frp/agent_client.go index 080f68e..687cd24 100644 --- a/internal/frp/agent_client.go +++ b/internal/frp/agent_client.go @@ -12,6 +12,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/url" "strconv" @@ -35,21 +36,47 @@ func NewAgentClientWithPin(baseURL, token, pin string) *AgentClient { return newAgentClient(baseURL, token, pin) } -func newAgentClient(baseURL, token, pin string) *AgentClient { +// NewAgentClientWithPins 支持证书轮换过渡期同时接受旧 pin 与新 pin。 +func NewAgentClientWithPins(baseURL, token string, pins ...string) *AgentClient { + return newAgentClient(baseURL, token, pins...) +} + +func newAgentClient(baseURL, token string, pins ...string) *AgentClient { + normalized := normalizePins(pins) client := &AgentClient{ baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), token: token, - pin: strings.ToLower(strings.TrimSpace(pin)), + pin: strings.Join(normalized, ","), // 路由写入链路含 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)} + if len(normalized) > 0 { + client.http.Transport = &http.Transport{TLSClientConfig: pinTLSConfig(hostFromBaseURL(client.baseURL), normalized...)} } return client } -func pinTLSConfig(pin string) *tls.Config { +func normalizePins(pins []string) []string { + out := make([]string, 0, len(pins)) + for _, pin := range pins { + pin = strings.ToLower(strings.TrimSpace(pin)) + if pin == "" { + continue + } + out = append(out, pin) + } + return out +} + +func hostFromBaseURL(baseURL string) string { + parsed, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil { + return "" + } + return parsed.Hostname() +} + +func pinTLSConfig(host string, pins ...string) *tls.Config { return &tls.Config{ MinVersion: tls.VersionTLS12, InsecureSkipVerify: true, @@ -65,16 +92,41 @@ func pinTLSConfig(pin string) *tls.Config { if now.Before(cert.NotBefore) || now.After(cert.NotAfter) { return fmt.Errorf("Agent HTTPS 证书不在有效期内") } + if err := verifyCertificateHost(cert, host); err != nil { + return err + } sum := sha256.Sum256(rawCerts[0]) got := hex.EncodeToString(sum[:]) - if subtle.ConstantTimeCompare([]byte(got), []byte(pin)) != 1 { - return fmt.Errorf("Agent HTTPS 证书指纹不匹配") + for _, pin := range pins { + if subtle.ConstantTimeCompare([]byte(got), []byte(pin)) == 1 { + return nil + } } - return nil + return fmt.Errorf("Agent HTTPS 证书指纹不匹配") }, } } +// verifyCertificateHost 校验证书 SAN 是否匹配连接主机,避免 pin 正确但证书属于其他主机。 +func verifyCertificateHost(cert *x509.Certificate, host string) error { + host = strings.TrimSpace(host) + if host == "" { + return nil + } + if ip := net.ParseIP(host); ip != nil { + for _, candidate := range cert.IPAddresses { + if candidate.Equal(ip) { + return nil + } + } + return fmt.Errorf("Agent HTTPS 证书不包含 IP %s", host) + } + if err := cert.VerifyHostname(host); err != nil { + return fmt.Errorf("Agent HTTPS 证书 SAN 不匹配 %s", host) + } + return nil +} + func (c *AgentClient) request(ctx context.Context, method, path string, payload any, result any) error { if c.baseURL == "" || c.token == "" { return fmt.Errorf("公网 agent 未配置") @@ -134,12 +186,33 @@ func (c *AgentClient) DisableHTTPS(ctx context.Context) error { return c.request(ctx, http.MethodDelete, "/api/v1/transport/https", nil, &result) } +// HTTPSState 读取 Agent 当前 HTTPS 反代状态与证书到期时间。 +func (c *AgentClient) HTTPSState(ctx context.Context) (map[string]any, error) { + var result map[string]any + err := c.request(ctx, http.MethodGet, "/api/v1/transport/https", nil, &result) + return result, err +} + 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) return result, err } +// FirewallStatus 读取 VPS 主机防火墙类型与目标端口放行状态。 +func (c *AgentClient) FirewallStatus(ctx context.Context, port int) (map[string]any, error) { + var result map[string]any + err := c.request(ctx, http.MethodGet, "/api/v1/firewall?port="+strconv.Itoa(port), nil, &result) + return result, err +} + +// AllowFirewallPort 请求 Agent 显式放行主机防火墙端口(仅 ufw / firewalld)。 +func (c *AgentClient) AllowFirewallPort(ctx context.Context, port int) (map[string]any, error) { + var result map[string]any + err := c.request(ctx, http.MethodPost, "/api/v1/firewall/allow", map[string]any{"port": port}, &result) + return result, err +} + // agentMinVersionInsights 是 P1 三个只读端点(metrics / logs / frps-config)要求的最低 agent 版本 const agentMinVersionInsights = "0.11.0" diff --git a/internal/frp/agent_https.go b/internal/frp/agent_https.go index ecbba9a..b75cb97 100644 --- a/internal/frp/agent_https.go +++ b/internal/frp/agent_https.go @@ -16,9 +16,10 @@ import ( ) type agentCertificate struct { - CertPEM string - KeyPEM string - Pin string + CertPEM string + KeyPEM string + Pin string + NotAfter time.Time } func generateAgentCertificate(host string) (agentCertificate, error) { @@ -55,9 +56,10 @@ func generateAgentCertificate(host string) (agentCertificate, error) { } 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[:]), + 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[:]), + NotAfter: template.NotAfter, }, nil } diff --git a/internal/frp/agent_https_test.go b/internal/frp/agent_https_test.go index 1be05e0..f86373c 100644 --- a/internal/frp/agent_https_test.go +++ b/internal/frp/agent_https_test.go @@ -4,7 +4,9 @@ import ( "crypto/x509" "encoding/pem" "net" + "strings" "testing" + "time" ) func TestGenerateAgentCertificate(t *testing.T) { @@ -37,16 +39,46 @@ func TestPinTLSConfigVerification(t *testing.T) { if block == nil { t.Fatal("证书 PEM 解码失败") } - verify := pinTLSConfig(cert.Pin).VerifyPeerCertificate + verify := pinTLSConfig("agent.example.com", cert.Pin).VerifyPeerCertificate if err := verify([][]byte{block.Bytes}, nil); err != nil { t.Fatalf("正确 pin 校验失败: %v", err) } - wrong := pinTLSConfig("0000000000000000000000000000000000000000000000000000000000000000").VerifyPeerCertificate + wrong := pinTLSConfig("agent.example.com", strings.Repeat("0", 64)).VerifyPeerCertificate if err := wrong([][]byte{block.Bytes}, nil); err == nil { t.Fatal("错误 pin 应校验失败") } } +func TestPinTLSConfigAcceptsGracePins(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("agent.example.com", strings.Repeat("1", 64), cert.Pin).VerifyPeerCertificate + if err := verify([][]byte{block.Bytes}, nil); err != nil { + t.Fatalf("轮换过渡期应同时接受旧 pin 与新 pin: %v", err) + } +} + +func TestPinTLSConfigRejectsHostMismatch(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("other.example.com", cert.Pin).VerifyPeerCertificate + if err := verify([][]byte{block.Bytes}, nil); err == nil { + t.Fatal("证书 SAN 与连接主机不一致时应拒绝") + } +} + func TestAgentHTTPSBaseURL(t *testing.T) { cases := []struct { name string @@ -82,3 +114,35 @@ func TestAgentHTTPSBaseURL(t *testing.T) { }) } } + +func TestAgentTransportPins(t *testing.T) { + active := Server{ + AgentTLSPin: "new", + AgentTLSPinPrev: "old", + AgentTLSPinPrevUntil: time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + } + pins := agentTransportPins(active) + if len(pins) != 2 || pins[0] != "new" || pins[1] != "old" { + t.Fatalf("过渡期应同时返回新旧 pin:%v", pins) + } + expired := active + expired.AgentTLSPinPrevUntil = time.Now().Add(-time.Hour).UTC().Format(time.RFC3339) + if pins := agentTransportPins(expired); len(pins) != 1 || pins[0] != "new" { + t.Fatalf("过渡期结束后只应保留新 pin:%v", pins) + } +} + +func TestResultPortAndListenPort(t *testing.T) { + if got := resultPort(map[string]any{"port": float64(8443)}, 0); got != 8443 { + t.Fatalf("resultPort() = %d, 期望 8443", got) + } + if got := resultPort(nil, 7443); got != 7443 { + t.Fatalf("resultPort() 回退值 = %d, 期望 7443", got) + } + if got := listenPort("0.0.0.0:9443", 7443); got != 9443 { + t.Fatalf("listenPort() = %d, 期望 9443", got) + } + if got := listenPort("", 7443); got != 7443 { + t.Fatalf("listenPort() 回退值 = %d, 期望 7443", got) + } +} diff --git a/internal/frp/agent_tunnel.go b/internal/frp/agent_tunnel.go index 5b2cc71..9577277 100644 --- a/internal/frp/agent_tunnel.go +++ b/internal/frp/agent_tunnel.go @@ -13,6 +13,12 @@ import ( const agentRemoteAddr = "127.0.0.1:7700" +const ( + agentTunnelKeepalive = 30 * time.Second + agentTunnelBackoffMin = time.Second + agentTunnelBackoffMax = 60 * time.Second +) + type agentTunnel struct { serverID int64 localPort int @@ -59,11 +65,55 @@ func (t *agentTunnel) Start() error { t.state = "connected" t.lastError = "" t.mu.Unlock() - t.wg.Add(1) + t.wg.Add(2) go t.acceptLoop() + go t.monitorLoop() return nil } +// monitorLoop 主动保活并在断开后按指数退避重建 SSH 连接,避免只在有流量时才尝试重连。 +func (t *agentTunnel) monitorLoop() { + defer t.wg.Done() + backoff := agentTunnelBackoffMin + for { + _, err := t.ensureClient() + wait := agentTunnelKeepalive + if err != nil { + t.setState("reconnecting", err) + backoff = nextTunnelBackoff(backoff) + wait = backoff + } else { + t.setState("connected", nil) + backoff = agentTunnelBackoffMin + } + if !t.sleep(wait) { + return + } + } +} + +func (t *agentTunnel) sleep(d time.Duration) bool { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-t.ctx.Done(): + return false + case <-timer.C: + return true + } +} + +func nextTunnelBackoff(current time.Duration) time.Duration { + if current < agentTunnelBackoffMin { + return agentTunnelBackoffMin + } + next := current * 2 + if next > agentTunnelBackoffMax { + return agentTunnelBackoffMax + } + return next +} + func (t *agentTunnel) acceptLoop() { defer t.wg.Done() for { diff --git a/internal/frp/agent_tunnel_test.go b/internal/frp/agent_tunnel_test.go index bffbd0b..9219bfb 100644 --- a/internal/frp/agent_tunnel_test.go +++ b/internal/frp/agent_tunnel_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "testing" + "time" ) func TestAgentTunnelBindsLoopbackAndReleasesPort(t *testing.T) { @@ -23,8 +24,8 @@ func TestAgentTunnelBindsLoopbackAndReleasesPort(t *testing.T) { t.Fatal("隧道启动后本地端口应已被占用") } state, _ := tunnel.State() - if state != "connected" { - t.Fatalf("state=%q, want connected", state) + if state != "connected" && state != "reconnecting" { + t.Fatalf("state=%q, want connected 或 reconnecting", state) } tunnel.Stop() if listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)); err != nil { @@ -33,3 +34,20 @@ func TestAgentTunnelBindsLoopbackAndReleasesPort(t *testing.T) { _ = listener.Close() } } + +func TestNextTunnelBackoff(t *testing.T) { + cases := []struct { + in time.Duration + want time.Duration + }{ + {0, agentTunnelBackoffMin}, + {time.Second, 2 * time.Second}, + {40 * time.Second, agentTunnelBackoffMax}, + {agentTunnelBackoffMax, agentTunnelBackoffMax}, + } + for _, tc := range cases { + if got := nextTunnelBackoff(tc.in); got != tc.want { + t.Fatalf("nextTunnelBackoff(%v) = %v, 期望 %v", tc.in, got, tc.want) + } + } +} diff --git a/internal/frp/agentbin/fonu-agent-linux-amd64 b/internal/frp/agentbin/fonu-agent-linux-amd64 index e3a1468..26a32d9 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 fc5d625..968a033 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 0a6833f..3e7cc7b 100644 --- a/internal/frp/model.go +++ b/internal/frp/model.go @@ -84,44 +84,48 @@ type DownloadTask struct { } type Server struct { - ID int64 `json:"id"` - Name string `json:"name"` - AgentURL string `json:"agent_url,omitempty"` - AgentTransport string `json:"agent_transport"` - AgentLocalPort int `json:"agent_local_port,omitempty"` - AgentTLSPin string `json:"agent_tls_pin,omitempty"` - AgentListenAddr string `json:"agent_listen_addr,omitempty"` - AgentTransportState string `json:"agent_transport_state"` - AgentTransportError string `json:"agent_transport_error,omitempty"` - AgentConfigured bool `json:"agent_configured"` - SSHConfigured bool `json:"ssh_configured"` - MgmtEnabled bool `json:"mgmt_enabled"` - 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"` + AgentTLSPinPrev string `json:"agent_tls_pin_prev,omitempty"` + AgentTLSPinPrevUntil string `json:"agent_tls_pin_prev_until,omitempty"` + AgentTLSNotAfter string `json:"agent_tls_not_after,omitempty"` + AgentFirewallState string `json:"agent_firewall_state,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 c749ef7..3f66cbc 100644 --- a/internal/frp/service.go +++ b/internal/frp/service.go @@ -941,6 +941,83 @@ func (s *Service) RestartAgentTunnel(ctx context.Context, serverID int64) (Serve return server, nil } +// AgentTransportStatus 返回传输方式的详细状态,供前端与诊断使用。 +func (s *Service) AgentTransportStatus(ctx context.Context, serverID int64) (map[string]any, error) { + server, err := s.store.GetServer(ctx, serverID) + if err != nil { + return nil, err + } + state := server.AgentTransportState + lastError := server.AgentTransportError + if server.AgentTransport == "tunnel" { + if tunnelState, tunnelError, ok := s.tunnelState(serverID); ok { + state, lastError = tunnelState, tunnelError + } + } + return map[string]any{ + "transport": server.AgentTransport, + "state": state, + "agent_url": server.AgentURL, + "local_port": server.AgentLocalPort, + "listen_addr": server.AgentListenAddr, + "tls_pin": server.AgentTLSPin, + "tls_not_after": server.AgentTLSNotAfter, + "pin_prev_until": server.AgentTLSPinPrevUntil, + "firewall_state": server.AgentFirewallState, + "last_error": lastError, + }, nil +} + +// AgentFirewallStatus 检测 VPS 主机防火墙,并把结果缓存到服务端记录供 UI 展示。 +func (s *Service) AgentFirewallStatus(ctx context.Context, serverID int64, port int) (map[string]any, error) { + if port <= 0 || port > 65535 { + return nil, fmt.Errorf("端口无效") + } + client, ok := s.agentClient(ctx, serverID) + if !ok { + return nil, fmt.Errorf("当前 Agent 连接不可用") + } + status, err := client.FirewallStatus(ctx, port) + if err != nil { + return nil, err + } + _ = s.store.SetAgentFirewallState(ctx, serverID, firewallSummary(status)) + return status, nil +} + +// AllowAgentFirewallPort 由用户显式触发,Agent 仅对 ufw / firewalld 执行放行。 +func (s *Service) AllowAgentFirewallPort(ctx context.Context, serverID int64, port int) (map[string]any, error) { + if port <= 0 || port > 65535 { + return nil, fmt.Errorf("端口无效") + } + client, ok := s.agentClient(ctx, serverID) + if !ok { + return nil, fmt.Errorf("当前 Agent 连接不可用") + } + result, err := client.AllowFirewallPort(ctx, port) + if err != nil { + return nil, err + } + _ = s.store.SetAgentFirewallState(ctx, serverID, firewallSummary(result)) + return result, nil +} + +func firewallSummary(result map[string]any) string { + status := result + if nested, ok := result["status"].(map[string]any); ok { + status = nested + } + tool, _ := status["tool"].(string) + open, _ := status["port_open"].(bool) + if tool == "" { + return "" + } + if open { + return tool + ":已放行" + } + return tool + ":未放行" +} + func (s *Service) agentToken(ctx context.Context, serverID int64) (string, error) { encoded, err := s.store.AgentToken(ctx, serverID) if err != nil || encoded == "" { @@ -954,10 +1031,7 @@ func (s *Service) agentToken(ctx context.Context, serverID int64) (string, error } func (s *Service) EnableAgentHTTPS(ctx context.Context, serverID int64, port int) (Server, error) { - if port == 0 { - port = 7443 - } - if port <= 0 || port > 65535 { + if port < 0 || port > 65535 { return Server{}, fmt.Errorf("HTTPS 端口无效") } server, err := s.store.GetServer(ctx, serverID) @@ -983,16 +1057,23 @@ func (s *Service) EnableAgentHTTPS(ctx context.Context, serverID int64, port int if err != nil { return Server{}, err } - baseURL := fmt.Sprintf("https://%s:%d", server.SSHHost, port) + actualPort := resultPort(result, port) + if actualPort <= 0 { + return Server{}, fmt.Errorf("Agent 未返回可用的 HTTPS 端口") + } + baseURL := fmt.Sprintf("https://%s:%d", server.SSHHost, actualPort) pinned := NewAgentClientWithPin(baseURL, token, cert.Pin) if err := pinned.Ping(ctx); err != nil { _ = currentClient.DisableHTTPS(ctx) if result["self_check"] == "ok" { - return Server{}, fmt.Errorf("HTTPS 直连验证失败:Agent 本机 7443 自检通过,但公网 %s 返回的证书不一致,请检查该端口是否被其他服务、端口转发或防火墙接管: %w", baseURL, err) + return Server{}, fmt.Errorf("HTTPS 直连验证失败:Agent 本机 %d 端口自检通过,但公网 %s 返回的证书不一致,请检查该端口是否被其他服务、端口转发或防火墙接管: %w", actualPort, baseURL, err) } return Server{}, fmt.Errorf("HTTPS 直连验证失败: %w", err) } - if err := s.store.SetAgentTransport(ctx, serverID, baseURL, "https-pin", server.AgentLocalPort, cert.Pin, fmt.Sprintf("0.0.0.0:%d", port), "connected", ""); err != nil { + if err := s.store.SetAgentTransport(ctx, serverID, baseURL, "https-pin", server.AgentLocalPort, cert.Pin, fmt.Sprintf("0.0.0.0:%d", actualPort), "connected", ""); err != nil { + return Server{}, err + } + if err := s.store.SetAgentTLSPinRotation(ctx, serverID, cert.Pin, "", "", cert.NotAfter.UTC().Format(time.RFC3339)); err != nil { return Server{}, err } s.invalidateAgentClient(serverID) @@ -1004,6 +1085,128 @@ func (s *Service) EnableAgentHTTPS(ctx context.Context, serverID int64, port int return updated, nil } +const ( + agentCertRenewBefore = 30 * 24 * time.Hour + agentCertPinGrace = 7 * 24 * time.Hour +) + +// StartAgentCertificateRenewal 后台巡检 HTTPS 直连证书,剩余不足 30 天时自动轮换。 +func (s *Service) StartAgentCertificateRenewal(ctx context.Context) { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + s.renewAgentCertificates(ctx) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.renewAgentCertificates(ctx) + } + } +} + +func (s *Service) renewAgentCertificates(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 != "https-pin" { + continue + } + notAfter, ok := s.agentCertificateExpiry(ctx, server) + if !ok || time.Until(notAfter) > agentCertRenewBefore { + continue + } + if _, err := s.RotateAgentCertificate(ctx, server.ID); err != nil { + s.logger.Warn("Agent 证书自动轮换失败", "server_id", server.ID, "error", err.Error()) + } + } +} + +// agentCertificateExpiry 优先使用数据库缓存;旧记录缺少到期时间时从 Agent 补齐。 +func (s *Service) agentCertificateExpiry(ctx context.Context, server Server) (time.Time, bool) { + if raw := strings.TrimSpace(server.AgentTLSNotAfter); raw != "" { + if parsed, err := time.Parse(time.RFC3339, raw); err == nil { + return parsed, true + } + } + client, ok := s.agentClient(ctx, server.ID) + if !ok { + return time.Time{}, false + } + state, err := client.HTTPSState(ctx) + if err != nil { + return time.Time{}, false + } + raw, _ := state["not_after"].(string) + parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(raw)) + if err != nil { + return time.Time{}, false + } + _ = s.store.SetAgentTLSNotAfter(ctx, server.ID, parsed.UTC().Format(time.RFC3339)) + return parsed, true +} + +// RotateAgentCertificate 安装新证书;过渡期内客户端同时接受新 pin 与旧 pin。 +func (s *Service) RotateAgentCertificate(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" { + return Server{}, fmt.Errorf("仅 HTTPS 直连模式支持证书轮换") + } + token, err := s.agentToken(ctx, serverID) + if err != nil { + return Server{}, err + } + cert, err := generateAgentCertificate(server.SSHHost) + if err != nil { + return Server{}, err + } + oldPin := strings.TrimSpace(server.AgentTLSPin) + port := listenPort(server.AgentListenAddr, 7443) + configureClient := NewAgentClientWithPins(agentHTTPSBaseURL(server), token, cert.Pin, oldPin) + result, err := configureClient.ConfigureHTTPS(ctx, cert.CertPEM, cert.KeyPEM, port) + if err != nil { + return Server{}, err + } + actualPort := resultPort(result, port) + baseURL := fmt.Sprintf("https://%s:%d", server.SSHHost, actualPort) + graceUntil := time.Now().Add(agentCertPinGrace).UTC().Format(time.RFC3339) + notAfter := cert.NotAfter.UTC().Format(time.RFC3339) + if err := s.store.SetAgentTransport(ctx, serverID, baseURL, "https-pin", server.AgentLocalPort, cert.Pin, fmt.Sprintf("0.0.0.0:%d", actualPort), "connected", ""); err != nil { + return Server{}, err + } + if err := s.store.SetAgentTLSPinRotation(ctx, serverID, cert.Pin, oldPin, graceUntil, notAfter); err != nil { + return Server{}, err + } + s.invalidateAgentClient(serverID) + dual := NewAgentClientWithPins(baseURL, token, cert.Pin, oldPin) + if err := dual.Ping(ctx); err != nil { + return Server{}, fmt.Errorf("证书轮换后公网校验失败(旧 pin 在过渡期仍可用): %w", err) + } + return s.store.GetServer(ctx, serverID) +} + +func resultPort(result map[string]any, fallback int) int { + if raw, ok := result["port"].(float64); ok && raw > 0 && raw <= 65535 { + return int(raw) + } + return fallback +} + +func listenPort(listenAddr string, fallback int) int { + if _, rawPort, err := net.SplitHostPort(strings.TrimSpace(listenAddr)); err == nil { + if parsed, err := strconv.Atoi(rawPort); err == nil && parsed > 0 && parsed <= 65535 { + return parsed + } + } + return fallback +} + func (s *Service) SwitchAgentToTunnel(ctx context.Context, serverID int64) (Server, error) { server, err := s.store.GetServer(ctx, serverID) if err != nil { @@ -1609,15 +1812,35 @@ func agentHTTPSBaseURL(server Server) string { if host == "" { return current } - port := 7443 - if _, rawPort, err := net.SplitHostPort(strings.TrimSpace(server.AgentListenAddr)); err == nil { - if parsed, err := strconv.Atoi(rawPort); err == nil && parsed > 0 { - port = parsed - } - } + port := listenPort(server.AgentListenAddr, 7443) return fmt.Sprintf("https://%s:%d", host, port) } +// agentTransportPins 返回当前应接受的证书 pin;轮换过渡期内同时包含旧 pin。 +func agentTransportPins(server Server) []string { + out := make([]string, 0, 2) + if pin := strings.TrimSpace(server.AgentTLSPin); pin != "" { + out = append(out, pin) + } + prev := strings.TrimSpace(server.AgentTLSPinPrev) + if prev == "" || !pinGraceActive(server.AgentTLSPinPrevUntil) { + return out + } + return append(out, prev) +} + +func pinGraceActive(raw string) bool { + raw = strings.TrimSpace(raw) + if raw == "" { + return false + } + deadline, err := time.Parse(time.RFC3339, raw) + if err != nil { + return false + } + return time.Now().Before(deadline) +} + func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient, bool) { server, err := s.store.GetServer(ctx, serverID) if err != nil || !server.MgmtEnabled || !server.AgentConfigured { @@ -1632,7 +1855,7 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient return nil, false } baseURL := server.AgentURL - pin := "" + var pins []string if server.AgentTransport == "tunnel" { tunnel, err := s.ensureAgentTunnel(ctx, serverID) if err != nil { @@ -1640,8 +1863,8 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient } baseURL = fmt.Sprintf("http://127.0.0.1:%d", tunnel.localPort) } else if server.AgentTransport == "https-pin" { - pin = strings.TrimSpace(server.AgentTLSPin) - if pin == "" { + pins = agentTransportPins(server) + if len(pins) == 0 { return nil, false } baseURL = agentHTTPSBaseURL(server) @@ -1650,7 +1873,7 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient } if baseURL != strings.TrimRight(strings.TrimSpace(server.AgentURL), "/") { err := s.store.SetAgentTransport(ctx, serverID, baseURL, "https-pin", server.AgentLocalPort, - pin, server.AgentListenAddr, server.AgentTransportState, server.AgentTransportError) + server.AgentTLSPin, server.AgentListenAddr, server.AgentTransportState, server.AgentTransportError) if err != nil { s.logger.Warn("agent https url repair failed", "server_id", serverID, "error", err) } @@ -1659,7 +1882,7 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient return nil, false } // 复用同一个 http.Client:批量同步 N 条路由时不再每次新建 Transport 与 TLS 握手 - fingerprint := baseURL + "|" + encoded + "|" + pin + fingerprint := baseURL + "|" + encoded + "|" + strings.Join(pins, ",") s.agentMu.Lock() defer s.agentMu.Unlock() if s.agentClients == nil { @@ -1669,8 +1892,8 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient return cached.client, true } var client *AgentClient - if pin != "" { - client = NewAgentClientWithPin(baseURL, token, pin) + if len(pins) > 0 { + client = NewAgentClientWithPins(baseURL, token, pins...) } else { client = NewAgentClient(baseURL, token) } diff --git a/internal/frp/store.go b/internal/frp/store.go index 045b799..8de04bb 100644 --- a/internal/frp/store.go +++ b/internal/frp/store.go @@ -297,6 +297,54 @@ func (s *Store) SetAgentTransport(ctx context.Context, id int64, agentURL, trans return nil } +// SetAgentTLSPinRotation 更新当前证书 pin、过渡期旧 pin 与证书到期时间。 +func (s *Store) SetAgentTLSPinRotation(ctx context.Context, id int64, pin, prevPin, prevUntil, notAfter string) error { + result, err := s.db.ExecContext(ctx, ` + UPDATE frp_servers + SET agent_tls_pin = ?, agent_tls_pin_prev = ?, agent_tls_pin_prev_until = ?, agent_tls_not_after = ?, + updated_at = datetime('now') + WHERE id = ? + `, pin, prevPin, prevUntil, notAfter, id) + if err != nil { + return err + } + count, _ := result.RowsAffected() + if count == 0 { + return fmt.Errorf("FRP 服务端不存在") + } + return nil +} + +// SetAgentFirewallState 记录最近一次主机防火墙检测 / 放行结果,供前端展示。 +func (s *Store) SetAgentFirewallState(ctx context.Context, id int64, state string) error { + result, err := s.db.ExecContext(ctx, ` + UPDATE frp_servers SET agent_firewall_state = ?, updated_at = datetime('now') WHERE id = ? + `, state, id) + if err != nil { + return err + } + count, _ := result.RowsAffected() + if count == 0 { + return fmt.Errorf("FRP 服务端不存在") + } + return nil +} + +// SetAgentTLSNotAfter 只同步证书到期时间,不改动 pin(用于旧记录补齐)。 +func (s *Store) SetAgentTLSNotAfter(ctx context.Context, id int64, notAfter string) error { + result, err := s.db.ExecContext(ctx, ` + UPDATE frp_servers SET agent_tls_not_after = ?, updated_at = datetime('now') WHERE id = ? + `, notAfter, 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) @@ -447,6 +495,8 @@ SELECT id, name, server_addr, server_port, tls_enabled, tls_server_name, enabled 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_tls_pin_prev, ''), COALESCE(agent_tls_pin_prev_until, ''), COALESCE(agent_tls_not_after, ''), + COALESCE(agent_firewall_state, ''), COALESCE(agent_listen_addr, ''), COALESCE(agent_transport_state, 'unknown'), COALESCE(agent_transport_error, ''), COALESCE(config_json, '{}'), created_at, updated_at FROM frp_servers` @@ -459,7 +509,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, &server.AgentTransport, &server.AgentLocalPort, &server.AgentTLSPin, &server.AgentListenAddr, &server.AgentTransportState, &server.AgentTransportError, &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.AgentTLSPinPrev, &server.AgentTLSPinPrevUntil, &server.AgentTLSNotAfter, &server.AgentFirewallState, &server.AgentListenAddr, &server.AgentTransportState, &server.AgentTransportError, &configJSON, &created, &updated) if err != nil { return Server{}, err } diff --git a/migrations/029_agent_tls_rotation.sql b/migrations/029_agent_tls_rotation.sql new file mode 100644 index 0000000..86b0b53 --- /dev/null +++ b/migrations/029_agent_tls_rotation.sql @@ -0,0 +1,5 @@ +-- Agent TLS 轮换与主机防火墙辅助状态。 +ALTER TABLE frp_servers ADD COLUMN agent_tls_pin_prev TEXT NOT NULL DEFAULT ''; +ALTER TABLE frp_servers ADD COLUMN agent_tls_pin_prev_until TEXT NOT NULL DEFAULT ''; +ALTER TABLE frp_servers ADD COLUMN agent_tls_not_after TEXT NOT NULL DEFAULT ''; +ALTER TABLE frp_servers ADD COLUMN agent_firewall_state TEXT NOT NULL DEFAULT ''; diff --git a/web/package-lock.json b/web/package-lock.json index 87fa6d7..c47789e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "fonu-web", - "version": "1.4.6", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fonu-web", - "version": "1.4.6", + "version": "1.5.0", "dependencies": { "@codemirror/commands": "^6.11.1", "@codemirror/lang-javascript": "^6.2.5", diff --git a/web/package.json b/web/package.json index cfa781c..11be064 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "fonu-web", "private": true, - "version": "1.4.6", + "version": "1.5.0", "packageManager": "npm@10.9.4", "type": "module", "scripts": { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ec14cbb..cab3a9e 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -307,11 +307,22 @@ 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) => + setFrpAgentTransport: (id: number, transport: 'tunnel' | 'https-pin', port = 0) => request(`/api/frpmulti/servers/${id}/agent/transport`, { method: 'POST', body: JSON.stringify({ transport, port }), }), + getFrpAgentTransport: (id: number) => + request>(`/api/frpmulti/servers/${id}/agent/transport`), + rotateFrpAgentTLSCert: (id: number) => + request(`/api/frpmulti/servers/${id}/agent/transport/rotate`, { method: 'POST' }), + getFrpAgentFirewall: (id: number, port: number) => + request>(`/api/frpmulti/servers/${id}/agent/firewall?port=${port}`), + allowFrpAgentFirewallPort: (id: number, port: number) => + request>(`/api/frpmulti/servers/${id}/agent/firewall/allow`, { + method: 'POST', + body: JSON.stringify({ 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 d901b84..a01b368 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -399,6 +399,10 @@ export interface FrpServer { agent_transport: 'tunnel' | 'https-pin' | 'http' | string agent_local_port?: number agent_tls_pin?: string + agent_tls_pin_prev?: string + agent_tls_pin_prev_until?: string + agent_tls_not_after?: string + agent_firewall_state?: string agent_listen_addr?: string agent_transport_state: string agent_transport_error?: string diff --git a/web/src/views/AgentView.vue b/web/src/views/AgentView.vue index b215f71..2f33b6e 100644 --- a/web/src/views/AgentView.vue +++ b/web/src/views/AgentView.vue @@ -249,13 +249,16 @@
{{ agentTransportStateText(server) }}
{{ server.agent_transport === 'tunnel' && server.agent_local_port ? `127.0.0.1:${server.agent_local_port}` : '—' }}
{{ server.agent_listen_addr || '—' }}
+
{{ agentHTTPSPort(server) || '—' }}
+
{{ server.agent_tls_not_after ? formatDate(server.agent_tls_not_after) : '—' }}
当前仍是公网明文 HTTP。点击「升级 Agent」可自动迁移到 SSH 隧道安全模式。 - HTTPS 直连使用证书指纹校验。请确认云安全组已放行配置的 TCP 端口,Fonu 不会自动修改云安全组。 + HTTPS 直连使用证书指纹校验,证书到期前 30 天会自动轮换。请确认云安全组已放行 {{ agentHTTPSPort(server) || 'HTTPS' }} 端口,Fonu 不会自动修改云安全组。 +

主机防火墙:{{ server.agent_firewall_state }}

{{ server.agent_transport_error }}

{{ server.agent_configured ? '升级 Agent' : '安装 Agent' }} @@ -264,7 +267,15 @@ - 将使用自签名证书启用 TCP 7443。请确保云安全组已放行该端口,否则切换后 Agent 会不可达。继续? + 将自动从 TCP 7443 / 8443 / 9443 中选择可用端口启用自签名 HTTPS。请确保云安全组已放行其中之一,否则切换后 Agent 会不可达。继续? + + 轮换证书 + 检测主机防火墙 + + + 只会对 ufw / firewalld 执行放行,不会修改云安全组。继续?