fix(agent): HTTPS 自检证书指纹并定位端口占用并发布 v1.4.6
CI / verify (push) Canceled after 0s

This commit is contained in:
2026-09-28 14:57:11 +08:00
parent 87c072f5d4
commit dfce843b50
15 changed files with 162 additions and 25 deletions
+3 -1
View File
@@ -56,7 +56,9 @@ import (
// 修掉旧 <name>.versions 目录被 include * 当配置文件读取、nginx -t 报 pread() "Is a directory" 的问题。
// 0.13.2:nginx -T 探测失败时先从报错文本解析旧 <name>.versions 目录并迁移后重试,
// 修掉「配置目录还没探测出来、迁移逻辑没机会执行」导致 HTTPS 直连持续失败的问题。
const AgentVersion = "0.13.2"
// 0.13.3:HTTPS 反代改用 default_server 并在 reload 后做本机证书指纹自检,
// 证书不匹配时回滚并回报实际指纹与端口占用进程,区分「nginx 未生效」与「公网链路被接管」。
const AgentVersion = "0.13.3"
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
type Config struct {
+82 -2
View File
@@ -1,14 +1,21 @@
package agent
import (
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/fonu/fonu/internal/fsutil"
)
@@ -49,7 +56,7 @@ func (s *Server) putHTTPSProxy(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "port": req.Port})
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "port": req.Port, "self_check": "ok"})
}
func (s *Server) deleteHTTPSProxy(w http.ResponseWriter, _ *http.Request) {
@@ -96,6 +103,14 @@ func (s *Server) configureHTTPSProxy(req httpsProxyRequest) error {
s.restoreHTTPSConfig(path, previous, readErr)
return err
}
pin, err := certificatePin(req.CertPEM)
if err != nil {
return err
}
if err := s.verifyHTTPSProxy(req.Port, pin); err != nil {
s.restoreHTTPSConfig(path, previous, readErr)
return err
}
return writeHTTPSProxyState(s.cfg.DataDir, httpsProxyState{Port: req.Port})
}
@@ -150,7 +165,7 @@ func (s *Server) ensureNginxDir() (string, error) {
func renderHTTPSProxy(port int, certPath, keyPath string) string {
return fmt.Sprintf(`# Managed by fonu-agent. Do not edit.
server {
listen %d ssl;
listen %d ssl default_server;
server_name _;
ssl_certificate %s;
ssl_certificate_key %s;
@@ -168,6 +183,71 @@ server {
`, port, certPath, keyPath)
}
func certificatePin(certPEM string) (string, error) {
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
return "", errors.New("证书 PEM 解码失败")
}
sum := sha256.Sum256(block.Bytes)
return hex.EncodeToString(sum[:]), nil
}
// verifyHTTPSProxy 在 reload 后从本机回环地址握手,确认 7443 上真正返回的就是刚写入的证书。
// nginx reload 是异步的,这里留出短暂重试窗口,避免旧 worker 造成的瞬时误判。
func (s *Server) verifyHTTPSProxy(port int, want string) error {
deadline := time.Now().Add(5 * time.Second)
var lastErr error
for {
lastErr = probeHTTPSPin(port, want)
if lastErr == nil {
return nil
}
if time.Now().After(deadline) {
return lastErr
}
time.Sleep(500 * time.Millisecond)
}
}
func probeHTTPSPin(port int, want string) error {
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
dialer := &net.Dialer{Timeout: 3 * time.Second}
conn, err := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12})
if err != nil {
return fmt.Errorf("本机 https://%s 自检失败:%w;%s", addr, err, portListenerHint(port))
}
defer conn.Close()
certs := conn.ConnectionState().PeerCertificates
if len(certs) == 0 {
return fmt.Errorf("本机 https://%s 未返回证书;%s", addr, portListenerHint(port))
}
sum := sha256.Sum256(certs[0].Raw)
got := hex.EncodeToString(sum[:])
if !strings.EqualFold(got, want) {
return fmt.Errorf("本机 https://%s 证书指纹不匹配:期望 %s,实际 %s;%s", addr, want, got, portListenerHint(port))
}
return nil
}
// portListenerHint 把监听该端口的进程信息带进错误里,便于区分 nginx 未生效与端口被其他服务占用。
func portListenerHint(port int) string {
out, err := exec.Command("ss", "-tlnp").CombinedOutput()
if err != nil {
return "无法读取端口监听信息"
}
suffix := ":" + strconv.Itoa(port)
lines := make([]string, 0, 2)
for _, line := range strings.Split(string(out), "\n") {
if strings.Contains(line, suffix+" ") || strings.HasSuffix(strings.TrimSpace(line), suffix) {
lines = append(lines, strings.TrimSpace(line))
}
}
if len(lines) == 0 {
return "未发现监听 " + suffix + " 的进程"
}
return "监听 " + suffix + ":" + strings.Join(lines, " | ")
}
func httpsProxyStatePath(dataDir string) string {
return filepath.Join(dataDir, "https_transport.json")
}
+34
View File
@@ -1,9 +1,18 @@
package agent
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"
)
func TestMigrateLegacyVersionDirs(t *testing.T) {
@@ -43,3 +52,28 @@ func TestMigrateVersionDirsFromNginxOutput(t *testing.T) {
t.Fatalf("旧版本目录应被移出 include 范围:%v", err)
}
}
func TestCertificatePin(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
block := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
got, err := certificatePin(string(block))
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(der)
if want := hex.EncodeToString(sum[:]); got != want {
t.Fatalf("certificatePin() = %s, 期望 %s", got, want)
}
}
+3 -2
View File
@@ -119,13 +119,14 @@ func (c *AgentClient) Ping(ctx context.Context) error {
return c.request(ctx, http.MethodGet, "/api/v1/ping", nil, nil)
}
func (c *AgentClient) ConfigureHTTPS(ctx context.Context, certPEM, keyPEM string, port int) error {
func (c *AgentClient) ConfigureHTTPS(ctx context.Context, certPEM, keyPEM string, port int) (map[string]any, error) {
var result map[string]any
return c.request(ctx, http.MethodPut, "/api/v1/transport/https", map[string]any{
err := c.request(ctx, http.MethodPut, "/api/v1/transport/https", map[string]any{
"cert_pem": certPEM,
"key_pem": keyPEM,
"port": port,
}, &result)
return result, err
}
func (c *AgentClient) DisableHTTPS(ctx context.Context) error {
Binary file not shown.
Binary file not shown.
+5 -1
View File
@@ -979,13 +979,17 @@ func (s *Service) EnableAgentHTTPS(ctx context.Context, serverID int64, port int
if err != nil {
return Server{}, err
}
if err := currentClient.ConfigureHTTPS(ctx, cert.CertPEM, cert.KeyPEM, port); err != nil {
result, err := currentClient.ConfigureHTTPS(ctx, cert.CertPEM, cert.KeyPEM, port)
if err != nil {
return Server{}, err
}
baseURL := fmt.Sprintf("https://%s:%d", server.SSHHost, port)
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 直连验证失败: %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 {