修复阿里云精确查询并补偿授权探测响应丢失

This commit is contained in:
xuans 2026-09-30 19:44:38 +08:00
parent 7d059e0196
commit 0bb427d5f8
8 changed files with 266 additions and 3 deletions

View File

@ -298,6 +298,10 @@ func dereferenceString(value *string) string {
// DiagnoseAliyunError 对阿里云 API 返回的错误进行分类并提供明确的修复指导。 // DiagnoseAliyunError 对阿里云 API 返回的错误进行分类并提供明确的修复指导。
func DiagnoseAliyunError(err error, domainName string) string { 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 { if err == nil {
return "" return ""
} }

View File

@ -36,7 +36,7 @@ func (a aliyunRecords) Add(ctx context.Context, zone, rr, value string) (string,
SetType("TXT").SetValue(value).SetTTL(aliyunAuthorizationTTL) SetType("TXT").SetValue(value).SetTTL(aliyunAuthorizationTTL)
response, err := client.AddDomainRecordWithContext(ctx, a.client, request, &dara.RuntimeOptions{}) response, err := client.AddDomainRecordWithContext(ctx, a.client, request, &dara.RuntimeOptions{})
if err != nil { 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) == "" { if response == nil || response.Body == nil || response.Body.RecordId == nil || strings.TrimSpace(*response.Body.RecordId) == "" {
return "", errors.New("阿里云未返回新增记录 ID,状态未确认,请人工核对本次记录") return "", errors.New("阿里云未返回新增记录 ID,状态未确认,请人工核对本次记录")
@ -56,7 +56,7 @@ func (a aliyunRecords) Describe(ctx context.Context, zone, rr, recordID string)
ctx, ctx,
a.client, a.client,
new(client.DescribeDomainRecordsRequest).SetDomainName(zone).SetRRKeyWord(rr). 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{}, &dara.RuntimeOptions{},
) )
if err != nil { if err != nil {

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -30,7 +30,7 @@ func TestAliyunRecordsDescribeUsesScopedExactQuery(t *testing.T) {
for key, expected := range map[string]string{ for key, expected := range map[string]string{
"DomainName": "example.com", "DomainName": "example.com",
"RRKeyWord": "_scert-authorization-mixed.web", "TypeKeyWord": "TXT", "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 { if got := r.Form.Get(key); got != expected {
t.Errorf("查询范围错误:%s=%q,预期 %q", key, got, expected) t.Errorf("查询范围错误:%s=%q,预期 %q", key, got, expected)

View File

@ -1,4 +1,16 @@
# Linux 生产流程测试记录 # 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 自动部署通过一次复测) ## 最新实机结果(2026-09-30 19:20,SSH 自动部署通过一次复测)
### 候选身份与部署 ### 候选身份与部署

View File

@ -1,4 +1,15 @@
# Scert 整改任务执行清单 # 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 自动部署阻塞已通过一次实机复测) ## 最新执行记录(2026-09-30 19:20,SSH 自动部署阻塞已通过一次实机复测)
- [x] 两批修复已中文提交推送;7147532 为实际部署源码提交。 - [x] 两批修复已中文提交推送;7147532 为实际部署源码提交。

View File

@ -1,4 +1,17 @@
# Scert 整改、合并与优化清单 # 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 公钥部署实机通过,整体验收未通过) ## 最新进度(2026-09-30 19:20,自动 SSH 公钥部署实机通过,整体验收未通过)
**总体验收仍为 P0-03:完成,待复审。** 当前 SSH 阻塞已获得一次真实生产页面复测成功证据,不得扩大为证书推送、多服务器或全部生产门禁通过。下方同日较早的“尚未部署”“竞态运行中”为历史快照。 **总体验收仍为 P0-03:完成,待复审。** 当前 SSH 阻塞已获得一次真实生产页面复测成功证据,不得扩大为证书推送、多服务器或全部生产门禁通过。下方同日较早的“尚未部署”“竞态运行中”为历史快照。