diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c89c7b..8bcdbbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to this project will be documented in this file. +## [v1.4.6] - 2026-09-28 + +### 修复 + +- **HTTPS 直连证书指纹不匹配可定位**:nginx 配置校验通过、但公网 `7443` 返回的证书与刚写入的不一致时,原来只提示“证书指纹不匹配”,无法判断是 nginx 未生效还是端口被其他服务接管。现在 HTTPS server 使用 `default_server` 绑定,Agent 会在 reload 后从本机 `127.0.0.1:<端口>` 握手自检并重试 5 秒;指纹不一致时回滚,并回报实际指纹与 `ss -tlnp` 端口占用进程 +- **公网链路错误提示**:主程序在 Agent 本机自检通过、但公网地址校验失败时,会明确提示检查 `7443` 的端口转发、防火墙或其他服务接管 + +### 变更 + +- 版本号提升到 1.4.6;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 0.13.2 → **0.13.3**,内嵌 amd64 / arm64 二进制已重编;VPS 上需重新点一次「升级 Agent」才会生效 + +### 测试 + +- `internal/agent`:新增证书 PEM 指纹计算测试 + ## [v1.4.5] - 2026-09-28 ### 修复 diff --git a/VERSION b/VERSION index e516bb9..c514bd8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.5 +1.4.6 diff --git a/docs/README.md b/docs/README.md index 793ac7d..573eb05 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # Fonu 文档索引 > 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 -> 最近更新:2026-09-28(v1.4.5) +> 最近更新:2026-09-28(v1.4.6) ## 方案与设计(本目录) diff --git a/docs/agent-listen-strategy-plan.md b/docs/agent-listen-strategy-plan.md index 0b87199..5465d07 100644 --- a/docs/agent-listen-strategy-plan.md +++ b/docs/agent-listen-strategy-plan.md @@ -2,10 +2,10 @@ > 方案编号:P0-5 > 制定日期:2026-09-28 -> 当前 AgentVersion:0.13.2 +> 当前 AgentVersion:0.13.3 > 目标:默认不暴露 Agent 管理接口,安装和升级过程自动建立安全连接,不要求用户手工配置端口、隧道或证书 -> 实施状态(2026-09-28):阶段 1-4 已随 v1.4.0 实施;0.13.1 / 0.13.2 修复 nginx 版本目录被 `include *` 误加载及探测阶段无法自愈的问题;仅剩 2.0.0 的旧 HTTP 兼容清理和可选主机防火墙辅助。 +> 实施状态(2026-09-28):阶段 1-4 已随 v1.4.0 实施;0.13.1 / 0.13.2 修复 nginx 版本目录被 `include *` 误加载及探测阶段无法自愈的问题,0.13.3 增加 HTTPS reload 后本机证书自检;仅剩 2.0.0 的旧 HTTP 兼容清理和可选主机防火墙辅助。 --- diff --git a/docs/system-upgrade.md b/docs/system-upgrade.md index b67aba1..17bea0e 100644 --- a/docs/system-upgrade.md +++ b/docs/system-upgrade.md @@ -238,16 +238,16 @@ FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu ## 10. 在线升级验证 -示例页面用于验证升级结果。v1.4.5 的验证流程: +示例页面用于验证升级结果。v1.4.6 的验证流程: 1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。 -2. 确认远端 `main` 分支的 `VERSION` 为 `1.4.5`。 +2. 确认远端 `main` 分支的 `VERSION` 为 `1.4.6`。 3. 在侧栏看到「一键升级」后点击并确认。 4. 等待升级弹窗显示成功,页面自动刷新。 5. 打开侧栏「示例页面」,确认: - 页面路由为 `/example`; - - 页面显示的版本为 `1.4.5`; - - 页面显示验证标识 `v1.4.5-example-page`; + - 页面显示的版本为 `1.4.6`; + - 页面显示验证标识 `v1.4.6-example-page`; - 页面显示独立的「本次升级验证」区块; - 「升级验证」显示为「通过」。 diff --git a/internal/agent/server.go b/internal/agent/server.go index ee67188..2808db2 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -56,7 +56,9 @@ import ( // 修掉旧 .versions 目录被 include * 当配置文件读取、nginx -t 报 pread() "Is a directory" 的问题。 // 0.13.2:nginx -T 探测失败时先从报错文本解析旧 .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 { diff --git a/internal/agent/transport.go b/internal/agent/transport.go index ace5add..e34d0e8 100644 --- a/internal/agent/transport.go +++ b/internal/agent/transport.go @@ -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") } diff --git a/internal/agent/versions_test.go b/internal/agent/versions_test.go index f5c1cd8..8191cc5 100644 --- a/internal/agent/versions_test.go +++ b/internal/agent/versions_test.go @@ -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) + } +} diff --git a/internal/frp/agent_client.go b/internal/frp/agent_client.go index 7c31bb9..080f68e 100644 --- a/internal/frp/agent_client.go +++ b/internal/frp/agent_client.go @@ -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 { diff --git a/internal/frp/agentbin/fonu-agent-linux-amd64 b/internal/frp/agentbin/fonu-agent-linux-amd64 index ef56538..e3a1468 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-amd64 and b/internal/frp/agentbin/fonu-agent-linux-amd64 differ diff --git a/internal/frp/agentbin/fonu-agent-linux-arm64 b/internal/frp/agentbin/fonu-agent-linux-arm64 index 32d3d98..fc5d625 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-arm64 and b/internal/frp/agentbin/fonu-agent-linux-arm64 differ diff --git a/internal/frp/service.go b/internal/frp/service.go index b4ebe24..c749ef7 100644 --- a/internal/frp/service.go +++ b/internal/frp/service.go @@ -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 { diff --git a/web/package-lock.json b/web/package-lock.json index 46bfadd..87fa6d7 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "fonu-web", - "version": "1.4.5", + "version": "1.4.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fonu-web", - "version": "1.4.5", + "version": "1.4.6", "dependencies": { "@codemirror/commands": "^6.11.1", "@codemirror/lang-javascript": "^6.2.5", diff --git a/web/package.json b/web/package.json index 9733b7f..cfa781c 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "fonu-web", "private": true, - "version": "1.4.5", + "version": "1.4.6", "packageManager": "npm@10.9.4", "type": "module", "scripts": { diff --git a/web/src/views/ExampleView.vue b/web/src/views/ExampleView.vue index 91e1ae3..ada3d91 100644 --- a/web/src/views/ExampleView.vue +++ b/web/src/views/ExampleView.vue @@ -8,7 +8,7 @@ - + 当前页面由 v{{ version }} 提供,验证标识为 {{ verificationMarker }}。 如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。 @@ -19,7 +19,7 @@ {{ verificationRound }}
- 看到这个新区块,说明前端资源已经从旧版本切换到 v1.4.5。 + 看到这个新区块,说明前端资源已经从旧版本切换到 v1.4.6。
@@ -33,16 +33,16 @@ - +
    -
  • 页面版本提示更新为 v1.4.5。
  • -
  • 「本次升级验证」区块和页面状态同步更新为 v1.4.5。
  • -
  • 验证标识更新为 v1.4.5-example-page。
  • +
  • 页面版本提示更新为 v1.4.6。
  • +
  • 「本次升级验证」区块和页面状态同步更新为 v1.4.6。
  • +
  • 验证标识更新为 v1.4.6-example-page。
@@ -87,8 +87,8 @@ const message = useMessage() const version = ref(__APP_VERSION__) const checkedAt = ref('') const refreshing = ref(false) -const verificationRound = 'v1.4.5' -const verificationMarker = 'v1.4.5-example-page' +const verificationRound = 'v1.4.6' +const verificationMarker = 'v1.4.6-example-page' async function refreshVersion() { refreshing.value = true