diff --git a/backend/internal/dnsprovider/aliyun_authorization.go b/backend/internal/dnsprovider/aliyun_authorization.go index 938f4e4..d09e2d4 100644 --- a/backend/internal/dnsprovider/aliyun_authorization.go +++ b/backend/internal/dnsprovider/aliyun_authorization.go @@ -298,6 +298,10 @@ func dereferenceString(value *string) string { // DiagnoseAliyunError 对阿里云 API 返回的错误进行分类并提供明确的修复指导。 func DiagnoseAliyunError(err error, domainName string) string { + var reconciliation *aliyunReconciliationError + if errors.As(err, &reconciliation) { + return fmt.Sprintf("阿里云授权探测创建响应异常,临时 TXT 记录 %s 的清理尚未确认,请核对该记录;本次权限验证未通过", reconciliation.recordName) + } if err == nil { return "" } diff --git a/backend/internal/dnsprovider/aliyun_provider.go b/backend/internal/dnsprovider/aliyun_provider.go index 9096cf6..ff80c77 100644 --- a/backend/internal/dnsprovider/aliyun_provider.go +++ b/backend/internal/dnsprovider/aliyun_provider.go @@ -36,7 +36,7 @@ func (a aliyunRecords) Add(ctx context.Context, zone, rr, value string) (string, SetType("TXT").SetValue(value).SetTTL(aliyunAuthorizationTTL) response, err := client.AddDomainRecordWithContext(ctx, a.client, request, &dara.RuntimeOptions{}) if err != nil { - return "", err + return "", a.reconcileAuthorizationAdd(ctx, zone, rr, value, err) } if response == nil || response.Body == nil || response.Body.RecordId == nil || strings.TrimSpace(*response.Body.RecordId) == "" { return "", errors.New("阿里云未返回新增记录 ID,状态未确认,请人工核对本次记录") @@ -56,7 +56,7 @@ func (a aliyunRecords) Describe(ctx context.Context, zone, rr, recordID string) ctx, a.client, new(client.DescribeDomainRecordsRequest).SetDomainName(zone).SetRRKeyWord(rr). - SetTypeKeyWord("TXT").SetSearchMode("EXACT").SetPageNumber(1).SetPageSize(100), + SetTypeKeyWord("TXT").SetSearchMode("COMBINATION").SetPageNumber(1).SetPageSize(100), &dara.RuntimeOptions{}, ) if err != nil { diff --git a/backend/internal/dnsprovider/aliyun_reconcile.go b/backend/internal/dnsprovider/aliyun_reconcile.go new file mode 100644 index 0000000..f830d48 --- /dev/null +++ b/backend/internal/dnsprovider/aliyun_reconcile.go @@ -0,0 +1,73 @@ +package dnsprovider + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "strings" + "time" + + "github.com/alibabacloud-go/tea/dara" + "github.com/go-acme/alidns-20150109/v5/client" +) + +const aliyunReconciliationTimeout = 10 * time.Second + +type aliyunReconciliationError struct { + error + recordName string +} + +// 创建响应丢失不等于云端未写入;仅核对本次随机授权探测记录,不重试创建。 +// ACME 固定主机记录可能被其他挑战共享,不能使用此补偿入口删除。 +func (a aliyunRecords) reconcileAuthorizationAdd(ctx context.Context, zone, rr, value string, cause error) error { + var networkError net.Error + uncertain := errors.Is(cause, context.DeadlineExceeded) || errors.Is(cause, context.Canceled) || + errors.Is(cause, io.EOF) || errors.Is(cause, io.ErrUnexpectedEOF) || errors.As(cause, &networkError) + if !uncertain || !strings.HasPrefix(rr, "_scert-authorization-") { + return cause + } + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), aliyunReconciliationTimeout) + defer cancel() + err := a.cleanupUncertainAuthorizationRecord(cleanupCtx, zone, rr, value) + if err != nil { + err = &aliyunReconciliationError{error: err, recordName: rr + "." + zone} + return errors.Join(cause, fmt.Errorf("授权探测创建结果不确定,记录 %s.%s 需要核对:%w", rr, zone, err)) + } + // 即使补偿成功,本次权限验证仍然失败,不能将网络异常转成已授权。 + return cause +} + +func (a aliyunRecords) cleanupUncertainAuthorizationRecord(ctx context.Context, zone, rr, value string) error { + response, err := client.DescribeDomainRecordsWithContext(ctx, a.client, + new(client.DescribeDomainRecordsRequest).SetDomainName(zone).SetRRKeyWord(rr). + SetTypeKeyWord("TXT").SetValueKeyWord(value).SetSearchMode("COMBINATION"). + SetPageNumber(1).SetPageSize(100), &dara.RuntimeOptions{}) + if err != nil { + return err + } + if response == nil || response.Body == nil || response.Body.DomainRecords == nil || response.Body.TotalCount == nil { + return errors.New("云端未返回完整的记录核对结果") + } + records := response.Body.DomainRecords.Record + if *response.Body.TotalCount != int64(len(records)) || len(records) > 1 { + return errors.New("核对结果不唯一或分页不完整,已拒绝删除") + } + if len(records) == 0 { + return errors.New("暂未发现记录,不能排除云端延迟写入,请稍后核对") + } + record := records[0] + if record == nil { + return errors.New("核对返回空记录,已拒绝删除") + } + info := aliyunRecordInfo{ + RecordID: dereferenceString(record.RecordId), DomainName: dereferenceString(record.DomainName), + RR: dereferenceString(record.RR), Type: dereferenceString(record.Type), Value: dereferenceString(record.Value), + } + if strings.TrimSpace(info.RecordID) == "" || !matchesAliyunAuthorizationRecord(info, info.RecordID, zone, rr, value) { + return errors.New("核对记录与本次随机授权探测不一致,已拒绝删除") + } + return a.Delete(ctx, info.RecordID) +} diff --git a/backend/internal/dnsprovider/aliyun_reconcile_test.go b/backend/internal/dnsprovider/aliyun_reconcile_test.go new file mode 100644 index 0000000..128aca3 --- /dev/null +++ b/backend/internal/dnsprovider/aliyun_reconcile_test.go @@ -0,0 +1,150 @@ +package dnsprovider + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" + "github.com/alibabacloud-go/tea/dara" + "github.com/go-acme/alidns-20150109/v5/client" +) + +func TestAliyunAuthorizationLostAddResponse(t *testing.T) { + for _, scenario := range []string{"cleanup", "wrong_value", "wrong_zone", "wrong_rr", "wrong_type", "empty_id", "multiple", "missing", "incomplete", "query_error", "delete_error"} { + t.Run(scenario, func(t *testing.T) { + var mu sync.Mutex + var actions []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + action := r.Header.Get("x-acs-action") + mu.Lock() + actions = append(actions, action) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + switch action { + case "AddDomainRecord": + // 模拟云端已经接受写入,但创建响应在传输中丢失。 + connection, _, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + _ = connection.Close() + case "DescribeDomainRecords": + for key, expected := range map[string]string{ + "DomainName": "example.com", "RRKeyWord": "_scert-authorization-unique.web", + "TypeKeyWord": "TXT", "ValueKeyWord": "unique-proof", "SearchMode": "COMBINATION", + } { + if r.Form.Get(key) != expected { + t.Errorf("补偿查询未限定本次记录:%s", key) + } + } + if scenario == "query_error" { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"Code":"Forbidden.RAM","Message":"denied"}`)) + return + } + record := map[string]string{"RecordId": "owned-id", "DomainName": "example.com", "RR": "_scert-authorization-unique.web", "Type": "TXT", "Value": "unique-proof"} + switch scenario { + case "wrong_value": + record["Value"] = "other-proof" + case "wrong_zone": + record["DomainName"] = "other.com" + case "wrong_rr": + record["RR"] = "other" + case "wrong_type": + record["Type"] = "A" + case "empty_id": + record["RecordId"] = "" + } + records := []map[string]string{record} + if scenario == "multiple" { + records = append(records, record) + } else if scenario == "missing" { + records = nil + } + total := len(records) + if scenario == "incomplete" { + total++ + } + _ = json.NewEncoder(w).Encode(map[string]any{"TotalCount": total, "DomainRecords": map[string]any{"Record": records}}) + case "DeleteDomainRecord": + if r.Form.Get("RecordId") != "owned-id" { + t.Error("不得删除其他记录") + } + if scenario == "delete_error" { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"Code":"Forbidden.RAM","Message":"denied"}`)) + return + } + _, _ = w.Write([]byte(`{"RecordId":"owned-id"}`)) + default: + t.Errorf("不允许的云端操作:%s", action) + } + })) + defer server.Close() + sdk, err := client.NewClient(new(openapi.Config).SetAccessKeyId("test-id").SetAccessKeySecret("test-secret"). + SetEndpoint(strings.TrimPrefix(server.URL, "http://")).SetProtocol("http"). + SetRetryOptions(&dara.RetryOptions{Retryable: false, MaxAttempts: 1})) + if err != nil { + t.Fatal(err) + } + id, err := (aliyunRecords{client: sdk}).Add(t.Context(), "example.com", "_scert-authorization-unique.web", "unique-proof") + if err == nil || id != "" { + t.Fatal("创建结果不确定不能变成验证成功") + } + if scenario != "cleanup" && !strings.Contains(err.Error(), "需要核对") { + t.Fatalf("补偿未完成必须保留人工核对信息:%v", err) + } + if scenario != "cleanup" && !strings.Contains(DiagnoseAliyunError(err, "example.com"), "_scert-authorization-unique.web.example.com") { + t.Fatal("页面诊断必须保留待核对记录,不能只显示通用超时") + } + mu.Lock() + defer mu.Unlock() + want := "AddDomainRecord,DescribeDomainRecords" + if scenario == "cleanup" || scenario == "delete_error" { + want += ",DeleteDomainRecord" + } + if strings.Join(actions, ",") != want { + t.Fatalf("云端操作越界或自动重试创建:%v", actions) + } + }) + } +} + +func TestAliyunReconcilePreservesCancellationAndExclusions(t *testing.T) { + api := aliyunRecords{} + denied := errors.New("Forbidden.RAM") + if got := api.reconcileAuthorizationAdd(t.Context(), "example.com", "_scert-authorization-unique", "proof", denied); got != denied { + t.Fatal("明确拒绝不应触发补偿查询") + } + if got := api.reconcileAuthorizationAdd(t.Context(), "example.com", "_acme-challenge", "proof", context.DeadlineExceeded); got != context.DeadlineExceeded { + t.Fatal("不得通过授权探测补偿删除共享 ACME 记录") + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"TotalCount":0,"DomainRecords":{"Record":[]}}`)) + })) + defer server.Close() + sdk, err := client.NewClient(new(openapi.Config).SetAccessKeyId("test-id").SetAccessKeySecret("test-secret").SetEndpoint(strings.TrimPrefix(server.URL, "http://")).SetProtocol("http")) + if err != nil { + t.Fatal(err) + } + api.client = sdk + started := time.Now() + err = api.reconcileAuthorizationAdd(ctx, "example.com", "_scert-authorization-unique", "proof", context.Canceled) + if !errors.Is(err, context.Canceled) || calls != 1 || time.Since(started) > aliyunReconciliationTimeout { + t.Fatalf("补偿应独立于取消且保持时限和原始错误:calls=%d err=%v", calls, err) + } +} diff --git a/backend/internal/dnsprovider/aliyun_records_test.go b/backend/internal/dnsprovider/aliyun_records_test.go index 8f721b7..77b254b 100644 --- a/backend/internal/dnsprovider/aliyun_records_test.go +++ b/backend/internal/dnsprovider/aliyun_records_test.go @@ -30,7 +30,7 @@ func TestAliyunRecordsDescribeUsesScopedExactQuery(t *testing.T) { for key, expected := range map[string]string{ "DomainName": "example.com", "RRKeyWord": "_scert-authorization-mixed.web", "TypeKeyWord": "TXT", - "SearchMode": "EXACT", "PageNumber": "1", "PageSize": "100", + "SearchMode": "COMBINATION", "PageNumber": "1", "PageSize": "100", } { if got := r.Form.Get(key); got != expected { t.Errorf("查询范围错误:%s=%q,预期 %q", key, got, expected) diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index df71665..594a456 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,4 +1,16 @@ # Linux 生产流程测试记录 +## 最新实机记录(2026-09-30 19:43,授权探测及测试子域准备) + +本轮继续保留现场的升级针对性复测,不是清库首次初始化验收。线上源码仍为 7147532,尚未部署本轮 DNS 修复。 + +- [x] 19:25:28 页面创建域名失败,HTTP 400,耗时 2661 毫秒,报权限验证超时。 +- [x] 19:30:54 产品正式命令 dns check 在 scert-20260930.apmmk.cn 返回 writable/readable/cleanup/verified 均为 true。 +- [x] 19:32:01 正式页面创建域名 HTTP 201,耗时 2395 毫秒;域名 ID dfcd80c0-1c58-4aa2-b156-d558d4d2b9a1,关联既有服务器 HTTP 200。 +- [x] 实机发现初次请求遗留随机授权 TXT,创建时间与失败请求一致;精确核对后删除该测试记录,不修改其他 TXT。 +- [x] 云端实测确认 COMBINATION 按 RR 和类型过滤,EXACT 忽略这两个参数;本地 SDK 文档同样明确该语义。 +- [x] 通过阿里云官方 CLI 为新子域新增 A 记录;只修改明确授权测试子域,保留原有泛解析。CLI 来自官方发布并验证校验和,不是自建辅助验收脚本;临时凭据只在会话环境使用。 +- [~] 真实证书尚未签发,本轮目录创建、文件推送、Nginx 和公网 HTTPS 未通过,不能标记完整生产验收成功。 + ## 最新实机结果(2026-09-30 19:20,SSH 自动部署通过一次复测) ### 候选身份与部署 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index fd32597..512ca5a 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,4 +1,15 @@ # Scert 整改任务执行清单 +## 最新执行记录(2026-09-30 19:43,DNS 超时现场与补偿) + +需求确认:精确限定授权域、临时 TXT 必须可核对清理、不得绕过权限验证、真实证书推送继续推进。基线提交 7d059e0 已推送。 + +- [x] 实机排查:系统 DNS/SOA 与阿里云 HTTPS 可达;产品权限检查闭环通过,页面新域名保存成功。 +- [x] 缺陷整改:纠正查询搜索模式;新增创建响应丢失后的独立取消上下文补偿,最长十秒,不自动重试创建。 +- [x] 专项回归:响应丢失、错误名称/值/托管区/类型、空 ID、多条结果、不完整分页、未发现记录、查询失败、删除失败、取消、明确拒绝和共享 ACME 排除;页面保留待核对记录提示。 +- [~] 最终全量检查与新正式包部署;实机补偿功能尚未复审,不替代本地测试。 +- [~] 新子域独立解析准备完成,下一步签发、目录创建、推送、校验重载和公网 TLS。 +- [ ] 回滚、多服务器、三数据库及完整首次初始化门禁仍未完成;总体验收仍为 P0-03 完成待复审。 + ## 最新执行记录(2026-09-30 19:20,SSH 自动部署阻塞已通过一次实机复测) - [x] 两批修复已中文提交推送;7147532 为实际部署源码提交。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index f8ddaa1..9c9fd72 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,4 +1,17 @@ # Scert 整改、合并与优化清单 +## 最新进度(2026-09-30 19:43,阿里云精确查询与超时补偿整改) + +本轮沿用已确认需求,继续真实证书部署闭环。总体验收仍为 P0-03 完成待复审;以下局部进展不代表生产放行。 + +- [x] 产品自带权限检查已在授权测试子域完成创建、读取、删除闭环;正式页面保存域名返回 201,关联既有服务器返回 200。 +- [x] 实机确认首次超时遗留一条授权探测 TXT;精确核对本次名称、值及记录 ID 后已执行清理,不扩大删除范围。 +- [x] 定位查询参数错误:EXACT 模式忽略 RRKeyWord、TypeKeyWord;改用 COMBINATION,并补充回归。证据来自当前 SDK 参数定义及云端两种模式的实际返回。 +- [x] 创建响应丢失时增加有界补偿:只查本次随机授权 TXT,核对唯一 ID、托管区、名称、类型、值后精确删除;不重试创建、不触碰共享 ACME 记录、不将补偿成功视为授权成功。 +- [x] 未发现记录、查询不完整、内容不匹配或清理失败时保留明确待核对提示;尚不能排除云端延迟写入,不声称零遗留。 +- [~] 最终全量回归、干净候选构建和远端部署待完成;当前线上仍为上一批 SSH 修复版本。 +- [~] 已为新测试子域创建独立 A 记录,原有泛解析未修改;证书签发、Nginx 消费端和公网 HTTPS 闭环仍待执行。 +- [ ] 后续仍包括失败回滚、多域名复用服务器、第二台真实服务器、三数据库及清库首次初始化完整验收。 + ## 最新进度(2026-09-30 19:20,自动 SSH 公钥部署实机通过,整体验收未通过) **总体验收仍为 P0-03:完成,待复审。** 当前 SSH 阻塞已获得一次真实生产页面复测成功证据,不得扩大为证书推送、多服务器或全部生产门禁通过。下方同日较早的“尚未部署”“竞态运行中”为历史快照。