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. 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 ## [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 文档索引 # Fonu 文档索引
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 > 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
> 最近更新:2026-09-28(v1.4.6) > 最近更新:2026-09-28(v1.5.0)
## 方案与设计(本目录) ## 方案与设计(本目录)
+6 -4
View File
@@ -2,10 +2,10 @@
> 方案编号:P0-5 > 方案编号:P0-5
> 制定日期:2026-09-28 > 制定日期:2026-09-28
> 当前 AgentVersion:0.13.3 > 当前 AgentVersion:0.14.0
> 目标:默认不暴露 Agent 管理接口,安装和升级过程自动建立安全连接,不要求用户手工配置端口、隧道或证书 > 目标:默认不暴露 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 防火墙 ### 9.4 防火墙
首版不自动修改主机防火墙。阶段 4 之后,在用户明确确认时可以辅助生成并执行主机防火墙命令: > 实施更新(v1.5.0 / Agent 0.14.0):已支持检测 ufw / firewalld / nftables / iptables;对 ufw 与 firewalld 可在用户明确确认后自动放行,nftables / iptables 仅检测和提示,不会自动改规则。
自动放行仅在用户明确确认后执行,支持的工具:
- ufw; - ufw;
- firewalld; - firewalld;
- nftables / iptables。 - nftables / iptables(只提示,不自动改规则)。
云厂商安全组始终不能自动修改。UI 必须提示: 云厂商安全组始终不能自动修改。UI 必须提示:
+4 -4
View File
@@ -238,16 +238,16 @@ FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu
## 10. 在线升级验证 ## 10. 在线升级验证
示例页面用于验证升级结果。v1.4.6 的验证流程: 示例页面用于验证升级结果。v1.5.0 的验证流程:
1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。 1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。
2. 确认远端 `main` 分支的 `VERSION` 为 `1.4.6`。 2. 确认远端 `main` 分支的 `VERSION` 为 `1.5.0`。
3. 在侧栏看到「一键升级」后点击并确认。 3. 在侧栏看到「一键升级」后点击并确认。
4. 等待升级弹窗显示成功,页面自动刷新。 4. 等待升级弹窗显示成功,页面自动刷新。
5. 打开侧栏「示例页面」,确认: 5. 打开侧栏「示例页面」,确认:
- 页面路由为 `/example`; - 页面路由为 `/example`;
- 页面显示的版本为 `1.4.6`; - 页面显示的版本为 `1.5.0`;
- 页面显示验证标识 `v1.4.6-example-page`; - 页面显示验证标识 `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 直连持续失败的问题。 // 修掉「配置目录还没探测出来、迁移逻辑没机会执行」导致 HTTPS 直连持续失败的问题。
// 0.13.3:HTTPS 反代改用 default_server 并在 reload 后做本机证书指纹自检, // 0.13.3:HTTPS 反代改用 default_server 并在 reload 后做本机证书指纹自检,
// 证书不匹配时回滚并回报实际指纹与端口占用进程,区分「nginx 未生效」与「公网链路被接管」。 // 证书不匹配时回滚并回报实际指纹与端口占用进程,区分「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 配置。 // Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
type Config struct { 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("PUT /api/v1/china-cidr", s.auth(s.putChinaCIDR))
mux.HandleFunc("POST /api/v1/nginx/install", s.auth(s.installNginx)) mux.HandleFunc("POST /api/v1/nginx/install", s.auth(s.installNginx))
mux.HandleFunc("PUT /api/v1/transport/https", s.auth(s.putHTTPSProxy)) mux.HandleFunc("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("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("DELETE /api/v1/routes/{domain}", s.auth(s.deleteRoute))
mux.HandleFunc("PUT /api/v1/frps/config", s.auth(s.putFRPSConfig)) mux.HandleFunc("PUT /api/v1/frps/config", s.auth(s.putFRPSConfig))
mux.HandleFunc("POST /api/v1/frps/install", s.auth(s.installFRPS)) mux.HandleFunc("POST /api/v1/frps/install", s.auth(s.installFRPS))
+55 -13
View File
@@ -20,7 +20,11 @@ import (
"github.com/fonu/fonu/internal/fsutil" "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 { type httpsProxyRequest struct {
CertPEM string `json:"cert_pem"` 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"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return return
} }
if req.Port <= 0 || req.Port > 65535 { if req.Port < 0 || req.Port > 65535 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "端口需在 1-65535 之间"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "端口需在 0-65535 之间,0 表示自动选择"})
return return
} }
if _, err := tls.X509KeyPair([]byte(req.CertPEM), []byte(req.KeyPEM)); err != nil { 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() s.mu.Lock()
defer s.mu.Unlock() 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()}) writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return 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) { 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}) 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() dir, err := s.ensureNginxDir()
if err != nil { if err != nil {
return err return err
@@ -78,12 +122,10 @@ func (s *Server) configureHTTPSProxy(req httpsProxyRequest) error {
if err := os.MkdirAll(tlsDir, 0o750); err != nil { if err := os.MkdirAll(tlsDir, 0o750); err != nil {
return err return err
} }
certPath := filepath.Join(tlsDir, "agent.crt") if err := atomicWrite(httpsProxyCertPath, []byte(req.CertPEM), 0o640); err != nil {
keyPath := filepath.Join(tlsDir, "agent.key")
if err := atomicWrite(certPath, []byte(req.CertPEM), 0o640); err != nil {
return err return err
} }
if err := atomicWrite(keyPath, []byte(req.KeyPEM), 0o600); err != nil { if err := atomicWrite(httpsProxyKeyPath, []byte(req.KeyPEM), 0o600); err != nil {
return err return err
} }
path := filepath.Join(dir, httpsProxyConfigName) path := filepath.Join(dir, httpsProxyConfigName)
@@ -91,7 +133,7 @@ func (s *Server) configureHTTPSProxy(req httpsProxyRequest) error {
if _, err := fsutil.BackupVersioned(path, 10); err != nil { if _, err := fsutil.BackupVersioned(path, 10); err != nil {
return err return err
} }
content := renderHTTPSProxy(req.Port, certPath, keyPath) content := renderHTTPSProxy(port, httpsProxyCertPath, httpsProxyKeyPath)
if err := atomicWrite(path, []byte(content), 0o640); err != nil { if err := atomicWrite(path, []byte(content), 0o640); err != nil {
return err return err
} }
@@ -107,11 +149,11 @@ func (s *Server) configureHTTPSProxy(req httpsProxyRequest) error {
if err != nil { if err != nil {
return err return err
} }
if err := s.verifyHTTPSProxy(req.Port, pin); err != nil { if err := s.verifyHTTPSProxy(port, pin); err != nil {
s.restoreHTTPSConfig(path, previous, readErr) s.restoreHTTPSConfig(path, previous, readErr)
return err return err
} }
return writeHTTPSProxyState(s.cfg.DataDir, httpsProxyState{Port: req.Port}) return writeHTTPSProxyState(s.cfg.DataDir, httpsProxyState{Port: port})
} }
func (s *Server) removeHTTPSProxy() error { func (s *Server) removeHTTPSProxy() error {
+91 -3
View File
@@ -4,18 +4,21 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"io" "io"
"log/slog"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/fonu/fonu/internal/auth"
"github.com/fonu/fonu/internal/frp" "github.com/fonu/fonu/internal/frp"
) )
// FRPMultiHandler 承载多服务端 FRP 模块的 HTTP 接口(/api/frpmulti/*), // FRPMultiHandler 承载多服务端 FRP 模块的 HTTP 接口(/api/frpmulti/*),
// 包装 internal/frp 的 Service(0.1.25 核心迁移版)。 // 包装 internal/frp 的 Service(0.1.25 核心迁移版)。
type FRPMultiHandler struct { type FRPMultiHandler struct {
svc *frp.Service svc *frp.Service
auth *auth.Service
} }
type frpServerRequest struct { type frpServerRequest struct {
@@ -68,8 +71,8 @@ type frpDownloadProxyRequest struct {
Proxy string `json:"proxy"` Proxy string `json:"proxy"`
} }
func NewFRPMultiHandler(svc *frp.Service) *FRPMultiHandler { func NewFRPMultiHandler(svc *frp.Service, authSvc *auth.Service) *FRPMultiHandler {
return &FRPMultiHandler{svc: svc} return &FRPMultiHandler{svc: svc, auth: authSvc}
} }
func (h *FRPMultiHandler) Status(w http.ResponseWriter, r *http.Request) { 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()) writeError(r, w, http.StatusBadGateway, err.Error())
return return
} }
h.audit(r, id, "transport", "切换为 "+server.AgentTransport)
writeJSON(w, http.StatusOK, server) 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) { func (h *FRPMultiHandler) AgentStatus(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id")) id, err := parseID(r.PathValue("id"))
if err != nil { if err != nil {
+5 -1
View File
@@ -26,7 +26,7 @@ func NewRouter(deps Deps) http.Handler {
statusPageHandler: NewStatusPageHandler(deps.Settings, deps.FRPMulti), statusPageHandler: NewStatusPageHandler(deps.Settings, deps.FRPMulti),
metricsHandler: NewMetricsHandler(deps.Metrics), metricsHandler: NewMetricsHandler(deps.Metrics),
discoveryHandler: NewDiscoveryHandler(deps.Discovery), discoveryHandler: NewDiscoveryHandler(deps.Discovery),
frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti), frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti, deps.Auth),
updateHandler: NewUpdateHandler(deps.Updater), updateHandler: NewUpdateHandler(deps.Updater),
authSvc: deps.Auth, authSvc: deps.Auth,
staticFS: deps.StaticFS, 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/test", frpHandler.AgentTest)
protect("POST /api/frpmulti/servers/{id}/agent/tunnel/restart", frpHandler.AgentTunnelRestart) protect("POST /api/frpmulti/servers/{id}/agent/tunnel/restart", frpHandler.AgentTunnelRestart)
protect("POST /api/frpmulti/servers/{id}/agent/transport", frpHandler.SetAgentTransport) protect("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/status", frpHandler.AgentStatus)
protect("GET /api/frpmulti/servers/{id}/agent/routes", frpHandler.AgentRoutes) protect("GET /api/frpmulti/servers/{id}/agent/routes", frpHandler.AgentRoutes)
protect("GET /api/frpmulti/servers/{id}/agent/routes/health", frpHandler.AgentRouteHealth) protect("GET /api/frpmulti/servers/{id}/agent/routes/health", frpHandler.AgentRouteHealth)
+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()) ctx, cancel := context.WithCancel(context.Background())
frpMultiSvc.SetBaseContext(ctx) frpMultiSvc.SetBaseContext(ctx)
go frpMultiSvc.StartAgentTunnels(ctx) go frpMultiSvc.StartAgentTunnels(ctx)
go frpMultiSvc.StartAgentCertificateRenewal(ctx)
intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval) intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
if intervalMinutes <= 0 { if intervalMinutes <= 0 {
intervalMinutes = 5 intervalMinutes = 5
+81 -8
View File
@@ -12,6 +12,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
@@ -35,21 +36,47 @@ func NewAgentClientWithPin(baseURL, token, pin string) *AgentClient {
return newAgentClient(baseURL, token, pin) 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{ client := &AgentClient{
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
token: token, token: token,
pin: strings.ToLower(strings.TrimSpace(pin)), pin: strings.Join(normalized, ","),
// 路由写入链路含 nginx -T 自省 + -t 校验 + reload(宝塔带 lua/waf 模块时各步可达数秒),15s 会超时 // 路由写入链路含 nginx -T 自省 + -t 校验 + reload(宝塔带 lua/waf 模块时各步可达数秒),15s 会超时
http: &http.Client{Timeout: 60 * time.Second}, http: &http.Client{Timeout: 60 * time.Second},
} }
if client.pin != "" { if len(normalized) > 0 {
client.http.Transport = &http.Transport{TLSClientConfig: pinTLSConfig(client.pin)} client.http.Transport = &http.Transport{TLSClientConfig: pinTLSConfig(hostFromBaseURL(client.baseURL), normalized...)}
} }
return client 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{ return &tls.Config{
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
InsecureSkipVerify: true, InsecureSkipVerify: true,
@@ -65,16 +92,41 @@ func pinTLSConfig(pin string) *tls.Config {
if now.Before(cert.NotBefore) || now.After(cert.NotAfter) { if now.Before(cert.NotBefore) || now.After(cert.NotAfter) {
return fmt.Errorf("Agent HTTPS 证书不在有效期内") return fmt.Errorf("Agent HTTPS 证书不在有效期内")
} }
if err := verifyCertificateHost(cert, host); err != nil {
return err
}
sum := sha256.Sum256(rawCerts[0]) sum := sha256.Sum256(rawCerts[0])
got := hex.EncodeToString(sum[:]) got := hex.EncodeToString(sum[:])
if subtle.ConstantTimeCompare([]byte(got), []byte(pin)) != 1 { for _, pin := range pins {
return fmt.Errorf("Agent HTTPS 证书指纹不匹配") 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 { func (c *AgentClient) request(ctx context.Context, method, path string, payload any, result any) error {
if c.baseURL == "" || c.token == "" { if c.baseURL == "" || c.token == "" {
return fmt.Errorf("公网 agent 未配置") 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) 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) { func (c *AgentClient) Status(ctx context.Context) (map[string]any, error) {
var result map[string]any var result map[string]any
err := c.request(ctx, http.MethodGet, "/api/v1/status", nil, &result) err := c.request(ctx, http.MethodGet, "/api/v1/status", nil, &result)
return result, err 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 版本 // agentMinVersionInsights 是 P1 三个只读端点(metrics / logs / frps-config)要求的最低 agent 版本
const agentMinVersionInsights = "0.11.0" const agentMinVersionInsights = "0.11.0"
+8 -6
View File
@@ -16,9 +16,10 @@ import (
) )
type agentCertificate struct { type agentCertificate struct {
CertPEM string CertPEM string
KeyPEM string KeyPEM string
Pin string Pin string
NotAfter time.Time
} }
func generateAgentCertificate(host string) (agentCertificate, error) { func generateAgentCertificate(host string) (agentCertificate, error) {
@@ -55,9 +56,10 @@ func generateAgentCertificate(host string) (agentCertificate, error) {
} }
sum := sha256.Sum256(der) sum := sha256.Sum256(der)
return agentCertificate{ return agentCertificate{
CertPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), CertPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})),
KeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})), KeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})),
Pin: hex.EncodeToString(sum[:]), Pin: hex.EncodeToString(sum[:]),
NotAfter: template.NotAfter,
}, nil }, nil
} }
+66 -2
View File
@@ -4,7 +4,9 @@ import (
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"net" "net"
"strings"
"testing" "testing"
"time"
) )
func TestGenerateAgentCertificate(t *testing.T) { func TestGenerateAgentCertificate(t *testing.T) {
@@ -37,16 +39,46 @@ func TestPinTLSConfigVerification(t *testing.T) {
if block == nil { if block == nil {
t.Fatal("证书 PEM 解码失败") 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 { if err := verify([][]byte{block.Bytes}, nil); err != nil {
t.Fatalf("正确 pin 校验失败: %v", err) 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 { if err := wrong([][]byte{block.Bytes}, nil); err == nil {
t.Fatal("错误 pin 应校验失败") 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) { func TestAgentHTTPSBaseURL(t *testing.T) {
cases := []struct { cases := []struct {
name string 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 agentRemoteAddr = "127.0.0.1:7700"
const (
agentTunnelKeepalive = 30 * time.Second
agentTunnelBackoffMin = time.Second
agentTunnelBackoffMax = 60 * time.Second
)
type agentTunnel struct { type agentTunnel struct {
serverID int64 serverID int64
localPort int localPort int
@@ -59,11 +65,55 @@ func (t *agentTunnel) Start() error {
t.state = "connected" t.state = "connected"
t.lastError = "" t.lastError = ""
t.mu.Unlock() t.mu.Unlock()
t.wg.Add(1) t.wg.Add(2)
go t.acceptLoop() go t.acceptLoop()
go t.monitorLoop()
return nil 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() { func (t *agentTunnel) acceptLoop() {
defer t.wg.Done() defer t.wg.Done()
for { for {
+20 -2
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"net" "net"
"testing" "testing"
"time"
) )
func TestAgentTunnelBindsLoopbackAndReleasesPort(t *testing.T) { func TestAgentTunnelBindsLoopbackAndReleasesPort(t *testing.T) {
@@ -23,8 +24,8 @@ func TestAgentTunnelBindsLoopbackAndReleasesPort(t *testing.T) {
t.Fatal("隧道启动后本地端口应已被占用") t.Fatal("隧道启动后本地端口应已被占用")
} }
state, _ := tunnel.State() state, _ := tunnel.State()
if state != "connected" { if state != "connected" && state != "reconnecting" {
t.Fatalf("state=%q, want connected", state) t.Fatalf("state=%q, want connected 或 reconnecting", state)
} }
tunnel.Stop() tunnel.Stop()
if listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)); err != nil { 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() _ = 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.
+42 -38
View File
@@ -84,44 +84,48 @@ type DownloadTask struct {
} }
type Server struct { type Server struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
AgentURL string `json:"agent_url,omitempty"` AgentURL string `json:"agent_url,omitempty"`
AgentTransport string `json:"agent_transport"` AgentTransport string `json:"agent_transport"`
AgentLocalPort int `json:"agent_local_port,omitempty"` AgentLocalPort int `json:"agent_local_port,omitempty"`
AgentTLSPin string `json:"agent_tls_pin,omitempty"` AgentTLSPin string `json:"agent_tls_pin,omitempty"`
AgentListenAddr string `json:"agent_listen_addr,omitempty"` AgentTLSPinPrev string `json:"agent_tls_pin_prev,omitempty"`
AgentTransportState string `json:"agent_transport_state"` AgentTLSPinPrevUntil string `json:"agent_tls_pin_prev_until,omitempty"`
AgentTransportError string `json:"agent_transport_error,omitempty"` AgentTLSNotAfter string `json:"agent_tls_not_after,omitempty"`
AgentConfigured bool `json:"agent_configured"` AgentFirewallState string `json:"agent_firewall_state,omitempty"`
SSHConfigured bool `json:"ssh_configured"` AgentListenAddr string `json:"agent_listen_addr,omitempty"`
MgmtEnabled bool `json:"mgmt_enabled"` AgentTransportState string `json:"agent_transport_state"`
SSHHost string `json:"ssh_host,omitempty"` AgentTransportError string `json:"agent_transport_error,omitempty"`
SSHPort int `json:"ssh_port,omitempty"` AgentConfigured bool `json:"agent_configured"`
SSHUser string `json:"ssh_user,omitempty"` SSHConfigured bool `json:"ssh_configured"`
SSHAuth string `json:"ssh_auth,omitempty"` MgmtEnabled bool `json:"mgmt_enabled"`
ServerAddr string `json:"server_addr"` SSHHost string `json:"ssh_host,omitempty"`
ServerPort int `json:"server_port"` SSHPort int `json:"ssh_port,omitempty"`
TLS bool `json:"tls_enabled"` SSHUser string `json:"ssh_user,omitempty"`
TLSServerName string `json:"tls_server_name,omitempty"` SSHAuth string `json:"ssh_auth,omitempty"`
Enabled bool `json:"enabled"` ServerAddr string `json:"server_addr"`
HasToken bool `json:"has_token"` ServerPort int `json:"server_port"`
HasOIDCSecret bool `json:"has_oidc_client_secret"` TLS bool `json:"tls_enabled"`
DashboardHasPwd bool `json:"dashboard_has_pwd"` TLSServerName string `json:"tls_server_name,omitempty"`
Options ServerOptions `json:"options"` Enabled bool `json:"enabled"`
BootStatus string `json:"boot_status"` HasToken bool `json:"has_token"`
DesiredState string `json:"desired_state"` HasOIDCSecret bool `json:"has_oidc_client_secret"`
ProcessState string `json:"process_state"` DashboardHasPwd bool `json:"dashboard_has_pwd"`
ConnectionState string `json:"connection_state"` Options ServerOptions `json:"options"`
StateSource string `json:"state_source"` BootStatus string `json:"boot_status"`
ProcessPID int `json:"process_pid,omitempty"` DesiredState string `json:"desired_state"`
ExitCode int `json:"exit_code,omitempty"` ProcessState string `json:"process_state"`
LastConnectedAt string `json:"last_connected_at,omitempty"` ConnectionState string `json:"connection_state"`
LastDisconnectedAt string `json:"last_disconnected_at,omitempty"` StateSource string `json:"state_source"`
LastError string `json:"last_error,omitempty"` ProcessPID int `json:"process_pid,omitempty"`
LastStartedAt string `json:"last_started_at,omitempty"` ExitCode int `json:"exit_code,omitempty"`
CreatedAt time.Time `json:"created_at"` LastConnectedAt string `json:"last_connected_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"` 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 { type ServerRuntimeStatus struct {
+243 -20
View File
@@ -941,6 +941,83 @@ func (s *Service) RestartAgentTunnel(ctx context.Context, serverID int64) (Serve
return server, nil 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) { func (s *Service) agentToken(ctx context.Context, serverID int64) (string, error) {
encoded, err := s.store.AgentToken(ctx, serverID) encoded, err := s.store.AgentToken(ctx, serverID)
if err != nil || encoded == "" { 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) { func (s *Service) EnableAgentHTTPS(ctx context.Context, serverID int64, port int) (Server, error) {
if port == 0 { if port < 0 || port > 65535 {
port = 7443
}
if port <= 0 || port > 65535 {
return Server{}, fmt.Errorf("HTTPS 端口无效") return Server{}, fmt.Errorf("HTTPS 端口无效")
} }
server, err := s.store.GetServer(ctx, serverID) 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 { if err != nil {
return Server{}, err 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) pinned := NewAgentClientWithPin(baseURL, token, cert.Pin)
if err := pinned.Ping(ctx); err != nil { if err := pinned.Ping(ctx); err != nil {
_ = currentClient.DisableHTTPS(ctx) _ = currentClient.DisableHTTPS(ctx)
if result["self_check"] == "ok" { 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) 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 return Server{}, err
} }
s.invalidateAgentClient(serverID) s.invalidateAgentClient(serverID)
@@ -1004,6 +1085,128 @@ func (s *Service) EnableAgentHTTPS(ctx context.Context, serverID int64, port int
return updated, nil 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) { func (s *Service) SwitchAgentToTunnel(ctx context.Context, serverID int64) (Server, error) {
server, err := s.store.GetServer(ctx, serverID) server, err := s.store.GetServer(ctx, serverID)
if err != nil { if err != nil {
@@ -1609,15 +1812,35 @@ func agentHTTPSBaseURL(server Server) string {
if host == "" { if host == "" {
return current return current
} }
port := 7443 port := listenPort(server.AgentListenAddr, 7443)
if _, rawPort, err := net.SplitHostPort(strings.TrimSpace(server.AgentListenAddr)); err == nil {
if parsed, err := strconv.Atoi(rawPort); err == nil && parsed > 0 {
port = parsed
}
}
return fmt.Sprintf("https://%s:%d", host, port) 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) { func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient, bool) {
server, err := s.store.GetServer(ctx, serverID) server, err := s.store.GetServer(ctx, serverID)
if err != nil || !server.MgmtEnabled || !server.AgentConfigured { if err != nil || !server.MgmtEnabled || !server.AgentConfigured {
@@ -1632,7 +1855,7 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient
return nil, false return nil, false
} }
baseURL := server.AgentURL baseURL := server.AgentURL
pin := "" var pins []string
if server.AgentTransport == "tunnel" { if server.AgentTransport == "tunnel" {
tunnel, err := s.ensureAgentTunnel(ctx, serverID) tunnel, err := s.ensureAgentTunnel(ctx, serverID)
if err != nil { if err != nil {
@@ -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) baseURL = fmt.Sprintf("http://127.0.0.1:%d", tunnel.localPort)
} else if server.AgentTransport == "https-pin" { } else if server.AgentTransport == "https-pin" {
pin = strings.TrimSpace(server.AgentTLSPin) pins = agentTransportPins(server)
if pin == "" { if len(pins) == 0 {
return nil, false return nil, false
} }
baseURL = agentHTTPSBaseURL(server) 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), "/") { if baseURL != strings.TrimRight(strings.TrimSpace(server.AgentURL), "/") {
err := s.store.SetAgentTransport(ctx, serverID, baseURL, "https-pin", server.AgentLocalPort, 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 { if err != nil {
s.logger.Warn("agent https url repair failed", "server_id", serverID, "error", err) 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 return nil, false
} }
// 复用同一个 http.Client:批量同步 N 条路由时不再每次新建 Transport 与 TLS 握手 // 复用同一个 http.Client:批量同步 N 条路由时不再每次新建 Transport 与 TLS 握手
fingerprint := baseURL + "|" + encoded + "|" + pin fingerprint := baseURL + "|" + encoded + "|" + strings.Join(pins, ",")
s.agentMu.Lock() s.agentMu.Lock()
defer s.agentMu.Unlock() defer s.agentMu.Unlock()
if s.agentClients == nil { if s.agentClients == nil {
@@ -1669,8 +1892,8 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient
return cached.client, true return cached.client, true
} }
var client *AgentClient var client *AgentClient
if pin != "" { if len(pins) > 0 {
client = NewAgentClientWithPin(baseURL, token, pin) client = NewAgentClientWithPins(baseURL, token, pins...)
} else { } else {
client = NewAgentClient(baseURL, token) 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 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) { func (s *Store) Token(ctx context.Context, id int64) (string, error) {
var token string var token string
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(auth_token_enc, '') FROM frp_servers WHERE id = ?`, id).Scan(&token) 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'), 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), 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_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(agent_listen_addr, ''), COALESCE(agent_transport_state, 'unknown'), COALESCE(agent_transport_error, ''),
COALESCE(config_json, '{}'), created_at, updated_at COALESCE(config_json, '{}'), created_at, updated_at
FROM frp_servers` FROM frp_servers`
@@ -459,7 +509,7 @@ func scanServer(row interface{ Scan(dest ...any) error }) (Server, error) {
var server Server var server Server
var tls, enabled, token, oidcSecret, dashboardPwd, agentConfigured, sshConfigured, mgmtEnabled int var tls, enabled, token, oidcSecret, dashboardPwd, agentConfigured, sshConfigured, mgmtEnabled int
var configJSON, created, updated string 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 { if err != nil {
return Server{}, err 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", "name": "fonu-web",
"version": "1.4.6", "version": "1.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "fonu-web", "name": "fonu-web",
"version": "1.4.6", "version": "1.5.0",
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.11.1", "@codemirror/commands": "^6.11.1",
"@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-javascript": "^6.2.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "fonu-web", "name": "fonu-web",
"private": true, "private": true,
"version": "1.4.6", "version": "1.5.0",
"packageManager": "npm@10.9.4", "packageManager": "npm@10.9.4",
"type": "module", "type": "module",
"scripts": { "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' }), testFrpAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/test`, { method: 'POST' }),
restartFrpAgentTunnel: (id: number) => restartFrpAgentTunnel: (id: number) =>
request<FrpServer>(`/api/frpmulti/servers/${id}/agent/tunnel/restart`, { method: 'POST' }), request<FrpServer>(`/api/frpmulti/servers/${id}/agent/tunnel/restart`, { method: 'POST' }),
setFrpAgentTransport: (id: number, transport: 'tunnel' | 'https-pin', port = 7443) => setFrpAgentTransport: (id: number, transport: 'tunnel' | 'https-pin', port = 0) =>
request<FrpServer>(`/api/frpmulti/servers/${id}/agent/transport`, { request<FrpServer>(`/api/frpmulti/servers/${id}/agent/transport`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ transport, port }), 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' }), probeFrpAgent: (id: number) => request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/probe`, { method: 'POST' }),
installFrpAgent: (id: number) => request<FrpServer>(`/api/frpmulti/servers/${id}/agent/install`, { method: 'POST' }), installFrpAgent: (id: number) => request<FrpServer>(`/api/frpmulti/servers/${id}/agent/install`, { method: 'POST' }),
uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { method: 'POST' }), uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { method: 'POST' }),
+4
View File
@@ -399,6 +399,10 @@ export interface FrpServer {
agent_transport: 'tunnel' | 'https-pin' | 'http' | string agent_transport: 'tunnel' | 'https-pin' | 'http' | string
agent_local_port?: number agent_local_port?: number
agent_tls_pin?: string 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_listen_addr?: string
agent_transport_state: string agent_transport_state: string
agent_transport_error?: 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>{{ 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>本地入口</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><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> </div>
<n-alert v-if="server.agent_transport === 'http'" type="warning" :bordered="false" class="agent-transport-warning"> <n-alert v-if="server.agent_transport === 'http'" type="warning" :bordered="false" class="agent-transport-warning">
当前仍是公网明文 HTTP。点击「升级 Agent」可自动迁移到 SSH 隧道安全模式。 当前仍是公网明文 HTTP。点击「升级 Agent」可自动迁移到 SSH 隧道安全模式。
</n-alert> </n-alert>
<n-alert v-if="server.agent_transport === 'https-pin'" type="info" :bordered="false" class="agent-transport-warning"> <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> </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> <p v-if="server.agent_transport_error" class="form-hint agent-transport-error">{{ server.agent_transport_error }}</p>
<div class="agent-actions"> <div class="agent-actions">
<n-button size="small" :loading="busy === 'agent-install'" :disabled="busy !== null && busy !== 'agent-install'" @click="installAgent(server)">{{ server.agent_configured ? '升级 Agent' : '安装 Agent' }}</n-button> <n-button size="small" :loading="busy === 'agent-install'" :disabled="busy !== null && busy !== 'agent-install'" @click="installAgent(server)">{{ server.agent_configured ? '升级 Agent' : '安装 Agent' }}</n-button>
@@ -264,7 +267,15 @@
<template #trigger> <template #trigger>
<n-button size="small" :loading="busy === 'transport-https'" :disabled="busy !== null && busy !== 'transport-https'">切换 HTTPS 直连</n-button> <n-button size="small" :loading="busy === 'transport-https'" :disabled="busy !== null && busy !== 'transport-https'">切换 HTTPS 直连</n-button>
</template> </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>
<n-popconfirm v-if="server.agent_transport === 'https-pin'" @positive-click="switchAgentToTunnel(server)"> <n-popconfirm v-if="server.agent_transport === 'https-pin'" @positive-click="switchAgentToTunnel(server)">
<template #trigger> <template #trigger>
@@ -666,7 +677,7 @@
</template> </template>
<script setup lang="ts"> <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 { useRoute } from 'vue-router'
import type { DataTableColumns } from 'naive-ui' import type { DataTableColumns } from 'naive-ui'
import { NButton, NIcon, NTag, useDialog, useMessage } 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 { formatBytes, formatDate, formatRelativeTime, formatUptime } from '../utils/format'
import type { StatusKind } from '../utils/status' import type { StatusKind } from '../utils/status'
import { copyText } from '../utils/clipboard' import { copyText } from '../utils/clipboard'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
use([CanvasRenderer, LineChart, ScatterChart, GridComponent, TooltipComponent]) use([CanvasRenderer, LineChart, ScatterChart, GridComponent, TooltipComponent])
@@ -701,6 +713,7 @@ use([CanvasRenderer, LineChart, ScatterChart, GridComponent, TooltipComponent])
type Action = type Action =
| 'probe' | 'agent-install' | 'agent-uninstall' | 'diagnose' | 'probe' | 'agent-install' | 'agent-uninstall' | 'diagnose'
| 'tunnel-restart' | 'transport-https' | 'transport-tunnel' | 'tunnel-restart' | 'transport-https' | 'transport-tunnel'
| 'tls-rotate' | 'firewall-check' | 'firewall-allow'
| 'frps-start' | 'frps-stop' | 'frps-restart' | 'frps-install' | 'push-config' | 'frps-start' | 'frps-stop' | 'frps-restart' | 'frps-install' | 'push-config'
interface OpOutput { at: string; source: string; text: string; ok: boolean } interface OpOutput { at: string; source: string; text: string; ok: boolean }
@@ -722,6 +735,7 @@ const agentStatusOk = ref(false)
const statusLoaded = ref(false) const statusLoaded = ref(false)
const statusLoading = ref(false) const statusLoading = ref(false)
const busy = ref<Action | null>(null) const busy = ref<Action | null>(null)
const firewallInfo = ref<{ serverId: number; data: Record<string, any> } | null>(null)
const opOutput = ref<OpOutput[]>([]) const opOutput = ref<OpOutput[]>([])
const frpsInstallVersion = ref<string | null>(null) const frpsInstallVersion = ref<string | null>(null)
const releases = ref<FrpRelease[]>([]) const releases = ref<FrpRelease[]>([])
@@ -729,7 +743,6 @@ const releasesLoading = ref(false)
const configModal = ref(false) const configModal = ref(false)
const configContent = ref('') const configContent = ref('')
const configLoading = ref(false) const configLoading = ref(false)
let timer: number | undefined
const watchedPorts = ['80', '443', '7000', '8080'] as const const watchedPorts = ['80', '443', '7000', '8080'] as const
@@ -1430,11 +1443,52 @@ function restartAgentTunnel(target: FrpServer) {
} }
function enableAgentHTTPS(target: FrpServer) { function enableAgentHTTPS(target: FrpServer) {
return run('transport-https', '切换 HTTPS 直连', () => api.setFrpAgentTransport(target.id, 'https-pin', 7443), return run('transport-https', '切换 HTTPS 直连', () => api.setFrpAgentTransport(target.id, 'https-pin', 0),
() => ({ text: 'HTTPS 直连已启用并通过证书指纹验证', ok: true })) () => ({ text: 'HTTPS 直连已启用并通过证书指纹验证(已自动选择端口)', ok: true }))
.then(async (result) => { if (result !== undefined) await load() }) .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) { function switchAgentToTunnel(target: FrpServer) {
return run('transport-tunnel', '切回 SSH 隧道', () => api.setFrpAgentTransport(target.id, 'tunnel'), return run('transport-tunnel', '切回 SSH 隧道', () => api.setFrpAgentTransport(target.id, 'tunnel'),
() => ({ text: '已关闭 HTTPS 直连并恢复 SSH 隧道', ok: true })) () => ({ text: '已关闭 HTTPS 直连并恢复 SSH 隧道', ok: true }))
@@ -1453,11 +1507,11 @@ function agentTransportKind(server: FrpServer): StatusKind {
} }
function agentTransportStateText(server: FrpServer) { 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 === 'connected') return '已连接'
if (server.agent_transport_state === 'reconnecting') return '重连中' if (server.agent_transport_state === 'reconnecting') return '重连中'
if (server.agent_transport_state === 'failed') return '连接失败' if (server.agent_transport_state === 'failed') return '连接失败'
return '未建立' return server.agent_transport === 'https-pin' ? '待验证' : '未建立'
} }
function processStateText(server: FrpServer) { function processStateText(server: FrpServer) {
@@ -1610,14 +1664,12 @@ async function loadTLSFile(target: 'certificate' | 'key' | 'ca', event: Event) {
onMounted(() => { onMounted(() => {
void load() void load()
void loadReleases() 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> </script>
<style scoped> <style scoped>
+8 -8
View File
@@ -8,7 +8,7 @@
</template> </template>
</PageHeader> </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>。 当前页面由 v{{ version }} 提供,验证标识为 <span class="mono">{{ verificationMarker }}</span>。
如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。 如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。
</n-alert> </n-alert>
@@ -19,7 +19,7 @@
<strong class="example-proof__value">{{ verificationRound }}</strong> <strong class="example-proof__value">{{ verificationRound }}</strong>
</div> </div>
<div class="example-proof__hint"> <div class="example-proof__hint">
看到这个新区块,说明前端资源已经从旧版本切换到 v1.4.6。 看到这个新区块,说明前端资源已经从旧版本切换到 v1.5.0。
</div> </div>
</div> </div>
@@ -33,16 +33,16 @@
<StatCard label="本次验证标识" :value="verificationMarker" sub="用于区分升级前后的前端资源" tone="blue" value-small> <StatCard label="本次验证标识" :value="verificationMarker" sub="用于区分升级前后的前端资源" tone="blue" value-small>
<template #icon><n-icon :component="FlashOutline" /></template> <template #icon><n-icon :component="FlashOutline" /></template>
</StatCard> </StatCard>
<StatCard label="页面状态" value="已更新" sub="v1.4.6 前端资源已加载" tone="teal"> <StatCard label="页面状态" value="已更新" sub="v1.5.0 前端资源已加载" tone="teal">
<template #icon><n-icon :component="CheckmarkCircleOutline" /></template> <template #icon><n-icon :component="CheckmarkCircleOutline" /></template>
</StatCard> </StatCard>
</div> </div>
<FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化"> <FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化">
<ul class="example-changes"> <ul class="example-changes">
<li>页面版本提示更新为 v1.4.6。</li> <li>页面版本提示更新为 v1.5.0。</li>
<li>「本次升级验证」区块和页面状态同步更新为 v1.4.6。</li> <li>「本次升级验证」区块和页面状态同步更新为 v1.5.0。</li>
<li>验证标识更新为 <span class="mono">v1.4.6-example-page</span>。</li> <li>验证标识更新为 <span class="mono">v1.5.0-example-page</span>。</li>
</ul> </ul>
</FonuCard> </FonuCard>
@@ -87,8 +87,8 @@ const message = useMessage()
const version = ref(__APP_VERSION__) const version = ref(__APP_VERSION__)
const checkedAt = ref('') const checkedAt = ref('')
const refreshing = ref(false) const refreshing = ref(false)
const verificationRound = 'v1.4.6' const verificationRound = 'v1.5.0'
const verificationMarker = 'v1.4.6-example-page' const verificationMarker = 'v1.5.0-example-page'
async function refreshVersion() { async function refreshVersion() {
refreshing.value = true refreshing.value = true