diff --git a/backend/internal/certificate/lego_http_protocol_test.go b/backend/internal/certificate/lego_http_protocol_test.go new file mode 100644 index 0000000..416c7c2 --- /dev/null +++ b/backend/internal/certificate/lego_http_protocol_test.go @@ -0,0 +1,87 @@ +package certificate + +import ( + "context" + "crypto/x509" + "net/http" + "slices" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/httpchallenge" +) + +// 使用真实 lego 客户端、签名请求、挑战服务和动态 CSR 证书,不替换签发器。 +// 隔离 CA 只验证本产品用到的协议流程,不代表公共 CA 或公网签发验收。 +func TestLegoHTTP01Protocol(t *testing.T) { + for _, item := range []struct { + name string + domains []string + environment string + reject bool + cancel bool + }{ + {name: "single", domains: []string{"example.com"}, environment: "production"}, + {name: "san", domains: []string{"example.com", "www.example.com"}, environment: "staging"}, + {name: "rejected", domains: []string{"example.com", "www.example.com"}, reject: true}, + {name: "canceled", domains: []string{"example.com"}, cancel: true}, + } { + t.Run(item.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + store := httpchallenge.NewStore() + lease, err := store.NewLease(ctx, item.domains) + if err != nil { + t.Fatal(err) + } + defer lease.Close() + ca := newProtocolCA(t, store, item.domains) + ca.reject = item.reject + if item.cancel { + ca.cancel = cancel + } + issuer := &LegoIssuer{User: ca.user, Provider: lease, HTTPClient: ca.client} + resource, err := issuer.Obtain(ctx, ObtainRequest{ + Domains: item.domains, ChallengeType: domain.ChallengeHTTP01, Environment: item.environment, + }) + if item.reject || item.cancel { + if err == nil || len(resource.Certificate) != 0 || len(resource.PrivateKey) != 0 { + t.Fatal("失败或取消的 ACME 订单返回了成功证书") + } + } else { + if err != nil { + t.Fatal(err) + } + if _, err := ValidateResourceForDomains(resource, item.domains); err != nil { + t.Fatal("实际 CSR 证书或密钥不匹配:", err) + } + roots := x509.NewCertPool() + roots.AddCert(ca.root) + validator := LocalValidator{Roots: roots, VerifyChain: true} + if _, err := validator.ValidatePEM(ctx, resource.Certificate, item.domains); err != nil { + t.Fatal("隔离 CA 返回的证书链校验失败:", err) + } + } + ca.mu.Lock() + defer ca.mu.Unlock() + if !ca.registered || ca.proofs == 0 || len(ca.failures) != 0 { + t.Fatalf("协议没有按预期完成:注册=%t,挑战=%d,异常=%v", ca.registered, ca.proofs, ca.failures) + } + if item.reject || item.cancel { + if ca.finalized || ca.downloads != 0 { + t.Fatal("失败订单不应提交 CSR 或下载证书") + } + } else if !ca.finalized || ca.downloads != 1 || ca.proofs != len(item.domains) || !slices.Equal(ca.csrNames, item.domains) { + t.Fatal("成功订单缺少逐域名授权、CSR 或证书下载") + } + // 在调用 Lease.Close 之前核对,避免测试清理掩盖 lego 未回收挑战。 + for i, name := range item.domains { + code, _, err := ca.readProof(context.Background(), name, protocolToken(i)) + if err != nil || code != http.StatusNotFound { + t.Fatalf("订单结束后挑战仍可读:域名=%s,状态=%d,错误=%v", name, code, err) + } + } + }) + } +} diff --git a/backend/internal/certificate/lego_protocol_fixture_test.go b/backend/internal/certificate/lego_protocol_fixture_test.go new file mode 100644 index 0000000..ed38079 --- /dev/null +++ b/backend/internal/certificate/lego_protocol_fixture_test.go @@ -0,0 +1,375 @@ +package certificate + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strconv" + "strings" + "sync" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/httpchallenge" + "github.com/go-jose/go-jose/v4" +) + +type protocolTransport struct { + base http.RoundTripper + target *url.URL +} + +func (p protocolTransport) RoundTrip(r *http.Request) (*http.Response, error) { + if r.URL.Scheme == "https" && r.URL.Path == "/directory" && + (r.URL.Host == "acme-v02.api.letsencrypt.org" || r.URL.Host == "acme-staging-v02.api.letsencrypt.org") { + r = r.Clone(r.Context()) + target := *p.target + target.Path = "/directory" + r.URL = &target + r.Host = target.Host + } + if r.URL.Scheme != p.target.Scheme || r.URL.Host != p.target.Host { + return nil, fmt.Errorf("隔离协议测试禁止访问外部地址") + } + return p.base.RoundTrip(r) +} + +type protocolCA struct { + mu sync.Mutex + server *httptest.Server + challenge *httptest.Server + client *http.Client + user *AccountUser + root *x509.Certificate + key *ecdsa.PrivateKey + names []string + statuses []string + nonces map[string]bool + sequence int + registered bool + finalized bool + downloads int + proofs int + csrNames []string + chain []byte + reject bool + cancel context.CancelFunc + failures []string +} + +func newProtocolCA(t *testing.T, store *httpchallenge.Store, names []string) *protocolCA { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + accountKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Scert protocol test CA"}, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), + IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + root, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + c := &protocolCA{ + key: key, root: root, names: slices.Clone(names), nonces: make(map[string]bool), + user: &AccountUser{Email: "acme@example.com", PrivateKey: accountKey}, + statuses: make([]string, len(names)), + } + for i := range c.statuses { + c.statuses[i] = "pending" + } + c.challenge = httptest.NewServer(store) + t.Cleanup(c.challenge.Close) + c.server = httptest.NewTLSServer(http.HandlerFunc(c.serveHTTP)) + t.Cleanup(c.server.Close) + target, err := url.Parse(c.server.URL) + if err != nil { + t.Fatal(err) + } + // 仅注入测试 CA 信任,不跳过 TLS;真实签发器的生产/测试目录选择不变。 + c.client = c.server.Client() + c.client.Timeout = 5 * time.Second + c.client.Transport = protocolTransport{base: c.client.Transport, target: target} + return c +} + +func protocolToken(index int) string { + return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("scert-isolated-acme-token-%d", index))) +} + +func (c *protocolCA) fail(w http.ResponseWriter, err error) { + c.failures = append(c.failures, err.Error()) + http.Error(w, "隔离 CA 协议断言失败", http.StatusBadRequest) +} + +func (c *protocolCA) signedPayload(r *http.Request) ([]byte, error) { + if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/jose+json" { + return nil, fmt.Errorf("请求方法或签名媒体类型不正确") + } + data, err := io.ReadAll(io.LimitReader(r.Body, 64*1024)) + if err != nil { + return nil, err + } + jws, err := jose.ParseSigned(string(data), []jose.SignatureAlgorithm{jose.ES256}) + if err != nil || len(jws.Signatures) != 1 { + return nil, fmt.Errorf("请求缺少唯一有效签名") + } + header := jws.Signatures[0].Protected + if !c.nonces[header.Nonce] || header.ExtraHeaders[jose.HeaderKey("url")] != c.server.URL+r.URL.Path { + return nil, fmt.Errorf("签名地址或一次性随机数无效") + } + delete(c.nonces, header.Nonce) + if r.URL.Path == "/account" { + if header.JSONWebKey == nil || header.KeyID != "" { + return nil, fmt.Errorf("注册请求缺少账户公钥") + } + } else if !c.registered || header.KeyID != c.server.URL+"/account/1" || header.JSONWebKey != nil { + return nil, fmt.Errorf("请求未引用已注册账户") + } + return jws.Verify(c.user.PrivateKey.Public()) +} + +func (c *protocolCA) order() map[string]any { + status := "ready" + ids := make([]map[string]string, len(c.names)) + auths := make([]string, len(c.names)) + for i, name := range c.names { + ids[i] = map[string]string{"type": "dns", "value": name} + auths[i] = fmt.Sprintf("%s/auth/%d", c.server.URL, i) + if c.statuses[i] != "valid" { + status = "pending" + } + } + if c.finalized { + status = "valid" + } + return map[string]any{"status": status, "identifiers": ids, "authorizations": auths, + "finalize": c.server.URL + "/finalize", "certificate": c.server.URL + "/certificate"} +} + +func (c *protocolCA) authorization(index int) map[string]any { + challenge := map[string]any{ + "type": "http-01", "url": fmt.Sprintf("%s/challenge/%d", c.server.URL, index), + "status": c.statuses[index], "token": protocolToken(index), + } + if c.statuses[index] == "invalid" { + challenge["error"] = map[string]any{"type": "urn:ietf:params:acme:error:unauthorized", "detail": "隔离 CA 拒绝授权", "status": 403} + } + return map[string]any{ + "status": c.statuses[index], "identifier": map[string]string{"type": "dns", "value": c.names[index]}, + "challenges": []any{map[string]string{"type": "dns-01", "url": c.server.URL + "/forbidden-dns", "status": "pending", "token": protocolToken(index)}, challenge}, + } +} + +func (c *protocolCA) readProof(ctx context.Context, name, token string) (int, string, error) { + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.challenge.URL+httpchallenge.PathPrefix+token, nil) + if err != nil { + return 0, "", err + } + req.Host = name + resp, err := c.challenge.Client().Do(req) + if err != nil { + return 0, "", err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return resp.StatusCode, string(data), err +} + +func (c *protocolCA) serveHTTP(w http.ResponseWriter, r *http.Request) { + c.mu.Lock() + defer c.mu.Unlock() + c.sequence++ + nonce := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("scert-isolated-nonce-%d", c.sequence))) + c.nonces[nonce] = true + w.Header().Set("Replay-Nonce", nonce) + w.Header().Set("Content-Type", "application/json") + write := func(value any) { _ = json.NewEncoder(w).Encode(value) } + if r.URL.Path == "/directory" && r.Method == http.MethodGet { + write(map[string]any{"newNonce": c.server.URL + "/nonce", "newAccount": c.server.URL + "/account", "newOrder": c.server.URL + "/order", "revokeCert": c.server.URL + "/revoke", "keyChange": c.server.URL + "/key-change"}) + return + } + if r.URL.Path == "/nonce" && r.Method == http.MethodHead { + w.WriteHeader(http.StatusNoContent) + return + } + payload, err := c.signedPayload(r) + if err != nil { + c.fail(w, err) + return + } + switch r.URL.Path { + case "/account": + var account struct { + Terms bool `json:"termsOfServiceAgreed"` + Contact []string `json:"contact"` + } + if json.Unmarshal(payload, &account) != nil || !account.Terms || !slices.Equal(account.Contact, []string{"mailto:" + c.user.Email}) { + c.fail(w, fmt.Errorf("注册未确认条款或账户邮箱不符")) + return + } + c.registered = true + w.Header().Set("Location", c.server.URL+"/account/1") + w.WriteHeader(http.StatusCreated) + write(map[string]any{"status": "valid", "contact": account.Contact, "orders": c.server.URL + "/orders"}) + case "/order": + var order struct { + Identifiers []struct { + Type string `json:"type"` + Value string `json:"value"` + } `json:"identifiers"` + } + if json.Unmarshal(payload, &order) != nil || len(order.Identifiers) != len(c.names) { + c.fail(w, fmt.Errorf("订单域名数量不符")) + return + } + for i, id := range order.Identifiers { + if id.Type != "dns" || id.Value != c.names[i] { + c.fail(w, fmt.Errorf("订单域名被修改")) + return + } + } + w.Header().Set("Location", c.server.URL+"/order/1") + w.WriteHeader(http.StatusCreated) + write(c.order()) + case "/order/1": + if len(payload) != 0 { + c.fail(w, fmt.Errorf("订单查询不是签名空载荷")) + return + } + write(c.order()) + case "/finalize": + if err := c.finalize(payload); err != nil { + c.fail(w, err) + return + } + write(c.order()) + case "/certificate": + if !c.finalized || len(payload) != 0 { + c.fail(w, fmt.Errorf("证书下载早于订单完成或载荷非空")) + return + } + c.downloads++ + w.Header().Set("Content-Type", "application/pem-certificate-chain") + _, _ = w.Write(c.chain) + default: + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/") + if len(parts) != 2 || (parts[0] != "auth" && parts[0] != "challenge") { + c.fail(w, fmt.Errorf("客户端访问了未授权协议路径:%s", r.URL.Path)) + return + } + index, err := strconv.Atoi(parts[1]) + if err != nil || index < 0 || index >= len(c.names) { + c.fail(w, fmt.Errorf("授权索引无效")) + return + } + if parts[0] == "auth" { + if len(payload) != 0 { + var state struct { + Status string `json:"status"` + } + if json.Unmarshal(payload, &state) != nil || state.Status != "deactivated" { + c.fail(w, fmt.Errorf("授权更新不是合法停用")) + return + } + c.statuses[index] = "deactivated" + } + write(c.authorization(index)) + return + } + if err := c.verifyChallenge(r.Context(), index); err != nil { + c.fail(w, err) + return + } + write(c.authorization(index)["challenges"].([]any)[1]) + } +} + +func (c *protocolCA) verifyChallenge(ctx context.Context, index int) error { + key := jose.JSONWebKey{Key: c.user.PrivateKey.Public()} + thumbprint, err := key.Thumbprint(crypto.SHA256) + if err != nil { + return err + } + code, value, err := c.readProof(ctx, c.names[index], protocolToken(index)) + if err != nil || code != http.StatusOK || value != protocolToken(index)+"."+base64.RawURLEncoding.EncodeToString(thumbprint) { + return fmt.Errorf("真实 HTTP 挑战未返回账户公钥绑定证明:状态=%d", code) + } + c.proofs++ + c.statuses[index] = "valid" + if c.reject { + c.statuses[index] = "invalid" + } + if c.cancel != nil { + c.cancel() + } + return nil +} + +func (c *protocolCA) finalize(payload []byte) error { + for _, status := range c.statuses { + if status != "valid" { + return fmt.Errorf("未完成全部授权就提交 CSR") + } + } + var request struct { + CSR string `json:"csr"` + } + if err := json.Unmarshal(payload, &request); err != nil { + return err + } + der, err := base64.RawURLEncoding.DecodeString(request.CSR) + if err != nil { + return err + } + csr, err := x509.ParseCertificateRequest(der) + if err != nil { + return err + } + if err := csr.CheckSignature(); err != nil { + return err + } + if !slices.Equal(csr.DNSNames, c.names) { + return fmt.Errorf("CSR 的 SAN 与原订单不符") + } + c.csrNames = slices.Clone(csr.DNSNames) + leaf := &x509.Certificate{ + SerialNumber: big.NewInt(2), Subject: csr.Subject, DNSNames: csr.DNSNames, + NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(30 * time.Minute), + KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err = x509.CreateCertificate(rand.Reader, leaf, c.root, csr.PublicKey, c.key) + if err != nil { + return err + } + c.chain = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + c.chain = append(c.chain, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.root.Raw})...) + c.finalized = true + return nil +} diff --git a/doc/HTTP验证部署说明.md b/doc/HTTP验证部署说明.md index 208ee68..b38326b 100644 --- a/doc/HTTP验证部署说明.md +++ b/doc/HTTP验证部署说明.md @@ -31,4 +31,4 @@ 第一台实际文件为 `/etc/nginx/conf.d/scert-odacm.conf` 和 `/etc/nginx/snippets/scert-http-challenge.conf`;仅接入管理域名对应 server,不表示其他托管域名已配置。Nginx reload 后最初请求曾由旧 worker 返回 301,随后重复检查已按新配置返回 404,原观察不隐藏。 -正常公共 DNS 仍指第二台,尚未切换。内部定向请求、独立 Nginx 及迁移专项都不能替代真实 ACME 协议、公网签发和三库首次 Web 验收;这些仍待完成,不关闭 P1-03 或整体验收。 +正常公共 DNS 仍指第二台,尚未切换。真实 lego 客户端已在隔离 CA 完成单域名、SAN、拒绝及取消的协议回归和五轮竞态,验证注册签名、账户绑定挑战、CSR 与证书链,不替换签发器。隔离 CA 不是公共 CA,测试目录的传输重定向不属于公共 DNS 证据;公网签发和三库首次 Web 仍待完成,不关闭 P1-03 或整体验收。 diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 544feb3..3848e90 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,5 +1,14 @@ # Linux 生产流程测试记录 +## 2026年10月4日 真实 lego 客户端隔离协议回归 + +- [✓] 新增 `lego_http_protocol_test.go`、`lego_protocol_fixture_test.go` 正式 Go 用例,调用产品真实 LegoIssuer 和真实 HTTP 挑战服务。仅通过已有 HTTPClient 注入把 ACME 目录限制在回环 TLS CA;信任测试 CA,不跳过 TLS,禁止访问外部 ACME 地址。 +- [✓] 隔离 CA 校验一次性 nonce、受保护 URL、ES256 请求签名、账户标识、申请域名、每个挑战的公钥指纹绑定、CSR 签名和 SAN;按实际 CSR 动态签发临时证书,再由产品验证私钥匹配和测试根信任链。 +- [✓] 单域名、SAN、CA 拒绝和挑战期间取消四场景通过;失败不提交 CSR 或下载证书。每个场景都在测试主动关闭租约之前核对挑战已返回 404。 +- [✓] 第一台 `scert-http01-protocol-20261004-0325.service` 已 inactive、Result=success、ExecMainStatus=0;普通专项 0.686 秒,五轮专项竞态 4.614 秒。日志 `/var/tmp/scert-http01-nginx-20261004/lego-protocol.log`。 +- [✓] `scert-http01-protocol-package-20261004-0324.service` 已 inactive、success、退出码 0;证书包全量 6.328 秒,随后 vet 通过。日志同目录 `lego-protocol-package.log`。测试使用 `9c26db0` 工作树加上述两个用例,生产实现未改。 +- [!] 这不是公共 CA 签发、所有 ACME 协议异常一致性、真实公网 Nginx 与 CA 联合验收或三库首次 Web。没有操作第二台、线上数据库、DNS 或正式证书。 + ## 2026年10月4日03:14 HTTP 验证回归与第一台正式升级 时间按运行环境 UTC+8。本轮接续重新读取第一台执行单元终态、下列日志与运行摘要,没有重复执行已成功的测试。第二台未操作;没有新增辅助验收脚本、备份或清空业务数据。 diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index b45a297..6fe47dc 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -7,7 +7,8 @@ - [✓] 域名验证方式、三库迁移二十六、HTTP 挑战存储与路由、正式签发及前端选择完成;实际配置快照授权补强已回归。 - [✓] 首批真实失败修复后,原生 Linux 全量、vet 和专项竞态通过;真实 Nginx 十项与三库迁移专项通过。前端 279 项及正式 Linux 构建通过。 - [✓] 第一台官方安装升级及运行摘要复核完成,80 挑战路径内部检查通过;没有重复第二台测试。 -- [~] 实际 lego 客户端 ACME 协议回归继续补齐;完整 DNS 多账户、CNAME 委派及元数据权限仍有开发和审查缺口。 +- [✓] 实际 lego 客户端隔离 ACME 四场景、五轮竞态、证书包全量与 vet 通过;不是公网或全部 ACME 标准验证。 +- [~] 完整 DNS 多账户、CNAME 委派及元数据权限仍有开发和审查缺口;继续修复全局系统设置读取边界。 - [!] 公共 DNS 仍指第二台;公共签发、Smail 系统邮件与重置、三库首次 Web 未完成,P1-03 及总体生产门禁不关闭。 详见《Linux生产流程测试记录》同时间章节;历史“待回归、待部署”不代表当前状态。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 1315546..26793f1 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -8,12 +8,20 @@ - [✓] 实际签发快照授权回归通过,拒绝路径早于状态修改、租约和 DNS 凭据读取;覆盖 HTTP 转 DNS、默认 DNS、项目及域名变化、撤权和重试。 - [✓] 官方 Linux 包已含两个 Nginx 模板,前端 279 项通过,第一台升级至干净 `9c26db0`,运行摘要已复核。 - [✓] 第一台 80 挑战反代与严格 TLS 内部就绪通过;第二台未操作。 -- [x] 同步主清单、开发清单、生产记录、核心规格和 HTTP 部署说明的最新边界,等待文档复审。 -- [~] 核对真实 lego 客户端的 HTTP-01 ACME 协议覆盖,补充正式 Go 回归,不新增辅助验收脚本。 +- [✓] 六份文档边界已核对并提交推送 `f327298`;第一次推送认证失败,后续重试成功。 +- [✓] 真实 lego 客户端隔离 HTTP-01 协议四场景、五轮竞态、证书包全量与 vet 已在第一台通过;新增正式 Go 用例,无辅助脚本。 - [ ] 完整 DNS 多账户增删、禁用、轮换及归属生命周期。 - [ ] 完整 CNAME 委派生命周期及其余元数据接口权限复审。 - [!] 第一台公共域名、系统发信授权与三数据库首次 Web 门禁保持;公共域名实际签发不以内部请求替代。 +### 系统设置读取隔离任务 + +沿用 P0-07 和本批元数据权限复审范围:系统全局设置只允许超级管理员读取,普通管理员和项目成员保留正常项目工作台;拒绝和成功响应均禁止共享缓存。先补旧代码失败用例,再修复,不缩减原有 DNS 账户可读范围。 + +- [~] 补后端正式路由回归:匿名、普通管理员、项目角色、超级管理员、原会话降权及缓存边界。 +- [ ] 限制全局设置读取;前端按实时会话角色加载,降权时清除旧系统配置,失败不能沿用旧权限。 +- [ ] 前后端回归、Linux 新包与第一台升级;完成前不把线上缺口标为关闭。 + ## 2026年10月4日02:46 HTTP 验证回归修复任务 沿用已确认需求,修改前检查点 `16224fa` 已推送;第一台只运行隔离回归,不修改线上数据,不操作第二台。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 58c2f48..4304ca7 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -9,7 +9,8 @@ - [✓] 真实独立 Nginx 十项通过;SQLite、MySQL 8.0.46、PostgreSQL 14.24 迁移专项及 MySQL 中断重入、冲突结构拒绝通过。不是三库完整首次 Web 验收。 - [✓] 干净 `9c26db0` 官方 Linux 包构建、前端 279 项及产物摘要通过;第一台正式安装升级完成,本轮重新读取服务终态、运行文件摘要和严格 TLS 就绪均正常。 - [✓] 第一台 Nginx 80 挑战路径已接入,未知令牌返回无缓存 404,普通业务仍跳转 HTTPS;上游严格 TLS 不变,应用和数据库仅回环监听。这是内部诊断,不是公网签发证据。 -- [~] 补齐真实 lego 客户端的 ACME 协议回归;继续 DNS 独立多账户生命周期、CNAME 委派和其余元数据权限复审。 +- [✓] 真实 lego 客户端的隔离 ACME 协议回归四场景通过,五轮竞态、证书包全量与 vet 通过;覆盖注册、签名、逐域名 HTTP 证明、CSR、证书链及失败清理,不等于公网签发或完整 ACME 标准一致性认证。 +- [~] 继续 DNS 独立多账户生命周期、CNAME 委派和其余元数据权限复审;已发现普通管理员可读系统设置全局元数据,正补访问限制和前端兼容。 - [!] 正常 DNS 仍指第二台,尚未获准切换;Smail 系统通知授权、真实邮件与重置、三数据库首次 Web、HTTP-01 公网签发及总体上线验收未完成。P1-03 不整项关闭。 证据路径与准确范围见《Linux生产流程测试记录》同时间章节。不清现有业务数据、不重复第二台主程序测试、不新增辅助脚本或远端备份。 diff --git a/doc/核心能力任务规格.md b/doc/核心能力任务规格.md index 01ed080..273301b 100644 --- a/doc/核心能力任务规格.md +++ b/doc/核心能力任务规格.md @@ -35,6 +35,7 @@ 2026年10月4日03:14 更新:HTTP-01 已接入正式签发、订单隔离路由、域名验证方式与迁移二十六;实际签发快照授权已补强。第一台 Linux 全量、vet、专项竞态、真实 Nginx 十项和三库迁移专项通过,干净 `9c26db0` 官方包已升级第一台并复核运行摘要。实际 lego 客户端完整 ACME 协议与公共域名签发仍待验收;system-dns 单账户列表不能代表独立多账户生命周期完成,完整账户及 CNAME 委派仍有开发缺口。保留六阶段未完成状态,内部就绪不替代公共入口、首次 Web 与全角色矩阵。 - 任务编号:P1-03;任务名称:完善 ACME 和 DNS 管理;优先级:P1。 +- 协议证据补充:真实 lego 客户端在第一台隔离 CA 通过单域名、SAN、拒绝和取消四场景,五轮竞态及证书包全量、vet 通过;注册/JWS/HTTP 证明/CSR/证书链经过实际校验,未替换签发器。该证据不关闭公共签发、全部异常协议、DNS 账户或委派门禁。 - 当前状态:`[~] 进行中`。 - 六阶段:需求确认 `[x]`;代码实现 `[~]`;自动化测试 `[~]`;隔离环境测试 `[~]`;生产部署测试 `[~]`;最终验收 `[ ]`。 - 问题说明:已有 ACME、DNS 账户与 TXT 测试,不能证明 HTTP-01、CNAME 委派、全部 SAN 和正式签发均通过。