feat(agent): 完成 HTTPS 自动端口、证书轮换与防火墙辅助并发布 v1.5.0
CI / verify (push) Canceled after 0s

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
+127
View File
@@ -0,0 +1,127 @@
package agent
import (
"encoding/json"
"errors"
"net/http"
"os/exec"
"strconv"
"strings"
)
type firewallStatus struct {
Tool string `json:"tool"`
Active bool `json:"active"`
PortOpen bool `json:"port_open"`
Message string `json:"message,omitempty"`
}
// firewallStatus 只检测主机防火墙,不修改规则;云安全组无法从 VPS 内部感知。
func (s *Server) firewallStatus(w http.ResponseWriter, r *http.Request) {
port := 7443
if raw := strings.TrimSpace(r.URL.Query().Get("port")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 65535 {
port = parsed
}
}
writeJSON(w, http.StatusOK, detectFirewall(port))
}
// firewallAllow 仅在用户显式确认后调用;只自动处理 ufw 与 firewalld。
func (s *Server) firewallAllow(w http.ResponseWriter, r *http.Request) {
var req struct {
Port int `json:"port"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if req.Port <= 0 || req.Port > 65535 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "端口无效"})
return
}
output, err := allowFirewallPort(req.Port)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "output": output, "status": detectFirewall(req.Port)})
}
func detectFirewall(port int) firewallStatus {
if path, err := exec.LookPath("ufw"); err == nil {
if out, err := exec.Command(path, "status").Output(); err == nil {
active, open := parseUFWStatus(string(out), port)
if active {
return firewallStatus{Tool: "ufw", Active: true, PortOpen: open}
}
}
}
if path, err := exec.LookPath("firewall-cmd"); err == nil {
if out, err := exec.Command(path, "--state").Output(); err == nil && strings.TrimSpace(string(out)) == "running" {
return firewallStatus{Tool: "firewalld", Active: true, PortOpen: firewalldPortOpen(path, port)}
}
}
if _, err := exec.LookPath("nft"); err == nil {
return firewallStatus{Tool: "nftables", Active: true, Message: "nftables 需要手工放行,Agent 不会自动修改规则"}
}
if _, err := exec.LookPath("iptables"); err == nil {
return firewallStatus{Tool: "iptables", Active: true, Message: "iptables 需要手工放行,Agent 不会自动修改规则"}
}
return firewallStatus{Tool: "none", Message: "未检测到主机防火墙"}
}
// parseUFWStatus 纯解析 ufw status 输出:是否启用、目标端口是否已放行。
func parseUFWStatus(output string, port int) (bool, bool) {
active := false
open := false
needle := strconv.Itoa(port)
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Status:") {
active = strings.EqualFold(strings.TrimSpace(strings.TrimPrefix(line, "Status:")), "active")
continue
}
if !strings.Contains(line, needle) || !strings.Contains(line, "ALLOW") {
continue
}
fields := strings.Fields(line)
if len(fields) > 0 && (fields[0] == needle || strings.HasPrefix(fields[0], needle+"/")) {
open = true
}
}
return active, open
}
func firewalldPortOpen(path string, port int) bool {
out, err := exec.Command(path, "--list-ports").Output()
if err != nil {
return false
}
for _, item := range strings.Fields(string(out)) {
if strings.HasPrefix(item, strconv.Itoa(port)+"/") {
return true
}
}
return false
}
func allowFirewallPort(port int) (string, error) {
status := detectFirewall(port)
switch status.Tool {
case "ufw":
out, err := exec.Command("ufw", "allow", strconv.Itoa(port)+"/tcp").CombinedOutput()
return strings.TrimSpace(string(out)), err
case "firewalld":
first, err := exec.Command("firewall-cmd", "--permanent", "--add-port="+strconv.Itoa(port)+"/tcp").CombinedOutput()
if err != nil {
return strings.TrimSpace(string(first)), err
}
second, err := exec.Command("firewall-cmd", "--reload").CombinedOutput()
return strings.TrimSpace(string(first) + "\n" + string(second)), err
case "nftables", "iptables":
return "", errors.New("当前防火墙需要手工放行,Agent 不会自动修改 nftables / iptables 规则")
default:
return "", errors.New("未检测到可自动放行的主机防火墙(仅支持 ufw / firewalld)")
}
}
+38
View File
@@ -0,0 +1,38 @@
package agent
import "testing"
func TestParseUFWStatus(t *testing.T) {
output := `Status: active
To Action From
-- ------ ----
22/tcp ALLOW Anywhere
7443/tcp ALLOW Anywhere
`
active, open := parseUFWStatus(output, 7443)
if !active || !open {
t.Fatalf("应识别出 ufw 已启用且 7443 已放行:active=%v open=%v", active, open)
}
_, open = parseUFWStatus(output, 9443)
if open {
t.Fatal("未放行的端口不应判定为已放行")
}
}
func TestParseUFWStatusInactive(t *testing.T) {
active, open := parseUFWStatus("Status: inactive\n", 7443)
if active || open {
t.Fatalf("inactive 时不应判定为启用:active=%v open=%v", active, open)
}
}
func TestCandidateHTTPSPorts(t *testing.T) {
if got := candidateHTTPSPorts(9443); len(got) != 1 || got[0] != 9443 {
t.Fatalf("显式端口应只返回该端口:%v", got)
}
got := candidateHTTPSPorts(0)
if len(got) != 3 || got[0] != 7443 || got[1] != 8443 || got[2] != 9443 {
t.Fatalf("自动模式应返回 7443/8443/9443:%v", got)
}
}
+5 -1
View File
@@ -58,7 +58,8 @@ import (
// 修掉「配置目录还没探测出来、迁移逻辑没机会执行」导致 HTTPS 直连持续失败的问题。
// 0.13.3:HTTPS 反代改用 default_server 并在 reload 后做本机证书指纹自检,
// 证书不匹配时回滚并回报实际指纹与端口占用进程,区分「nginx 未生效」与「公网链路被接管」。
const AgentVersion = "0.13.3"
// 0.14.0:HTTPS 端口自动选择(7443/8443/9443)、当前证书信息查询、主机防火墙检测与 ufw/firewalld 显式放行。
const AgentVersion = "0.14.0"
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
type Config struct {
@@ -227,7 +228,10 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("PUT /api/v1/china-cidr", s.auth(s.putChinaCIDR))
mux.HandleFunc("POST /api/v1/nginx/install", s.auth(s.installNginx))
mux.HandleFunc("PUT /api/v1/transport/https", s.auth(s.putHTTPSProxy))
mux.HandleFunc("GET /api/v1/transport/https", s.auth(s.getHTTPSProxy))
mux.HandleFunc("DELETE /api/v1/transport/https", s.auth(s.deleteHTTPSProxy))
mux.HandleFunc("GET /api/v1/firewall", s.auth(s.firewallStatus))
mux.HandleFunc("POST /api/v1/firewall/allow", s.auth(s.firewallAllow))
mux.HandleFunc("DELETE /api/v1/routes/{domain}", s.auth(s.deleteRoute))
mux.HandleFunc("PUT /api/v1/frps/config", s.auth(s.putFRPSConfig))
mux.HandleFunc("POST /api/v1/frps/install", s.auth(s.installFRPS))
+55 -13
View File
@@ -20,7 +20,11 @@ import (
"github.com/fonu/fonu/internal/fsutil"
)
const httpsProxyConfigName = "fonuagent-https.conf"
const (
httpsProxyConfigName = "fonuagent-https.conf"
httpsProxyCertPath = "/etc/fonu-agent/tls/agent.crt"
httpsProxyKeyPath = "/etc/fonu-agent/tls/agent.key"
)
type httpsProxyRequest struct {
CertPEM string `json:"cert_pem"`
@@ -38,8 +42,8 @@ func (s *Server) putHTTPSProxy(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if req.Port <= 0 || req.Port > 65535 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "端口需在 1-65535 之间"})
if req.Port < 0 || req.Port > 65535 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "端口需在 0-65535 之间,0 表示自动选择"})
return
}
if _, err := tls.X509KeyPair([]byte(req.CertPEM), []byte(req.KeyPEM)); err != nil {
@@ -52,11 +56,12 @@ func (s *Server) putHTTPSProxy(w http.ResponseWriter, r *http.Request) {
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.configureHTTPSProxy(req); err != nil {
port, err := s.configureHTTPSProxy(req)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "port": req.Port, "self_check": "ok"})
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "port": port, "self_check": "ok"})
}
func (s *Server) deleteHTTPSProxy(w http.ResponseWriter, _ *http.Request) {
@@ -69,7 +74,46 @@ func (s *Server) deleteHTTPSProxy(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) configureHTTPSProxy(req httpsProxyRequest) error {
// getHTTPSProxy 返回当前 HTTPS 反代状态与证书到期时间,供中央端同步轮换计划。
func (s *Server) getHTTPSProxy(w http.ResponseWriter, _ *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
state, enabled := readHTTPSProxyState(s.cfg.DataDir)
notAfter := ""
if t, err := readCertificateNotAfter(httpsProxyCertPath); err == nil {
notAfter = t.UTC().Format(time.RFC3339)
}
writeJSON(w, http.StatusOK, map[string]any{
"enabled": enabled,
"port": state.Port,
"not_after": notAfter,
})
}
// configureHTTPSProxy 按候选端口逐个尝试;请求端口为 0 时自动选择 7443 / 8443 / 9443。
func (s *Server) configureHTTPSProxy(req httpsProxyRequest) (int, error) {
var lastErr error
for _, port := range candidateHTTPSPorts(req.Port) {
if err := s.applyHTTPSProxy(req, port); err != nil {
lastErr = fmt.Errorf("端口 %d 不可用: %w", port, err)
continue
}
return port, nil
}
if lastErr == nil {
lastErr = errors.New("没有可用的 HTTPS 端口")
}
return 0, lastErr
}
func candidateHTTPSPorts(requested int) []int {
if requested > 0 {
return []int{requested}
}
return []int{7443, 8443, 9443}
}
func (s *Server) applyHTTPSProxy(req httpsProxyRequest, port int) error {
dir, err := s.ensureNginxDir()
if err != nil {
return err
@@ -78,12 +122,10 @@ func (s *Server) configureHTTPSProxy(req httpsProxyRequest) error {
if err := os.MkdirAll(tlsDir, 0o750); err != nil {
return err
}
certPath := filepath.Join(tlsDir, "agent.crt")
keyPath := filepath.Join(tlsDir, "agent.key")
if err := atomicWrite(certPath, []byte(req.CertPEM), 0o640); err != nil {
if err := atomicWrite(httpsProxyCertPath, []byte(req.CertPEM), 0o640); err != nil {
return err
}
if err := atomicWrite(keyPath, []byte(req.KeyPEM), 0o600); err != nil {
if err := atomicWrite(httpsProxyKeyPath, []byte(req.KeyPEM), 0o600); err != nil {
return err
}
path := filepath.Join(dir, httpsProxyConfigName)
@@ -91,7 +133,7 @@ func (s *Server) configureHTTPSProxy(req httpsProxyRequest) error {
if _, err := fsutil.BackupVersioned(path, 10); err != nil {
return err
}
content := renderHTTPSProxy(req.Port, certPath, keyPath)
content := renderHTTPSProxy(port, httpsProxyCertPath, httpsProxyKeyPath)
if err := atomicWrite(path, []byte(content), 0o640); err != nil {
return err
}
@@ -107,11 +149,11 @@ func (s *Server) configureHTTPSProxy(req httpsProxyRequest) error {
if err != nil {
return err
}
if err := s.verifyHTTPSProxy(req.Port, pin); err != nil {
if err := s.verifyHTTPSProxy(port, pin); err != nil {
s.restoreHTTPSConfig(path, previous, readErr)
return err
}
return writeHTTPSProxyState(s.cfg.DataDir, httpsProxyState{Port: req.Port})
return writeHTTPSProxyState(s.cfg.DataDir, httpsProxyState{Port: port})
}
func (s *Server) removeHTTPSProxy() error {
+91 -3
View File
@@ -4,18 +4,21 @@ import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/fonu/fonu/internal/auth"
"github.com/fonu/fonu/internal/frp"
)
// FRPMultiHandler 承载多服务端 FRP 模块的 HTTP 接口(/api/frpmulti/*),
// 包装 internal/frp 的 Service(0.1.25 核心迁移版)。
type FRPMultiHandler struct {
svc *frp.Service
svc *frp.Service
auth *auth.Service
}
type frpServerRequest struct {
@@ -68,8 +71,8 @@ type frpDownloadProxyRequest struct {
Proxy string `json:"proxy"`
}
func NewFRPMultiHandler(svc *frp.Service) *FRPMultiHandler {
return &FRPMultiHandler{svc: svc}
func NewFRPMultiHandler(svc *frp.Service, authSvc *auth.Service) *FRPMultiHandler {
return &FRPMultiHandler{svc: svc, auth: authSvc}
}
func (h *FRPMultiHandler) Status(w http.ResponseWriter, r *http.Request) {
@@ -366,9 +369,94 @@ func (h *FRPMultiHandler) SetAgentTransport(w http.ResponseWriter, r *http.Reque
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
h.audit(r, id, "transport", "切换为 "+server.AgentTransport)
writeJSON(w, http.StatusOK, server)
}
func (h *FRPMultiHandler) AgentTransportStatus(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
return
}
status, err := h.svc.AgentTransportStatus(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, status)
}
func (h *FRPMultiHandler) AgentTransportRotateCert(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
return
}
server, err := h.svc.RotateAgentCertificate(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
h.audit(r, id, "rotate-tls-cert", server.AgentTLSPin)
writeJSON(w, http.StatusOK, server)
}
func (h *FRPMultiHandler) AgentFirewallStatus(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
return
}
port, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("port")))
status, err := h.svc.AgentFirewallStatus(r.Context(), id, port)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, status)
}
func (h *FRPMultiHandler) AgentFirewallAllow(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
return
}
var request struct {
Port int `json:"port"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
result, err := h.svc.AllowAgentFirewallPort(r.Context(), id, request.Port)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
h.audit(r, id, "firewall-allow", strconv.Itoa(request.Port))
writeJSON(w, http.StatusOK, result)
}
// audit 记录 Agent 传输配置变更;admin_id 取自登录会话,API Token 调用没有会话时记为 0。
func (h *FRPMultiHandler) audit(r *http.Request, serverID int64, action, detail string) {
adminID := 0
if sessionID, ok := r.Context().Value(sessionContextKey).(string); ok && sessionID != "" && h.auth != nil {
if id, err := h.auth.AdminIDForSession(r.Context(), sessionID); err == nil {
adminID = id
}
}
slog.Default().Info("审计:Agent 配置变更",
"module", "AUDIT",
"action", action,
"server_id", serverID,
"admin_id", adminID,
"detail", detail,
"remote_addr", r.RemoteAddr,
)
}
func (h *FRPMultiHandler) AgentStatus(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
+5 -1
View File
@@ -26,7 +26,7 @@ func NewRouter(deps Deps) http.Handler {
statusPageHandler: NewStatusPageHandler(deps.Settings, deps.FRPMulti),
metricsHandler: NewMetricsHandler(deps.Metrics),
discoveryHandler: NewDiscoveryHandler(deps.Discovery),
frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti),
frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti, deps.Auth),
updateHandler: NewUpdateHandler(deps.Updater),
authSvc: deps.Auth,
staticFS: deps.StaticFS,
@@ -133,6 +133,10 @@ func NewRouter(deps Deps) http.Handler {
protect("POST /api/frpmulti/servers/{id}/agent/test", frpHandler.AgentTest)
protect("POST /api/frpmulti/servers/{id}/agent/tunnel/restart", frpHandler.AgentTunnelRestart)
protect("POST /api/frpmulti/servers/{id}/agent/transport", frpHandler.SetAgentTransport)
protect("GET /api/frpmulti/servers/{id}/agent/transport", frpHandler.AgentTransportStatus)
protect("POST /api/frpmulti/servers/{id}/agent/transport/rotate", frpHandler.AgentTransportRotateCert)
protect("GET /api/frpmulti/servers/{id}/agent/firewall", frpHandler.AgentFirewallStatus)
protect("POST /api/frpmulti/servers/{id}/agent/firewall/allow", frpHandler.AgentFirewallAllow)
protect("GET /api/frpmulti/servers/{id}/agent/status", frpHandler.AgentStatus)
protect("GET /api/frpmulti/servers/{id}/agent/routes", frpHandler.AgentRoutes)
protect("GET /api/frpmulti/servers/{id}/agent/routes/health", frpHandler.AgentRouteHealth)
+1
View File
@@ -147,6 +147,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
ctx, cancel := context.WithCancel(context.Background())
frpMultiSvc.SetBaseContext(ctx)
go frpMultiSvc.StartAgentTunnels(ctx)
go frpMultiSvc.StartAgentCertificateRenewal(ctx)
intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
if intervalMinutes <= 0 {
intervalMinutes = 5
+81 -8
View File
@@ -12,6 +12,7 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
@@ -35,21 +36,47 @@ func NewAgentClientWithPin(baseURL, token, pin string) *AgentClient {
return newAgentClient(baseURL, token, pin)
}
func newAgentClient(baseURL, token, pin string) *AgentClient {
// NewAgentClientWithPins 支持证书轮换过渡期同时接受旧 pin 与新 pin。
func NewAgentClientWithPins(baseURL, token string, pins ...string) *AgentClient {
return newAgentClient(baseURL, token, pins...)
}
func newAgentClient(baseURL, token string, pins ...string) *AgentClient {
normalized := normalizePins(pins)
client := &AgentClient{
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
token: token,
pin: strings.ToLower(strings.TrimSpace(pin)),
pin: strings.Join(normalized, ","),
// 路由写入链路含 nginx -T 自省 + -t 校验 + reload(宝塔带 lua/waf 模块时各步可达数秒),15s 会超时
http: &http.Client{Timeout: 60 * time.Second},
}
if client.pin != "" {
client.http.Transport = &http.Transport{TLSClientConfig: pinTLSConfig(client.pin)}
if len(normalized) > 0 {
client.http.Transport = &http.Transport{TLSClientConfig: pinTLSConfig(hostFromBaseURL(client.baseURL), normalized...)}
}
return client
}
func pinTLSConfig(pin string) *tls.Config {
func normalizePins(pins []string) []string {
out := make([]string, 0, len(pins))
for _, pin := range pins {
pin = strings.ToLower(strings.TrimSpace(pin))
if pin == "" {
continue
}
out = append(out, pin)
}
return out
}
func hostFromBaseURL(baseURL string) string {
parsed, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil {
return ""
}
return parsed.Hostname()
}
func pinTLSConfig(host string, pins ...string) *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: true,
@@ -65,16 +92,41 @@ func pinTLSConfig(pin string) *tls.Config {
if now.Before(cert.NotBefore) || now.After(cert.NotAfter) {
return fmt.Errorf("Agent HTTPS 证书不在有效期内")
}
if err := verifyCertificateHost(cert, host); err != nil {
return err
}
sum := sha256.Sum256(rawCerts[0])
got := hex.EncodeToString(sum[:])
if subtle.ConstantTimeCompare([]byte(got), []byte(pin)) != 1 {
return fmt.Errorf("Agent HTTPS 证书指纹不匹配")
for _, pin := range pins {
if subtle.ConstantTimeCompare([]byte(got), []byte(pin)) == 1 {
return nil
}
}
return 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"
+8 -6
View File
@@ -16,9 +16,10 @@ import (
)
type agentCertificate struct {
CertPEM string
KeyPEM string
Pin string
CertPEM string
KeyPEM string
Pin string
NotAfter time.Time
}
func generateAgentCertificate(host string) (agentCertificate, error) {
@@ -55,9 +56,10 @@ func generateAgentCertificate(host string) (agentCertificate, error) {
}
sum := sha256.Sum256(der)
return agentCertificate{
CertPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})),
KeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})),
Pin: hex.EncodeToString(sum[:]),
CertPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})),
KeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})),
Pin: hex.EncodeToString(sum[:]),
NotAfter: template.NotAfter,
}, nil
}
+66 -2
View File
@@ -4,7 +4,9 @@ import (
"crypto/x509"
"encoding/pem"
"net"
"strings"
"testing"
"time"
)
func TestGenerateAgentCertificate(t *testing.T) {
@@ -37,16 +39,46 @@ func TestPinTLSConfigVerification(t *testing.T) {
if block == nil {
t.Fatal("证书 PEM 解码失败")
}
verify := pinTLSConfig(cert.Pin).VerifyPeerCertificate
verify := pinTLSConfig("agent.example.com", cert.Pin).VerifyPeerCertificate
if err := verify([][]byte{block.Bytes}, nil); err != nil {
t.Fatalf("正确 pin 校验失败: %v", err)
}
wrong := pinTLSConfig("0000000000000000000000000000000000000000000000000000000000000000").VerifyPeerCertificate
wrong := pinTLSConfig("agent.example.com", strings.Repeat("0", 64)).VerifyPeerCertificate
if err := wrong([][]byte{block.Bytes}, nil); err == nil {
t.Fatal("错误 pin 应校验失败")
}
}
func TestPinTLSConfigAcceptsGracePins(t *testing.T) {
cert, err := generateAgentCertificate("agent.example.com")
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode([]byte(cert.CertPEM))
if block == nil {
t.Fatal("证书 PEM 解码失败")
}
verify := pinTLSConfig("agent.example.com", strings.Repeat("1", 64), cert.Pin).VerifyPeerCertificate
if err := verify([][]byte{block.Bytes}, nil); err != nil {
t.Fatalf("轮换过渡期应同时接受旧 pin 与新 pin: %v", err)
}
}
func TestPinTLSConfigRejectsHostMismatch(t *testing.T) {
cert, err := generateAgentCertificate("agent.example.com")
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode([]byte(cert.CertPEM))
if block == nil {
t.Fatal("证书 PEM 解码失败")
}
verify := pinTLSConfig("other.example.com", cert.Pin).VerifyPeerCertificate
if err := verify([][]byte{block.Bytes}, nil); err == nil {
t.Fatal("证书 SAN 与连接主机不一致时应拒绝")
}
}
func TestAgentHTTPSBaseURL(t *testing.T) {
cases := []struct {
name string
@@ -82,3 +114,35 @@ func TestAgentHTTPSBaseURL(t *testing.T) {
})
}
}
func TestAgentTransportPins(t *testing.T) {
active := Server{
AgentTLSPin: "new",
AgentTLSPinPrev: "old",
AgentTLSPinPrevUntil: time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
}
pins := agentTransportPins(active)
if len(pins) != 2 || pins[0] != "new" || pins[1] != "old" {
t.Fatalf("过渡期应同时返回新旧 pin:%v", pins)
}
expired := active
expired.AgentTLSPinPrevUntil = time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
if pins := agentTransportPins(expired); len(pins) != 1 || pins[0] != "new" {
t.Fatalf("过渡期结束后只应保留新 pin:%v", pins)
}
}
func TestResultPortAndListenPort(t *testing.T) {
if got := resultPort(map[string]any{"port": float64(8443)}, 0); got != 8443 {
t.Fatalf("resultPort() = %d, 期望 8443", got)
}
if got := resultPort(nil, 7443); got != 7443 {
t.Fatalf("resultPort() 回退值 = %d, 期望 7443", got)
}
if got := listenPort("0.0.0.0:9443", 7443); got != 9443 {
t.Fatalf("listenPort() = %d, 期望 9443", got)
}
if got := listenPort("", 7443); got != 7443 {
t.Fatalf("listenPort() 回退值 = %d, 期望 7443", got)
}
}
+51 -1
View File
@@ -13,6 +13,12 @@ import (
const agentRemoteAddr = "127.0.0.1:7700"
const (
agentTunnelKeepalive = 30 * time.Second
agentTunnelBackoffMin = time.Second
agentTunnelBackoffMax = 60 * time.Second
)
type agentTunnel struct {
serverID int64
localPort int
@@ -59,11 +65,55 @@ func (t *agentTunnel) Start() error {
t.state = "connected"
t.lastError = ""
t.mu.Unlock()
t.wg.Add(1)
t.wg.Add(2)
go t.acceptLoop()
go t.monitorLoop()
return nil
}
// monitorLoop 主动保活并在断开后按指数退避重建 SSH 连接,避免只在有流量时才尝试重连。
func (t *agentTunnel) monitorLoop() {
defer t.wg.Done()
backoff := agentTunnelBackoffMin
for {
_, err := t.ensureClient()
wait := agentTunnelKeepalive
if err != nil {
t.setState("reconnecting", err)
backoff = nextTunnelBackoff(backoff)
wait = backoff
} else {
t.setState("connected", nil)
backoff = agentTunnelBackoffMin
}
if !t.sleep(wait) {
return
}
}
}
func (t *agentTunnel) sleep(d time.Duration) bool {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-t.ctx.Done():
return false
case <-timer.C:
return true
}
}
func nextTunnelBackoff(current time.Duration) time.Duration {
if current < agentTunnelBackoffMin {
return agentTunnelBackoffMin
}
next := current * 2
if next > agentTunnelBackoffMax {
return agentTunnelBackoffMax
}
return next
}
func (t *agentTunnel) acceptLoop() {
defer t.wg.Done()
for {
+20 -2
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net"
"testing"
"time"
)
func TestAgentTunnelBindsLoopbackAndReleasesPort(t *testing.T) {
@@ -23,8 +24,8 @@ func TestAgentTunnelBindsLoopbackAndReleasesPort(t *testing.T) {
t.Fatal("隧道启动后本地端口应已被占用")
}
state, _ := tunnel.State()
if state != "connected" {
t.Fatalf("state=%q, want connected", state)
if state != "connected" && state != "reconnecting" {
t.Fatalf("state=%q, want connected 或 reconnecting", state)
}
tunnel.Stop()
if listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)); err != nil {
@@ -33,3 +34,20 @@ func TestAgentTunnelBindsLoopbackAndReleasesPort(t *testing.T) {
_ = listener.Close()
}
}
func TestNextTunnelBackoff(t *testing.T) {
cases := []struct {
in time.Duration
want time.Duration
}{
{0, agentTunnelBackoffMin},
{time.Second, 2 * time.Second},
{40 * time.Second, agentTunnelBackoffMax},
{agentTunnelBackoffMax, agentTunnelBackoffMax},
}
for _, tc := range cases {
if got := nextTunnelBackoff(tc.in); got != tc.want {
t.Fatalf("nextTunnelBackoff(%v) = %v, 期望 %v", tc.in, got, tc.want)
}
}
}
Binary file not shown.
Binary file not shown.
+42 -38
View File
@@ -84,44 +84,48 @@ type DownloadTask struct {
}
type Server struct {
ID int64 `json:"id"`
Name string `json:"name"`
AgentURL string `json:"agent_url,omitempty"`
AgentTransport string `json:"agent_transport"`
AgentLocalPort int `json:"agent_local_port,omitempty"`
AgentTLSPin string `json:"agent_tls_pin,omitempty"`
AgentListenAddr string `json:"agent_listen_addr,omitempty"`
AgentTransportState string `json:"agent_transport_state"`
AgentTransportError string `json:"agent_transport_error,omitempty"`
AgentConfigured bool `json:"agent_configured"`
SSHConfigured bool `json:"ssh_configured"`
MgmtEnabled bool `json:"mgmt_enabled"`
SSHHost string `json:"ssh_host,omitempty"`
SSHPort int `json:"ssh_port,omitempty"`
SSHUser string `json:"ssh_user,omitempty"`
SSHAuth string `json:"ssh_auth,omitempty"`
ServerAddr string `json:"server_addr"`
ServerPort int `json:"server_port"`
TLS bool `json:"tls_enabled"`
TLSServerName string `json:"tls_server_name,omitempty"`
Enabled bool `json:"enabled"`
HasToken bool `json:"has_token"`
HasOIDCSecret bool `json:"has_oidc_client_secret"`
DashboardHasPwd bool `json:"dashboard_has_pwd"`
Options ServerOptions `json:"options"`
BootStatus string `json:"boot_status"`
DesiredState string `json:"desired_state"`
ProcessState string `json:"process_state"`
ConnectionState string `json:"connection_state"`
StateSource string `json:"state_source"`
ProcessPID int `json:"process_pid,omitempty"`
ExitCode int `json:"exit_code,omitempty"`
LastConnectedAt string `json:"last_connected_at,omitempty"`
LastDisconnectedAt string `json:"last_disconnected_at,omitempty"`
LastError string `json:"last_error,omitempty"`
LastStartedAt string `json:"last_started_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID int64 `json:"id"`
Name string `json:"name"`
AgentURL string `json:"agent_url,omitempty"`
AgentTransport string `json:"agent_transport"`
AgentLocalPort int `json:"agent_local_port,omitempty"`
AgentTLSPin string `json:"agent_tls_pin,omitempty"`
AgentTLSPinPrev string `json:"agent_tls_pin_prev,omitempty"`
AgentTLSPinPrevUntil string `json:"agent_tls_pin_prev_until,omitempty"`
AgentTLSNotAfter string `json:"agent_tls_not_after,omitempty"`
AgentFirewallState string `json:"agent_firewall_state,omitempty"`
AgentListenAddr string `json:"agent_listen_addr,omitempty"`
AgentTransportState string `json:"agent_transport_state"`
AgentTransportError string `json:"agent_transport_error,omitempty"`
AgentConfigured bool `json:"agent_configured"`
SSHConfigured bool `json:"ssh_configured"`
MgmtEnabled bool `json:"mgmt_enabled"`
SSHHost string `json:"ssh_host,omitempty"`
SSHPort int `json:"ssh_port,omitempty"`
SSHUser string `json:"ssh_user,omitempty"`
SSHAuth string `json:"ssh_auth,omitempty"`
ServerAddr string `json:"server_addr"`
ServerPort int `json:"server_port"`
TLS bool `json:"tls_enabled"`
TLSServerName string `json:"tls_server_name,omitempty"`
Enabled bool `json:"enabled"`
HasToken bool `json:"has_token"`
HasOIDCSecret bool `json:"has_oidc_client_secret"`
DashboardHasPwd bool `json:"dashboard_has_pwd"`
Options ServerOptions `json:"options"`
BootStatus string `json:"boot_status"`
DesiredState string `json:"desired_state"`
ProcessState string `json:"process_state"`
ConnectionState string `json:"connection_state"`
StateSource string `json:"state_source"`
ProcessPID int `json:"process_pid,omitempty"`
ExitCode int `json:"exit_code,omitempty"`
LastConnectedAt string `json:"last_connected_at,omitempty"`
LastDisconnectedAt string `json:"last_disconnected_at,omitempty"`
LastError string `json:"last_error,omitempty"`
LastStartedAt string `json:"last_started_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ServerRuntimeStatus struct {
+243 -20
View File
@@ -941,6 +941,83 @@ func (s *Service) RestartAgentTunnel(ctx context.Context, serverID int64) (Serve
return server, nil
}
// AgentTransportStatus 返回传输方式的详细状态,供前端与诊断使用。
func (s *Service) AgentTransportStatus(ctx context.Context, serverID int64) (map[string]any, error) {
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
return nil, err
}
state := server.AgentTransportState
lastError := server.AgentTransportError
if server.AgentTransport == "tunnel" {
if tunnelState, tunnelError, ok := s.tunnelState(serverID); ok {
state, lastError = tunnelState, tunnelError
}
}
return map[string]any{
"transport": server.AgentTransport,
"state": state,
"agent_url": server.AgentURL,
"local_port": server.AgentLocalPort,
"listen_addr": server.AgentListenAddr,
"tls_pin": server.AgentTLSPin,
"tls_not_after": server.AgentTLSNotAfter,
"pin_prev_until": server.AgentTLSPinPrevUntil,
"firewall_state": server.AgentFirewallState,
"last_error": lastError,
}, nil
}
// AgentFirewallStatus 检测 VPS 主机防火墙,并把结果缓存到服务端记录供 UI 展示。
func (s *Service) AgentFirewallStatus(ctx context.Context, serverID int64, port int) (map[string]any, error) {
if port <= 0 || port > 65535 {
return nil, fmt.Errorf("端口无效")
}
client, ok := s.agentClient(ctx, serverID)
if !ok {
return nil, fmt.Errorf("当前 Agent 连接不可用")
}
status, err := client.FirewallStatus(ctx, port)
if err != nil {
return nil, err
}
_ = s.store.SetAgentFirewallState(ctx, serverID, firewallSummary(status))
return status, nil
}
// AllowAgentFirewallPort 由用户显式触发,Agent 仅对 ufw / firewalld 执行放行。
func (s *Service) AllowAgentFirewallPort(ctx context.Context, serverID int64, port int) (map[string]any, error) {
if port <= 0 || port > 65535 {
return nil, fmt.Errorf("端口无效")
}
client, ok := s.agentClient(ctx, serverID)
if !ok {
return nil, fmt.Errorf("当前 Agent 连接不可用")
}
result, err := client.AllowFirewallPort(ctx, port)
if err != nil {
return nil, err
}
_ = s.store.SetAgentFirewallState(ctx, serverID, firewallSummary(result))
return result, nil
}
func firewallSummary(result map[string]any) string {
status := result
if nested, ok := result["status"].(map[string]any); ok {
status = nested
}
tool, _ := status["tool"].(string)
open, _ := status["port_open"].(bool)
if tool == "" {
return ""
}
if open {
return tool + ":已放行"
}
return tool + ":未放行"
}
func (s *Service) agentToken(ctx context.Context, serverID int64) (string, error) {
encoded, err := s.store.AgentToken(ctx, serverID)
if err != nil || encoded == "" {
@@ -954,10 +1031,7 @@ func (s *Service) agentToken(ctx context.Context, serverID int64) (string, error
}
func (s *Service) EnableAgentHTTPS(ctx context.Context, serverID int64, port int) (Server, error) {
if port == 0 {
port = 7443
}
if port <= 0 || port > 65535 {
if port < 0 || port > 65535 {
return Server{}, fmt.Errorf("HTTPS 端口无效")
}
server, err := s.store.GetServer(ctx, serverID)
@@ -983,16 +1057,23 @@ func (s *Service) EnableAgentHTTPS(ctx context.Context, serverID int64, port int
if err != nil {
return Server{}, err
}
baseURL := fmt.Sprintf("https://%s:%d", server.SSHHost, port)
actualPort := resultPort(result, port)
if actualPort <= 0 {
return Server{}, fmt.Errorf("Agent 未返回可用的 HTTPS 端口")
}
baseURL := fmt.Sprintf("https://%s:%d", server.SSHHost, actualPort)
pinned := NewAgentClientWithPin(baseURL, token, cert.Pin)
if err := pinned.Ping(ctx); err != nil {
_ = currentClient.DisableHTTPS(ctx)
if result["self_check"] == "ok" {
return Server{}, fmt.Errorf("HTTPS 直连验证失败:Agent 本机 7443 自检通过,但公网 %s 返回的证书不一致,请检查该端口是否被其他服务、端口转发或防火墙接管: %w", baseURL, err)
return Server{}, fmt.Errorf("HTTPS 直连验证失败:Agent 本机 %d 端口自检通过,但公网 %s 返回的证书不一致,请检查该端口是否被其他服务、端口转发或防火墙接管: %w", actualPort, baseURL, err)
}
return Server{}, fmt.Errorf("HTTPS 直连验证失败: %w", err)
}
if err := s.store.SetAgentTransport(ctx, serverID, baseURL, "https-pin", server.AgentLocalPort, cert.Pin, fmt.Sprintf("0.0.0.0:%d", port), "connected", ""); err != nil {
if err := s.store.SetAgentTransport(ctx, serverID, baseURL, "https-pin", server.AgentLocalPort, cert.Pin, fmt.Sprintf("0.0.0.0:%d", actualPort), "connected", ""); err != nil {
return Server{}, err
}
if err := s.store.SetAgentTLSPinRotation(ctx, serverID, cert.Pin, "", "", cert.NotAfter.UTC().Format(time.RFC3339)); err != nil {
return Server{}, err
}
s.invalidateAgentClient(serverID)
@@ -1004,6 +1085,128 @@ func (s *Service) EnableAgentHTTPS(ctx context.Context, serverID int64, port int
return updated, nil
}
const (
agentCertRenewBefore = 30 * 24 * time.Hour
agentCertPinGrace = 7 * 24 * time.Hour
)
// StartAgentCertificateRenewal 后台巡检 HTTPS 直连证书,剩余不足 30 天时自动轮换。
func (s *Service) StartAgentCertificateRenewal(ctx context.Context) {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
s.renewAgentCertificates(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.renewAgentCertificates(ctx)
}
}
}
func (s *Service) renewAgentCertificates(ctx context.Context) {
servers, err := s.store.ListServers(ctx)
if err != nil {
s.logger.Warn("巡检 Agent 证书失败", "error", err.Error())
return
}
for _, server := range servers {
if !server.MgmtEnabled || server.AgentTransport != "https-pin" {
continue
}
notAfter, ok := s.agentCertificateExpiry(ctx, server)
if !ok || time.Until(notAfter) > agentCertRenewBefore {
continue
}
if _, err := s.RotateAgentCertificate(ctx, server.ID); err != nil {
s.logger.Warn("Agent 证书自动轮换失败", "server_id", server.ID, "error", err.Error())
}
}
}
// agentCertificateExpiry 优先使用数据库缓存;旧记录缺少到期时间时从 Agent 补齐。
func (s *Service) agentCertificateExpiry(ctx context.Context, server Server) (time.Time, bool) {
if raw := strings.TrimSpace(server.AgentTLSNotAfter); raw != "" {
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
return parsed, true
}
}
client, ok := s.agentClient(ctx, server.ID)
if !ok {
return time.Time{}, false
}
state, err := client.HTTPSState(ctx)
if err != nil {
return time.Time{}, false
}
raw, _ := state["not_after"].(string)
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(raw))
if err != nil {
return time.Time{}, false
}
_ = s.store.SetAgentTLSNotAfter(ctx, server.ID, parsed.UTC().Format(time.RFC3339))
return parsed, true
}
// RotateAgentCertificate 安装新证书;过渡期内客户端同时接受新 pin 与旧 pin。
func (s *Service) RotateAgentCertificate(ctx context.Context, serverID int64) (Server, error) {
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, err
}
if server.AgentTransport != "https-pin" {
return Server{}, fmt.Errorf("仅 HTTPS 直连模式支持证书轮换")
}
token, err := s.agentToken(ctx, serverID)
if err != nil {
return Server{}, err
}
cert, err := generateAgentCertificate(server.SSHHost)
if err != nil {
return Server{}, err
}
oldPin := strings.TrimSpace(server.AgentTLSPin)
port := listenPort(server.AgentListenAddr, 7443)
configureClient := NewAgentClientWithPins(agentHTTPSBaseURL(server), token, cert.Pin, oldPin)
result, err := configureClient.ConfigureHTTPS(ctx, cert.CertPEM, cert.KeyPEM, port)
if err != nil {
return Server{}, err
}
actualPort := resultPort(result, port)
baseURL := fmt.Sprintf("https://%s:%d", server.SSHHost, actualPort)
graceUntil := time.Now().Add(agentCertPinGrace).UTC().Format(time.RFC3339)
notAfter := cert.NotAfter.UTC().Format(time.RFC3339)
if err := s.store.SetAgentTransport(ctx, serverID, baseURL, "https-pin", server.AgentLocalPort, cert.Pin, fmt.Sprintf("0.0.0.0:%d", actualPort), "connected", ""); err != nil {
return Server{}, err
}
if err := s.store.SetAgentTLSPinRotation(ctx, serverID, cert.Pin, oldPin, graceUntil, notAfter); err != nil {
return Server{}, err
}
s.invalidateAgentClient(serverID)
dual := NewAgentClientWithPins(baseURL, token, cert.Pin, oldPin)
if err := dual.Ping(ctx); err != nil {
return Server{}, fmt.Errorf("证书轮换后公网校验失败(旧 pin 在过渡期仍可用): %w", err)
}
return s.store.GetServer(ctx, serverID)
}
func resultPort(result map[string]any, fallback int) int {
if raw, ok := result["port"].(float64); ok && raw > 0 && raw <= 65535 {
return int(raw)
}
return fallback
}
func listenPort(listenAddr string, fallback int) int {
if _, rawPort, err := net.SplitHostPort(strings.TrimSpace(listenAddr)); err == nil {
if parsed, err := strconv.Atoi(rawPort); err == nil && parsed > 0 && parsed <= 65535 {
return parsed
}
}
return fallback
}
func (s *Service) SwitchAgentToTunnel(ctx context.Context, serverID int64) (Server, error) {
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
@@ -1609,15 +1812,35 @@ func agentHTTPSBaseURL(server Server) string {
if host == "" {
return current
}
port := 7443
if _, rawPort, err := net.SplitHostPort(strings.TrimSpace(server.AgentListenAddr)); err == nil {
if parsed, err := strconv.Atoi(rawPort); err == nil && parsed > 0 {
port = parsed
}
}
port := listenPort(server.AgentListenAddr, 7443)
return fmt.Sprintf("https://%s:%d", host, port)
}
// agentTransportPins 返回当前应接受的证书 pin;轮换过渡期内同时包含旧 pin。
func agentTransportPins(server Server) []string {
out := make([]string, 0, 2)
if pin := strings.TrimSpace(server.AgentTLSPin); pin != "" {
out = append(out, pin)
}
prev := strings.TrimSpace(server.AgentTLSPinPrev)
if prev == "" || !pinGraceActive(server.AgentTLSPinPrevUntil) {
return out
}
return append(out, prev)
}
func pinGraceActive(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return false
}
deadline, err := time.Parse(time.RFC3339, raw)
if err != nil {
return false
}
return time.Now().Before(deadline)
}
func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient, bool) {
server, err := s.store.GetServer(ctx, serverID)
if err != nil || !server.MgmtEnabled || !server.AgentConfigured {
@@ -1632,7 +1855,7 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient
return nil, false
}
baseURL := server.AgentURL
pin := ""
var pins []string
if server.AgentTransport == "tunnel" {
tunnel, err := s.ensureAgentTunnel(ctx, serverID)
if err != nil {
@@ -1640,8 +1863,8 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient
}
baseURL = fmt.Sprintf("http://127.0.0.1:%d", tunnel.localPort)
} else if server.AgentTransport == "https-pin" {
pin = strings.TrimSpace(server.AgentTLSPin)
if pin == "" {
pins = agentTransportPins(server)
if len(pins) == 0 {
return nil, false
}
baseURL = agentHTTPSBaseURL(server)
@@ -1650,7 +1873,7 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient
}
if baseURL != strings.TrimRight(strings.TrimSpace(server.AgentURL), "/") {
err := s.store.SetAgentTransport(ctx, serverID, baseURL, "https-pin", server.AgentLocalPort,
pin, server.AgentListenAddr, server.AgentTransportState, server.AgentTransportError)
server.AgentTLSPin, server.AgentListenAddr, server.AgentTransportState, server.AgentTransportError)
if err != nil {
s.logger.Warn("agent https url repair failed", "server_id", serverID, "error", err)
}
@@ -1659,7 +1882,7 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient
return nil, false
}
// 复用同一个 http.Client:批量同步 N 条路由时不再每次新建 Transport 与 TLS 握手
fingerprint := baseURL + "|" + encoded + "|" + pin
fingerprint := baseURL + "|" + encoded + "|" + strings.Join(pins, ",")
s.agentMu.Lock()
defer s.agentMu.Unlock()
if s.agentClients == nil {
@@ -1669,8 +1892,8 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient
return cached.client, true
}
var client *AgentClient
if pin != "" {
client = NewAgentClientWithPin(baseURL, token, pin)
if len(pins) > 0 {
client = NewAgentClientWithPins(baseURL, token, pins...)
} else {
client = NewAgentClient(baseURL, token)
}
+51 -1
View File
@@ -297,6 +297,54 @@ func (s *Store) SetAgentTransport(ctx context.Context, id int64, agentURL, trans
return nil
}
// SetAgentTLSPinRotation 更新当前证书 pin、过渡期旧 pin 与证书到期时间。
func (s *Store) SetAgentTLSPinRotation(ctx context.Context, id int64, pin, prevPin, prevUntil, notAfter string) error {
result, err := s.db.ExecContext(ctx, `
UPDATE frp_servers
SET agent_tls_pin = ?, agent_tls_pin_prev = ?, agent_tls_pin_prev_until = ?, agent_tls_not_after = ?,
updated_at = datetime('now')
WHERE id = ?
`, pin, prevPin, prevUntil, notAfter, id)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return fmt.Errorf("FRP 服务端不存在")
}
return nil
}
// SetAgentFirewallState 记录最近一次主机防火墙检测 / 放行结果,供前端展示。
func (s *Store) SetAgentFirewallState(ctx context.Context, id int64, state string) error {
result, err := s.db.ExecContext(ctx, `
UPDATE frp_servers SET agent_firewall_state = ?, updated_at = datetime('now') WHERE id = ?
`, state, id)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return fmt.Errorf("FRP 服务端不存在")
}
return nil
}
// SetAgentTLSNotAfter 只同步证书到期时间,不改动 pin(用于旧记录补齐)。
func (s *Store) SetAgentTLSNotAfter(ctx context.Context, id int64, notAfter string) error {
result, err := s.db.ExecContext(ctx, `
UPDATE frp_servers SET agent_tls_not_after = ?, updated_at = datetime('now') WHERE id = ?
`, notAfter, id)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return fmt.Errorf("FRP 服务端不存在")
}
return nil
}
func (s *Store) Token(ctx context.Context, id int64) (string, error) {
var token string
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(auth_token_enc, '') FROM frp_servers WHERE id = ?`, id).Scan(&token)
@@ -447,6 +495,8 @@ SELECT id, name, server_addr, server_port, tls_enabled, tls_server_name, enabled
COALESCE(ssh_host, ''), COALESCE(ssh_port, 22), COALESCE(ssh_user, ''), COALESCE(ssh_auth, 'key'),
CASE WHEN COALESCE(ssh_secret_enc, '') != '' THEN 1 ELSE 0 END, COALESCE(mgmt_enabled, 0),
COALESCE(agent_transport, 'http'), COALESCE(agent_local_port, 0), COALESCE(agent_tls_pin, ''),
COALESCE(agent_tls_pin_prev, ''), COALESCE(agent_tls_pin_prev_until, ''), COALESCE(agent_tls_not_after, ''),
COALESCE(agent_firewall_state, ''),
COALESCE(agent_listen_addr, ''), COALESCE(agent_transport_state, 'unknown'), COALESCE(agent_transport_error, ''),
COALESCE(config_json, '{}'), created_at, updated_at
FROM frp_servers`
@@ -459,7 +509,7 @@ func scanServer(row interface{ Scan(dest ...any) error }) (Server, error) {
var server Server
var tls, enabled, token, oidcSecret, dashboardPwd, agentConfigured, sshConfigured, mgmtEnabled int
var configJSON, created, updated string
err := row.Scan(&server.ID, &server.Name, &server.ServerAddr, &server.ServerPort, &tls, &server.TLSServerName, &enabled, &token, &oidcSecret, &dashboardPwd, &server.AgentURL, &agentConfigured, &server.SSHHost, &server.SSHPort, &server.SSHUser, &server.SSHAuth, &sshConfigured, &mgmtEnabled, &server.AgentTransport, &server.AgentLocalPort, &server.AgentTLSPin, &server.AgentListenAddr, &server.AgentTransportState, &server.AgentTransportError, &configJSON, &created, &updated)
err := row.Scan(&server.ID, &server.Name, &server.ServerAddr, &server.ServerPort, &tls, &server.TLSServerName, &enabled, &token, &oidcSecret, &dashboardPwd, &server.AgentURL, &agentConfigured, &server.SSHHost, &server.SSHPort, &server.SSHUser, &server.SSHAuth, &sshConfigured, &mgmtEnabled, &server.AgentTransport, &server.AgentLocalPort, &server.AgentTLSPin, &server.AgentTLSPinPrev, &server.AgentTLSPinPrevUntil, &server.AgentTLSNotAfter, &server.AgentFirewallState, &server.AgentListenAddr, &server.AgentTransportState, &server.AgentTransportError, &configJSON, &created, &updated)
if err != nil {
return Server{}, err
}