feat(agent): 完成 HTTPS 自动端口、证书轮换与防火墙辅助并发布 v1.5.0
This commit is contained in:
@@ -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
@@ -1,7 +1,7 @@
|
||||
# Fonu 文档索引
|
||||
|
||||
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
|
||||
> 最近更新:2026-09-28(v1.4.6)
|
||||
> 最近更新:2026-09-28(v1.5.0)
|
||||
|
||||
## 方案与设计(本目录)
|
||||
|
||||
|
||||
@@ -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 必须提示:
|
||||
|
||||
|
||||
@@ -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`;
|
||||
- 页面显示独立的「本次升级验证」区块;
|
||||
- 「升级验证」显示为「通过」。
|
||||
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 '';
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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' }),
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user