This commit is contained in:
@@ -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,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 {
|
||||
|
||||
@@ -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 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"
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
+42
-38
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user