feat(agent): 完成 HTTPS 自动端口、证书轮换与防火墙辅助并发布 v1.5.0

This commit is contained in:
2026-09-28 15:19:51 +08:00
parent dfce843b50
commit faf43293e5
29 changed files with 1018 additions and 132 deletions
+24
View File
@@ -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
### 修复
+1 -1
View File
@@ -1 +1 @@
1.4.6
1.5.0
+1 -1
View File
@@ -1,7 +1,7 @@
# Fonu 文档索引
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
> 最近更新:2026-09-28(v1.4.6)
> 最近更新:2026-09-28(v1.5.0)
## 方案与设计(本目录)
+6 -4
View File
@@ -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 必须提示:
+4 -4
View File
@@ -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`;
- 页面显示独立的「本次升级验证」区块;
- 「升级验证」显示为「通过」。
+127
View File
@@ -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)")
}
}
+38
View File
@@ -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)
}
}
+5 -1
View File
@@ -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))
+55 -13
View File
@@ -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 {
+90 -2
View File
@@ -4,11 +4,13 @@ import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/fonu/fonu/internal/auth"
"github.com/fonu/fonu/internal/frp"
)
@@ -16,6 +18,7 @@ import (
// 包装 internal/frp 的 Service(0.1.25 核心迁移版)。
type FRPMultiHandler struct {
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 {
+5 -1
View File
@@ -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)
+1
View File
@@ -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
+81 -8
View File
@@ -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 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"
+2
View File
@@ -19,6 +19,7 @@ type agentCertificate struct {
CertPEM string
KeyPEM string
Pin string
NotAfter time.Time
}
func generateAgentCertificate(host string) (agentCertificate, error) {
@@ -58,6 +59,7 @@ func generateAgentCertificate(host string) (agentCertificate, error) {
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
}
+66 -2
View File
@@ -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)
}
}
+51 -1
View File
@@ -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 {
+20 -2
View File
@@ -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)
}
}
}
Binary file not shown.
Binary file not shown.
+4
View File
@@ -90,6 +90,10 @@ type Server struct {
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"`
+243 -20
View File
@@ -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)
}
+51 -1
View File
@@ -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
}
+5
View File
@@ -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 '';
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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": {
+12 -1
View File
@@ -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<FrpServer>(`/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<FrpServer>(`/api/frpmulti/servers/${id}/agent/transport`, {
method: 'POST',
body: JSON.stringify({ transport, port }),
}),
getFrpAgentTransport: (id: number) =>
request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/transport`),
rotateFrpAgentTLSCert: (id: number) =>
request<FrpServer>(`/api/frpmulti/servers/${id}/agent/transport/rotate`, { method: 'POST' }),
getFrpAgentFirewall: (id: number, port: number) =>
request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/firewall?port=${port}`),
allowFrpAgentFirewallPort: (id: number, port: number) =>
request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/firewall/allow`, {
method: 'POST',
body: JSON.stringify({ port }),
}),
probeFrpAgent: (id: number) => request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/probe`, { method: 'POST' }),
installFrpAgent: (id: number) => request<FrpServer>(`/api/frpmulti/servers/${id}/agent/install`, { method: 'POST' }),
uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { method: 'POST' }),
+4
View File
@@ -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
+66 -14
View File
@@ -249,13 +249,16 @@
<div><label>隧道状态</label><span>{{ agentTransportStateText(server) }}</span></div>
<div><label>本地入口</label><span class="mono">{{ server.agent_transport === 'tunnel' && server.agent_local_port ? `127.0.0.1:${server.agent_local_port}` : '—' }}</span></div>
<div><label>Agent 监听</label><span class="mono">{{ server.agent_listen_addr || '—' }}</span></div>
<div v-if="server.agent_transport === 'https-pin'"><label>HTTPS 端口</label><span class="mono">{{ agentHTTPSPort(server) || '—' }}</span></div>
<div v-if="server.agent_transport === 'https-pin'"><label>证书到期</label><span>{{ server.agent_tls_not_after ? formatDate(server.agent_tls_not_after) : '—' }}</span></div>
</div>
<n-alert v-if="server.agent_transport === 'http'" type="warning" :bordered="false" class="agent-transport-warning">
当前仍是公网明文 HTTP。点击「升级 Agent」可自动迁移到 SSH 隧道安全模式。
</n-alert>
<n-alert v-if="server.agent_transport === 'https-pin'" type="info" :bordered="false" class="agent-transport-warning">
HTTPS 直连使用证书指纹校验。请确认云安全组已放行配置的 TCP 端口,Fonu 不会自动修改云安全组。
HTTPS 直连使用证书指纹校验,证书到期前 30 天会自动轮换。请确认云安全组已放行 {{ agentHTTPSPort(server) || 'HTTPS' }} 端口,Fonu 不会自动修改云安全组。
</n-alert>
<p v-if="server.agent_transport === 'https-pin' && server.agent_firewall_state" class="form-hint">主机防火墙:{{ server.agent_firewall_state }}</p>
<p v-if="server.agent_transport_error" class="form-hint agent-transport-error">{{ server.agent_transport_error }}</p>
<div class="agent-actions">
<n-button size="small" :loading="busy === 'agent-install'" :disabled="busy !== null && busy !== 'agent-install'" @click="installAgent(server)">{{ server.agent_configured ? '升级 Agent' : '安装 Agent' }}</n-button>
@@ -264,7 +267,15 @@
<template #trigger>
<n-button size="small" :loading="busy === 'transport-https'" :disabled="busy !== null && busy !== 'transport-https'">切换 HTTPS 直连</n-button>
</template>
将使用自签名证书启用 TCP 7443。请确保云安全组已放行该端口,否则切换后 Agent 会不可达。继续?
将自动从 TCP 7443 / 8443 / 9443 中选择可用端口启用自签名 HTTPS。请确保云安全组已放行其中之一,否则切换后 Agent 会不可达。继续?
</n-popconfirm>
<n-button v-if="server.agent_transport === 'https-pin'" size="small" :loading="busy === 'tls-rotate'" :disabled="busy !== null && busy !== 'tls-rotate'" @click="rotateAgentCert(server)">轮换证书</n-button>
<n-button v-if="server.agent_transport === 'https-pin'" size="small" :loading="busy === 'firewall-check'" :disabled="busy !== null && busy !== 'firewall-check'" @click="checkAgentFirewall(server)">检测主机防火墙</n-button>
<n-popconfirm v-if="server.agent_transport === 'https-pin' && firewallInfo && firewallInfo.serverId === server.id && !firewallInfo.data.port_open" @positive-click="allowAgentFirewall(server)">
<template #trigger>
<n-button size="small" :loading="busy === 'firewall-allow'" :disabled="busy !== null && busy !== 'firewall-allow'">放行主机防火墙</n-button>
</template>
只会对 ufw / firewalld 执行放行,不会修改云安全组。继续?
</n-popconfirm>
<n-popconfirm v-if="server.agent_transport === 'https-pin'" @positive-click="switchAgentToTunnel(server)">
<template #trigger>
@@ -666,7 +677,7 @@
</template>
<script setup lang="ts">
import { computed, h, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { computed, h, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { DataTableColumns } from 'naive-ui'
import { NButton, NIcon, NTag, useDialog, useMessage } from 'naive-ui'
@@ -694,6 +705,7 @@ import type {
import { formatBytes, formatDate, formatRelativeTime, formatUptime } from '../utils/format'
import type { StatusKind } from '../utils/status'
import { copyText } from '../utils/clipboard'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
use([CanvasRenderer, LineChart, ScatterChart, GridComponent, TooltipComponent])
@@ -701,6 +713,7 @@ use([CanvasRenderer, LineChart, ScatterChart, GridComponent, TooltipComponent])
type Action =
| 'probe' | 'agent-install' | 'agent-uninstall' | 'diagnose'
| 'tunnel-restart' | 'transport-https' | 'transport-tunnel'
| 'tls-rotate' | 'firewall-check' | 'firewall-allow'
| 'frps-start' | 'frps-stop' | 'frps-restart' | 'frps-install' | 'push-config'
interface OpOutput { at: string; source: string; text: string; ok: boolean }
@@ -722,6 +735,7 @@ const agentStatusOk = ref(false)
const statusLoaded = ref(false)
const statusLoading = ref(false)
const busy = ref<Action | null>(null)
const firewallInfo = ref<{ serverId: number; data: Record<string, any> } | null>(null)
const opOutput = ref<OpOutput[]>([])
const frpsInstallVersion = ref<string | null>(null)
const releases = ref<FrpRelease[]>([])
@@ -729,7 +743,6 @@ const releasesLoading = ref(false)
const configModal = ref(false)
const configContent = ref('')
const configLoading = ref(false)
let timer: number | undefined
const watchedPorts = ['80', '443', '7000', '8080'] as const
@@ -1430,11 +1443,52 @@ function restartAgentTunnel(target: FrpServer) {
}
function enableAgentHTTPS(target: FrpServer) {
return run('transport-https', '切换 HTTPS 直连', () => api.setFrpAgentTransport(target.id, 'https-pin', 7443),
() => ({ text: 'HTTPS 直连已启用并通过证书指纹验证', ok: true }))
return run('transport-https', '切换 HTTPS 直连', () => api.setFrpAgentTransport(target.id, 'https-pin', 0),
() => ({ text: 'HTTPS 直连已启用并通过证书指纹验证(已自动选择端口)', ok: true }))
.then(async (result) => { if (result !== undefined) await load() })
}
function agentHTTPSPort(server: FrpServer): number {
const addr = server.agent_listen_addr ?? ''
const index = addr.lastIndexOf(':')
if (index < 0) return 0
const port = Number(addr.slice(index + 1))
return Number.isInteger(port) && port > 0 ? port : 0
}
function rotateAgentCert(target: FrpServer) {
return run('tls-rotate', '轮换 HTTPS 证书', () => api.rotateFrpAgentTLSCert(target.id),
() => ({ text: 'HTTPS 证书已轮换,过渡期内同时接受新旧指纹', ok: true }))
.then(async (result) => { if (result !== undefined) await load() })
}
function checkAgentFirewall(target: FrpServer) {
const port = agentHTTPSPort(target)
if (!port) {
message.warning('请先切换到 HTTPS 直连')
return
}
return run('firewall-check', '检测主机防火墙', () => api.getFrpAgentFirewall(target.id, port),
(result) => ({ text: `主机防火墙:${result.tool ?? '未知'},端口${result.port_open ? '已放行' : '未放行'}`, ok: true }))
.then(async (result) => {
if (result) firewallInfo.value = { serverId: target.id, data: result }
})
}
function allowAgentFirewall(target: FrpServer) {
const port = agentHTTPSPort(target)
if (!port) {
message.warning('请先切换到 HTTPS 直连')
return
}
return run('firewall-allow', '放行主机防火墙', () => api.allowFrpAgentFirewallPort(target.id, port),
() => ({ text: `已在主机防火墙放行 TCP ${port}`, ok: true }))
.then(async (result) => {
if (result) firewallInfo.value = { serverId: target.id, data: result.status ?? result }
await load()
})
}
function switchAgentToTunnel(target: FrpServer) {
return run('transport-tunnel', '切回 SSH 隧道', () => api.setFrpAgentTransport(target.id, 'tunnel'),
() => ({ text: '已关闭 HTTPS 直连并恢复 SSH 隧道', ok: true }))
@@ -1453,11 +1507,11 @@ function agentTransportKind(server: FrpServer): StatusKind {
}
function agentTransportStateText(server: FrpServer) {
if (server.agent_transport !== 'tunnel') return '不适用'
if (server.agent_transport === 'http') return '不适用'
if (server.agent_transport_state === 'connected') return '已连接'
if (server.agent_transport_state === 'reconnecting') return '重连中'
if (server.agent_transport_state === 'failed') return '连接失败'
return '未建立'
return server.agent_transport === 'https-pin' ? '待验证' : '未建立'
}
function processStateText(server: FrpServer) {
@@ -1610,14 +1664,12 @@ async function loadTLSFile(target: 'certificate' | 'key' | 'ca', event: Event) {
onMounted(() => {
void load()
void loadReleases()
timer = window.setInterval(() => {
if (!server.value) return
if (document.visibilityState === 'hidden') return // 页面不可见时暂停轮询
void refreshAll()
}, 10000)
})
onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
useVisibilityPolling(() => {
if (!server.value) return
void refreshAll()
}, 10_000)
</script>
<style scoped>
+8 -8
View File
@@ -8,7 +8,7 @@
</template>
</PageHeader>
<n-alert type="success" :bordered="false" class="example-alert" title="v1.4.6 示例页面验证标识已生效">
<n-alert type="success" :bordered="false" class="example-alert" title="v1.5.0 示例页面验证标识已生效">
当前页面由 v{{ version }} 提供,验证标识为 <span class="mono">{{ verificationMarker }}</span>。
如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。
</n-alert>
@@ -19,7 +19,7 @@
<strong class="example-proof__value">{{ verificationRound }}</strong>
</div>
<div class="example-proof__hint">
看到这个新区块,说明前端资源已经从旧版本切换到 v1.4.6。
看到这个新区块,说明前端资源已经从旧版本切换到 v1.5.0。
</div>
</div>
@@ -33,16 +33,16 @@
<StatCard label="本次验证标识" :value="verificationMarker" sub="用于区分升级前后的前端资源" tone="blue" value-small>
<template #icon><n-icon :component="FlashOutline" /></template>
</StatCard>
<StatCard label="页面状态" value="已更新" sub="v1.4.6 前端资源已加载" tone="teal">
<StatCard label="页面状态" value="已更新" sub="v1.5.0 前端资源已加载" tone="teal">
<template #icon><n-icon :component="CheckmarkCircleOutline" /></template>
</StatCard>
</div>
<FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化">
<ul class="example-changes">
<li>页面版本提示更新为 v1.4.6。</li>
<li>「本次升级验证」区块和页面状态同步更新为 v1.4.6。</li>
<li>验证标识更新为 <span class="mono">v1.4.6-example-page</span>。</li>
<li>页面版本提示更新为 v1.5.0。</li>
<li>「本次升级验证」区块和页面状态同步更新为 v1.5.0。</li>
<li>验证标识更新为 <span class="mono">v1.5.0-example-page</span>。</li>
</ul>
</FonuCard>
@@ -87,8 +87,8 @@ const message = useMessage()
const version = ref(__APP_VERSION__)
const checkedAt = ref('')
const refreshing = ref(false)
const verificationRound = 'v1.4.6'
const verificationMarker = 'v1.4.6-example-page'
const verificationRound = 'v1.5.0'
const verificationMarker = 'v1.5.0-example-page'
async function refreshVersion() {
refreshing.value = true