diff --git a/docker-compose.yaml b/docker-compose.yaml index 1863f10b..1f695df7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -105,7 +105,7 @@ services: - ./data/agent/:/data environment: OPENFLARE_SERVER_URL: "http://host.docker.internal:3000" - OPENFLARE_AGENT_TOKEN: "6ef051dba8b1b7c7e8a4b5dc1b138593" + OPENFLARE_AGENT_TOKEN: "3f7b66c9329a0cf253e71a86681223aa" LOG_LEVEL: "debug" extra_hosts: - "host.docker.internal:host-gateway" diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 46c0359b..bb1ef1b2 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -23,6 +23,7 @@ sidebar: false ### 修复 +- 修复配置版本发布到 Agent 后 `openresty -t` 因证书私钥无法解析而失败的问题。根因是发布快照生成 `certs/{id}.key` 时直接写入库内加密的 `KeyPEM`(`enc:v1:`),未解密为 PEM;现与证书详情接口一致,发布前通过 `OpenKeyPEM` 解密后再下发。 - 修复 `/api/v1/d/option` 批量更新 OpenResty 等业务配置不生效的问题。根本原因是 option 模块在读写时做了 PascalCase 与 snake_case 的机械转换(如 `OpenRestyEventsUse` → `open_resty_events_use`),与 `w_system_configs` 中实际 key(`openresty_events_use`)不一致,更新写入了错误的幽灵配置行。现改为 API 直接使用与数据库一致的 snake_case key,并同步更新前端性能调优与运维设置页。 - 修复 PostgreSQL 数据库执行迁移时报 `duplicate key value violates unique constraint "goose_db_version_pkey"` 导致迁移中断的问题。根本原因:`goose_db_version.id` 自增序列落后于表内 `MAX(id)`(常见于从 dump 恢复或历史迁移以显式 id 复制版本记录后),goose 记录新版本号时自增 id 与既有行冲突。修复方式:在 `goose.Up` 前对 PostgreSQL 执行 `setval` 重新对齐 `goose_db_version` 的 id 序列。 - 修复 openflared(Tunnel Client)WebSocket 连接在 Cloudflare 代理环境下频繁收到 EOF 断连的问题。根本原因:服务端 `read_pump` 仅在收到 WebSocket 协议层 Pong 帧时刷新读超时,而客户端(`golang.org/x/net/websocket`)以 JSON 应用层 `{"type":"pong"}` 响应 ping,服务端 90s 读超时到期后主动关闭连接,客户端收到 EOF 并进入无限重连循环。修复方式:在 `clientPongType` 分支中同步调用 `conn.SetReadDeadline` 刷新超时。 diff --git a/internal/apps/openflare/config_version/certificate_snapshot_test.go b/internal/apps/openflare/config_version/certificate_snapshot_test.go new file mode 100644 index 00000000..ffb4aeaa --- /dev/null +++ b/internal/apps/openflare/config_version/certificate_snapshot_test.go @@ -0,0 +1,77 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package config_version + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "strings" + "testing" + "time" + + oftls "github.com/Rain-kl/Wavelet/internal/apps/openflare/tls" + "github.com/Rain-kl/Wavelet/internal/config" + "github.com/Rain-kl/Wavelet/internal/db" + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildCertificateSupportFilesDecryptsSealedPrivateKey(t *testing.T) { + cleanup := setupConfigVersionTestDB(t) + defer cleanup() + require.NoError(t, db.DB(context.Background()).AutoMigrate(&model.TLSCertificate{})) + + oldSecret := config.Config.App.SessionSecret + config.Config.App.SessionSecret = "test-session-secret-for-tls-seal" + t.Cleanup(func() { config.Config.App.SessionSecret = oldSecret }) + + ctx := context.Background() + certPEM, keyPEM := generateTestCertKeyPairForSnapshot(t) + certificate, err := oftls.CreateCertificate(ctx, oftls.CertificateInput{ + Name: "publish-cert", + CertPEM: certPEM, + KeyPEM: keyPEM, + }) + require.NoError(t, err) + + files, err := buildCertificateSupportFiles(ctx, []snapshotRoute{ + {CertIDs: []uint{certificate.ID}}, + }) + require.NoError(t, err) + require.Len(t, files, 2) + + var keyContent string + for _, file := range files { + if file.Path == certificateKeyFileName(certificate.ID) { + keyContent = file.Content + } + assert.NotContains(t, file.Content, "enc:v1:") + } + assert.Contains(t, keyContent, "BEGIN") + assert.Equal(t, normalizePEM(strings.TrimSpace(keyPEM)), keyContent) +} + +func generateTestCertKeyPairForSnapshot(t *testing.T) (certPEM string, keyPEM string) { + t.Helper() + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test.example.com"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + } + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + require.NoError(t, err) + certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) + keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})) + return certPEM, keyPEM +} diff --git a/internal/apps/openflare/config_version/snapshot.go b/internal/apps/openflare/config_version/snapshot.go index 297f4627..5db49239 100644 --- a/internal/apps/openflare/config_version/snapshot.go +++ b/internal/apps/openflare/config_version/snapshot.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/Rain-kl/Wavelet/internal/apps/openflare/routeidentity" + oftls "github.com/Rain-kl/Wavelet/internal/apps/openflare/tls" "github.com/Rain-kl/Wavelet/internal/apps/openflare/waf" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/repository" @@ -538,11 +539,19 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot { func buildCertificateSupportFiles(ctx context.Context, routes []snapshotRoute) ([]SupportFile, error) { certIDSet := make(map[uint]struct{}) for _, route := range routes { + if route.CertID != nil && *route.CertID != 0 { + certIDSet[*route.CertID] = struct{}{} + } for _, certID := range route.CertIDs { if certID != 0 { certIDSet[certID] = struct{}{} } } + for _, certID := range route.DomainCertIDs { + if certID != 0 { + certIDSet[certID] = struct{}{} + } + } } if len(certIDSet) == 0 { return nil, nil @@ -558,9 +567,16 @@ func buildCertificateSupportFiles(ctx context.Context, routes []snapshotRoute) ( if err != nil { return nil, err } + keyPEM, err := oftls.OpenKeyPEM(certificate.KeyPEM) + if err != nil { + return nil, fmt.Errorf("certificate %d private key: %w", certificate.ID, err) + } + if strings.TrimSpace(keyPEM) == "" { + return nil, fmt.Errorf("certificate %d has no private key", certificate.ID) + } files = append(files, SupportFile{Path: certificateCertFileName(certificate.ID), Content: normalizePEM(certificate.CertPEM)}, - SupportFile{Path: certificateKeyFileName(certificate.ID), Content: normalizePEM(certificate.KeyPEM)}, + SupportFile{Path: certificateKeyFileName(certificate.ID), Content: normalizePEM(keyPEM)}, ) } return dedupeSupportFiles(files), nil diff --git a/internal/apps/openflare/tls/sensitive.go b/internal/apps/openflare/tls/sensitive.go index 2a488ea4..83a336d0 100644 --- a/internal/apps/openflare/tls/sensitive.go +++ b/internal/apps/openflare/tls/sensitive.go @@ -39,6 +39,11 @@ func sealSensitive(plaintext string) (string, error) { return sensitiveValuePrefix + encrypted, nil } +// OpenKeyPEM decrypts a stored certificate private key for runtime distribution. +func OpenKeyPEM(stored string) (string, error) { + return openSensitive(stored) +} + func openSensitive(stored string) (string, error) { stored = strings.TrimSpace(stored) if stored == "" {