限制认证状态增长并优化阿里云默认入口和权限诊断

This commit is contained in:
xuans 2026-09-15 21:24:18 +08:00
parent 00ea12cbd9
commit 667f76924e
10 changed files with 353 additions and 14 deletions

View File

@ -358,7 +358,7 @@ func DiagnoseAliyunError(err error, domainName string) string {
msg := err.Error()
lowered := strings.ToLower(msg)
if strings.Contains(lowered, "forbidden.nopermission") || strings.Contains(lowered, "forbidden.ram") || strings.Contains(lowered, "accessdenied") || strings.Contains(lowered, "not authorized") {
return fmt.Sprintf("当前阿里云 RAM 账号未获得域名 %s 的 DNS 解析权限。请在阿里云 RAM 控制台授予该账号针对 acs:alidns:*:*:domain/%s 的操作权限(Action: alidns:*)", domainName, domainName)
return fmt.Sprintf("当前阿里云 RAM 账号未获得域名 %s 所需的 DNS 操作权限。请管理员核对实际托管区资源 acs:alidns:*:*:domain/%s,按失败操作补充必要的读取、新增或删除权限;不要直接扩大为全部操作或全部域名权限", domainName, domainName)
}
if strings.Contains(lowered, "nosuchdomain") || strings.Contains(lowered, "invaliddomainname.noexist") {
return fmt.Sprintf("域名 %s 未在当前阿里云账号下添加 DNS 托管,请先在阿里云云解析 DNS 控制台添加此域名", domainName)

View File

@ -135,12 +135,12 @@ func TestDiagnoseAliyunError(t *testing.T) {
{
err: errors.New("SDKError: Code: Forbidden.RAM, Message: User not authorized to operate on the specified resource."),
domain: "example.com",
expected: "当前阿里云 RAM 账号未获得域名 example.com 的 DNS 解析权限。请在阿里云 RAM 控制台授予该账号针对 acs:alidns:*:*:domain/example.com 的操作权限(Action: alidns:*)",
expected: "当前阿里云 RAM 账号未获得域名 example.com 所需的 DNS 操作权限。请管理员核对实际托管区资源 acs:alidns:*:*:domain/example.com,按失败操作补充必要的读取、新增或删除权限;不要直接扩大为全部操作或全部域名权限",
},
{
err: errors.New("SDKError: Code: Forbidden.NoPermission, Message: Specified access key is not authorized."),
domain: "solit.cn",
expected: "当前阿里云 RAM 账号未获得域名 solit.cn 的 DNS 解析权限。请在阿里云 RAM 控制台授予该账号针对 acs:alidns:*:*:domain/solit.cn 的操作权限(Action: alidns:*)",
expected: "当前阿里云 RAM 账号未获得域名 solit.cn 所需的 DNS 操作权限。请管理员核对实际托管区资源 acs:alidns:*:*:domain/solit.cn,按失败操作补充必要的读取、新增或删除权限;不要直接扩大为全部操作或全部域名权限",
},
{
err: errors.New("SDKError: Code: NoSuchDomain, Message: The specified domain name does not exist."),

View File

@ -3,6 +3,7 @@ package httpapi
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net"
"net/http"
@ -90,6 +91,12 @@ func (s *Server) authLogin(c *gin.Context) {
}
expiresAt := minTime(now.Add(authSessionLifetime), absoluteExpiresAt)
s.authMu.Lock()
s.pruneAuthSessionsLocked(now)
if len(s.authSessions) >= authStateCapacity {
s.authMu.Unlock()
s.writeError(c, http.StatusServiceUnavailable, "登录会话数量已达上限,请稍后重试", nil)
return
}
s.authSessions[token] = authSession{
UserID: userID,
Username: account.Username,
@ -189,15 +196,20 @@ func (s *Server) currentAuthSession(c *gin.Context) (authSession, bool) {
return authSession{}, false
}
newExpiresAt := minTime(now.Add(authSessionLifetime), session.AbsoluteExpiresAt)
if newExpiresAt.After(session.ExpiresAt) {
session.ExpiresAt = newExpiresAt
s.authMu.Lock()
if current, exists := s.authSessions[token]; exists {
current.ExpiresAt = newExpiresAt
s.authSessions[token] = current
session = current
}
s.authMu.Lock()
current, exists := s.authSessions[token]
if !exists {
s.authMu.Unlock()
return authSession{}, false
}
refreshed := newExpiresAt.After(current.ExpiresAt)
if refreshed {
current.ExpiresAt = newExpiresAt
s.authSessions[token] = current
}
session = current
s.authMu.Unlock()
if refreshed {
s.setAuthCookie(c, token, session.ExpiresAt)
}
return session, true
@ -270,9 +282,10 @@ func (s *Server) allowLogin(key string) bool {
now := s.now()
s.loginMu.Lock()
defer s.loginMu.Unlock()
s.pruneLoginAttemptsLocked(now)
attempt, ok := s.loginAttempts[key]
if !ok {
return true
return len(s.loginAttempts) < authStateCapacity
}
if !attempt.BlockedTill.IsZero() && attempt.BlockedTill.After(now) {
return false
@ -287,6 +300,10 @@ func (s *Server) recordLoginFailure(key string) {
now := s.now()
s.loginMu.Lock()
defer s.loginMu.Unlock()
s.pruneLoginAttemptsLocked(now)
if _, exists := s.loginAttempts[key]; !exists && len(s.loginAttempts) >= authStateCapacity {
return
}
attempt := s.loginAttempts[key]
if attempt.WindowStart.IsZero() || now.Sub(attempt.WindowStart) >= loginWindow {
attempt = loginAttempt{WindowStart: now}
@ -323,7 +340,8 @@ func loginAttemptKey(request *http.Request, identifier string) string {
remote = host
}
}
return strings.ToLower(strings.TrimSpace(remote + "|" + identifier))
digest := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(identifier))))
return strings.ToLower(strings.TrimSpace(remote)) + "|" + hex.EncodeToString(digest[:])
}
func newAuthToken() (string, error) {

View File

@ -0,0 +1,27 @@
package httpapi
import "time"
const authStateCapacity = 4096
// 调用方持有认证锁;会话表有容量上限,不淘汰仍然有效的用户会话。
func (s *Server) pruneAuthSessionsLocked(now time.Time) {
for token, session := range s.authSessions {
if !session.ExpiresAt.After(now) || (!session.AbsoluteExpiresAt.IsZero() && !session.AbsoluteExpiresAt.After(now)) {
delete(s.authSessions, token)
}
}
}
// 调用方持有限流锁;定期回收且保留有效封禁,避免随机账号使状态无限增长。
func (s *Server) pruneLoginAttemptsLocked(now time.Time) {
if !s.loginPrunedAt.IsZero() && !now.Before(s.loginPrunedAt) && now.Sub(s.loginPrunedAt) < time.Minute {
return
}
for key, attempt := range s.loginAttempts {
if !attempt.BlockedTill.After(now) && (attempt.WindowStart.IsZero() || now.Sub(attempt.WindowStart) >= loginWindow) {
delete(s.loginAttempts, key)
}
}
s.loginPrunedAt = now
}

View File

@ -0,0 +1,134 @@
package httpapi
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
func TestLoginPrunesUnvisitedExpiredState(t *testing.T) {
cfg := readyTestConfig(t.TempDir())
cfg.Admin.Username = "admin"
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
server := New(Dependencies{Config: cfg})
now := time.Now()
server.now = func() time.Time { return now }
server.authSessions["expired"] = authSession{ExpiresAt: now.Add(-time.Hour)}
server.authSessions["absolute-expired"] = authSession{ExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(-time.Second)}
server.loginAttempts["old-key"] = loginAttempt{WindowStart: now.Add(-time.Hour)}
server.loginAttempts["blocked-key"] = loginAttempt{WindowStart: now.Add(-time.Hour), BlockedTill: now.Add(time.Minute)}
response := loginAuthTest(t, server, "admin", "AdminPass123", false)
if response.Code != http.StatusOK {
t.Fatalf("登录失败:%d", response.Code)
}
if len(server.authSessions) != 1 {
t.Fatal("新登录必须清理不会再被访问的过期会话")
}
if _, exists := server.loginAttempts["old-key"]; exists {
t.Fatal("新登录必须清理过期限流键")
}
if _, exists := server.loginAttempts["blocked-key"]; !exists {
t.Fatal("清理不能缩短仍然有效的封禁期")
}
}
func TestSessionCapacityDoesNotEvictActiveSessions(t *testing.T) {
cfg := readyTestConfig(t.TempDir())
cfg.Admin.Username = "admin"
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
server := New(Dependencies{Config: cfg})
now := time.Now()
server.now = func() time.Time { return now }
for index := 0; index < authStateCapacity; index++ {
server.authSessions[fmt.Sprint(index)] = authSession{ExpiresAt: now.Add(time.Minute)}
}
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"identifier":"admin","password":"AdminPass123"}`))
server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable || len(response.Result().Cookies()) != 0 || len(server.authSessions) != authStateCapacity {
t.Fatal("会话容量已满时应明确拒绝,不得踢出其他有效会话或设置新 Cookie")
}
now = now.Add(2 * time.Minute)
response = loginAuthTest(t, server, "admin", "AdminPass123", false)
if response.Code != http.StatusOK || len(server.authSessions) != 1 {
t.Fatal("过期会话清理后必须恢复登录能力")
}
}
type pausedSessionRepository struct {
repository.UserRepository
started chan struct{}
release chan struct{}
user repository.User
}
func (r *pausedSessionRepository) GetUserByID(ctx context.Context, _ string) (repository.User, error) {
close(r.started)
select {
case <-r.release:
return r.user, nil
case <-ctx.Done():
return repository.User{}, ctx.Err()
}
}
func TestConcurrentLogoutWinsOverPausedSessionRefresh(t *testing.T) {
users := &pausedSessionRepository{
started: make(chan struct{}), release: make(chan struct{}),
user: repository.User{ID: "operator", Username: "operator", Email: "operator@example.test", PasswordHash: "test-hash", Role: repository.UserRoleUser, Status: repository.UserStatusActive},
}
server := New(Dependencies{Config: readyTestConfig(t.TempDir()), Users: users})
now := time.Now()
server.authSessions["paused"] = authSession{UserID: users.user.ID, Username: users.user.Username, Email: users.user.Email, Role: users.user.Role, PasswordHash: users.user.PasswordHash, ExpiresAt: now.Add(time.Minute), AbsoluteExpiresAt: now.Add(time.Hour)}
cookie := &http.Cookie{Name: authCookieName, Value: "paused"}
done := make(chan *httptest.ResponseRecorder, 1)
go func() { done <- authSessionRequest(server, cookie) }()
select {
case <-users.started:
case <-time.After(time.Second):
close(users.release)
t.Fatal("会话未进入账号校验阶段")
}
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
request.AddCookie(cookie)
server.Handler().ServeHTTP(httptest.NewRecorder(), request)
close(users.release)
response := <-done
if response.Code != http.StatusUnauthorized || len(response.Result().Cookies()) != 0 {
t.Fatal("并发退出后的旧请求不能继续刷新会话或设置 Cookie")
}
}
func TestLoginStateHasFiniteCapacity(t *testing.T) {
server := New(Dependencies{Config: readyTestConfig(t.TempDir())})
for index := 0; index < 20000; index++ {
key := fmt.Sprintf("unique-%d", index)
if server.allowLogin(key) {
server.recordLoginFailure(key)
}
}
if len(server.loginAttempts) > 4096 {
t.Fatalf("失败登录记录无界增长:%d", len(server.loginAttempts))
}
if server.allowLogin("new-key-after-capacity") {
t.Fatal("限流表已满时不能继续接受未知登录键")
}
}
func TestLoginAttemptKeyDoesNotRetainLargeIdentifier(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
request.RemoteAddr = "127.0.0.1:12345"
identifier := fmt.Sprintf("%016000d", 1)
if len(loginAttemptKey(request, identifier)) > 128 {
t.Fatal("限流键不应保留大体积用户输入")
}
if loginAttemptKey(request, " ADMIN ") != loginAttemptKey(request, "admin") {
t.Fatal("账号规范化规则不能因散列键改变")
}
}

View File

@ -106,6 +106,7 @@ type Server struct {
authMu sync.RWMutex
loginAttempts map[string]loginAttempt
loginMu sync.Mutex
loginPrunedAt time.Time
now func() time.Time
httpServer *http.Server
router *gin.Engine

View File

@ -3274,3 +3274,24 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无
- `[!]` 真实 DNS 服务商、Let’s Encrypt Production、远程 SSH 部署、Nginx 反向代理、生产 HTTPS、Windows Service、Windows ACL、MySQL 和真实防火墙链路仍需目标环境验收。
- `[!]` 工作区已有的 `desktop/desktop.exe`、`desktop/frontend/dist/index.html`、`dist/`、`backend/.patch*` 和 `backend/*.exe` 未删除、未纳入本轮源码提交。
- `[!]` 工作区已有的 `desktop/desktop.exe`、`desktop/frontend/dist/index.html`、`dist/`、`backend/.patch*` 和 `backend/*.exe` 未删除、未纳入源码提交。
## 八十七、请求安全、DNS 状态与认证稳定性审查(2026-09-15)
详细记录和证据范围见 `全面审查测试与优化记录.md`,本节不覆盖旧轮的实机验收结论。
- `[✓]` 恢复任务与工作区,分清管理部署机和推送目标机;保留禁止修改 `pcux.cn` 的边界。
- `[✓]` 桌面模块同步 SSH 安全依赖,恢复只读模块测试。
- `[✓]` 登录、退出和首次初始化增加跨源写入防护。
- `[✓]` 长操作采用有限上下文与独立响应期限,真实套接字回归通过。
- `[✓]` 初始化改用专用互斥锁,签发不再阻塞配置读取,并发初始化拒绝。
- `[✓]` DNS 验证结果与当前输入、请求代次和有效期绑定,防止旧响应覆盖。
- `[✓]` 嵌套续期、云 API 报告脱敏,保留清理失败等重要状态。
- `[✓]` 认证状态过期回收、容量上限、固定长度限流键和并发退出回归。
- `[✓]` 新初始化默认阿里云;兼容已保存服务商,不建议通配全部权限。
- `[✓]` 后端全量测试、竞态检测、静态检查、模块校验、漏洞复扫;最终覆盖率 64.0%。
- `[✓]` 前端 12 项自动化测试、生产构建、全依赖审计;桌面模块最终测试通过。
- `[✓]` Linux 和 Windows 安装脚本语法检查,未执行安装器。
- `[~]` 独立 Linux 发布候选、源码提交与归档哈希复核。
- `[!]` 本地服务启动被执行策略拒绝,本轮实际浏览器验收未执行,未尝试绕过。
- `[!]` 写权限探测逐级尝试父托管区的边界需专项整改;只读精准检查不等于写路径安全完成。
- `[!]` 真实初始化、正式 ACME、HTTPS/SNI、业务推送、升级回退及目标平台验收仍未完成。

View File

@ -0,0 +1,125 @@
# 全面审查、测试与优化记录
## 一、范围与需求确认
日期:2026-09-15。本轮从北京时间 20:47 开始,承接“全面审查、测试、优化”的要求。
本轮按既有需求执行,不扩大测试授权:
- 保留多用户;新安装优先阿里云,既有服务商配置保持兼容。
- 管理部署环境为 `47.108.78.12`;`8.137.23.95` 仅作为推送测试目标,不能混用。
- 禁止修改 `pcux.cn` 及其子域的 DNS、证书、私钥和站点;不重载共享 Nginx。
- 自动化测试使用各自隔离的临时数据,不清空现有业务数据库。
- 不调用真实 RAM API,不执行全局域名发现,不把历史可见范围当成当前可写授权。
- 不删除已有构建、补丁、工作区文件,不把含凭据的辅助脚本加入 Git。
- 中文文件通过补丁修改;分阶段中文提交、显式暂存、推送并验证。
## 二、任务清单
| 状态 | 任务 |
| --- | --- |
| `[✓]` | 恢复任务、核对工作区及两台主机边界 |
| `[✓]` | 桌面模块同步后端 SSH 安全依赖,恢复只读模块测试 |
| `[✓]` | 认证和初始化跨源写请求防护及回归 |
| `[✓]` | 同步长操作的有限上下文、响应期限及真实套接字回归 |
| `[✓]` | 初始化锁粒度优化及并发提交回归 |
| `[✓]` | 前端 DNS 旧响应、输入失效、过期令牌和卸载竞态修复 |
| `[✓]` | 续期及阿里云报告嵌套错误脱敏 |
| `[✓]` | 认证状态容量、过期回收、并发退出补强及最终全量复核 |
| `[✓]` | 默认阿里云与最小权限提示优化;前端 12 项测试通过 |
| `[✓]` | 最终全量测试、竞态检测、静态检查及漏洞复扫 |
| `[~]` | 独立发布包、源码提交和哈希复核 |
| `[!]` | 实际浏览器页面验收:本地服务启动被执行策略拒绝,未绕过 |
| `[!]` | 真实初始化、正式 ACME、受控业务推送及生产准入 |
## 三、复现与修复
### 1. 跨源登录、退出与初始化写入
原登录与退出接受跨源 `Origin` 或 `Referer`,并允许 `text/plain` 携带 JSON。跨源回归在修复前返回 200。仅限制初始化请求来自回环地址,也不能替代浏览器来源校验。
登录、退出、初始化提交和初始化 DNS 测试加入来源中间件;跨源返回 403,不设置或清除 Cookie,也不删除已有会话。同源和无来源头的既有 CLI/API 访问保持兼容。
### 2. 长操作完成后客户端却读到 EOF
普通 HTTP 写期限为 30 秒,而 DNS 等待和证书工作流明显更长。使用真实回环套接字,把普通写期限缩短为 50 毫秒、模拟 DNS 工作流延迟 150 毫秒,原实现稳定出现 EOF。
增加按路由配置的长操作中间件:DNS 使用其验证期限,证书操作上限 30 分钟,初始化外层 12 分钟。通过 `ResponseController` 调整响应写期限,并留出 2 分钟处理清理、回滚和结果序列化。普通请求的超时保护不变。新增取消后的明确响应测试。
**反向代理仍必须单独设置足够的读响应期限**。服务器放宽写期限不会自动改变 Nginx、负载均衡或客户端超时;本轮未修改远程代理配置。
### 3. 初始化阻塞所有配置读取
初始化原来在证书签发期间持续持有配置写锁。改为专用初始化互斥锁,加上开始和结束时的短配置锁。并发初始化返回 409;在模拟签发阻塞期间,状态请求仍可返回。
### 4. DNS 输入与旧响应串线
初始化及系统设置存在旧请求覆盖新输入、新结果的问题;系统设置对过期令牌和新增凭据检查不足。最初六个新增测试在修复前失败。
采用请求代次与同步输入监听,只有当前代次可更新结果、错误和加载状态。输入变化、关闭设置、组件卸载均使在途结果失效。新增配置必须有未过期的服务端验证令牌;未修改已保存 DNS 配置时仍可保存其他设置。
测试直接运行真实 Vue 组合式逻辑、监听和生命周期,模拟网络,不引入新 npm 依赖。**这些测试不是页面渲染或真实云 API 验收。**
### 5. 嵌套错误泄露
原 `RenewalReport.Errors`、阿里云报告 `Diagnosis` 和逐域 `Error` 能把底层错误带回客户端。新增哨兵错误测试在修复前失败。
保留失败数量、域名及写入和清理状态,返回安全中文诊断;未知 SDK 错误、受保护凭据读取错误不再直接拼接到响应。写入成功但清理失败仍明确显示 `writable=true`、`cleanup=false`、`verified=false`,不把脱敏误做成成功。
### 6. 登录状态长期增长和并发退出
三个新测试证明:过期且不再访问的会话未清理;随机账号可持续增加限流表;大体积账号输入直接留在限流键中。
改为:
- 新登录前回收过期会话,认证会话和失败登录表各设 4096 项上限。
- 限流表按请求触发、至少间隔一分钟回收过期项,不提前解除有效封禁。
- 账号规范化后散列为固定长度键,避免存储整段大输入。
- 会话表满时返回 503,不踢出有效用户;限流表满时未知键返回 429,不能悄悄绕过限制。
- 用户校验返回后再次检查会话存在性,避免已经完成的退出被旧刷新请求继续使用。
- 增加会话容量恢复和暂停账号查询期间并发退出的回归。
容量上限是内存保护,不是完整抗攻击方案。高并发认证、全局容量耗尽时正常用户可用性,以及可信代理下的来源限流,还需独立负载与攻击流量验收。
### 7. 阿里云入口与提示
全新初始化默认 `aliyun`,已保存的服务商仍保留。权限拒绝提示不再建议直接授予 `Action: alidns:*`,而是核对实际托管区并按失败动作补充必要权限,不扩大至所有域名。
这不代表整个产品已完成阿里云专用化。其他服务商的 UI 和兼容实现未删除。
## 四、已收取的验证证据
- 第一阶段后端全量测试、全量竞态检测、`go vet` 和模块校验通过;语句覆盖率 63.9%。
- 桌面模块依赖原来在 `-mod=readonly` 下要求更新;同步 `golang.org/x/crypto v0.56.0` 后测试、静态检查、模块校验通过。
- 后端、桌面漏洞扫描均为零调用可达漏洞、零已导入受影响包。模块层仍提示 `GO-2026-5932`:未使用的 `golang.org/x/crypto/openpgp` 包,无修复版本。不能写成“依赖树完全无漏洞提示”。
- 第二阶段认证及 DNS 包测试通过,新增容量及并发测试纳入最终全量复跑。
- 前端最终 `npm test` 为 12/12,通过全依赖及生产依赖审计,均零漏洞。
- 最新产品前端生产构建通过,输出到全新 `.runtime/review-20260915-2115/install/web`,未覆盖旧产物。
- Linux 安装脚本 `bash -n`、Windows 安装脚本 PowerShell 语法解析通过;未执行安装器。
- 本地服务后台启动调用被执行策略拒绝。没有换启动方式绕过,没有继续启动浏览器,没有本轮实际页面截图或页面验收结论。
21:23 收取最终后端复跑:全量测试、全量竞态检测、`go vet`、`go mod verify`、漏洞复扫均退出零。最终语句覆盖率 **64.0%**,HTTP 包 **70.6%**。桌面模块针对最新后端的只读模块测试、静态检查、模块校验也全部通过。
中间一次容量测试误用了“必须登录成功”的旧测试辅助函数,把预期 503 当作失败;已改为直接发 HTTP 请求断言 503,再验证过期回收后登录恢复。最终结果来自修正后完整重跑,不使用失败运行的覆盖率文件。
候选提交与发布包结果在本文件末尾追加,以最后一次成功记录为准。
## 五、仍需整改和生产验收的项目
### 发布阻塞
1. **阿里云写探测授权边界**:`AliyunAuthorizationChecker.Verify` 仍会从目标向父级候选逐级试写。应由用户明确托管区与允许操作范围,任何父区或公共后缀都不能因自动推断而获得操作授权。现有只读 `Inspect` 已是精确托管区模式,但不等于写路径已整改。
2. **当前管理环境未完成初始化和正式 TLS**:上轮文档记载 `/readyz=503 setup_incomplete`,正式 ACME 连接超时。本轮没有远程刷新,不能当作已解决或确认当前仍然如此。
3. **新代码未实机安装验收**:需要维护窗口、完整备份、受控升级、真实初始化、浏览器 HTTPS/SNI、正式证书链及回滚演练。
4. **真实业务推送未验收**:隔离 SSH/SFTP 事务测试不等于已有业务服务的实际证书读取、配置校验和授权 reload;`pcux.cn` 禁止修改仍然有效。
### 后续安全与质量工作
- 会话有效性校验对缺少 `UserID` 或 `PasswordHash` 的内部会话仍有兼容分支;真实登录会填写这两项,多个旧测试直接注入不完整会话。应先改测试夹具,再使生产分支严格失败关闭,不能把其当前状态称作完整认证审计通过。
- 长请求需要反向代理和实际客户端超时验证;后续宜评估异步任务接口,而不是继续提高所有请求的全局超时。
- 多用户、高并发、容量饱和与长时间运行尚未压力验收。
- Windows Service、Wails 安装包、ACL、MySQL 和真实防火墙链路需要各自目标平台验收。
## 六、结论
本轮完成了多项可复现缺陷修复和自动化回归,提升了发布候选质量;**不构成“全部开发完成”或“生产验收通过”**。尤其是用户强调的部分域名授权边界,应作为正式写入和生产准入前的优先整改项,而不是用全量测试通过替代。

View File

@ -75,7 +75,7 @@ export function useSetupWizard(props, emit) {
email_retry_count: 3,
email_retry_intervals_secs: "30,120,600",
recipients: "",
dns_provider: props.status.dns_provider || "cloudflare",
dns_provider: props.status.dns_provider || "aliyun",
propagation_timeout_secs: props.status.propagation_timeout_secs || 900,
propagation_polling_interval_secs:
props.status.propagation_polling_interval_secs || 10,

View File

@ -4,6 +4,19 @@ import { nextTick } from "vue";
import { useSetupWizard } from "./SetupWizard.js";
import { aliyunProvider, deferred, mountComposable, verifiedResponse } from "../../test-support/mount.js";
test("全新初始化默认选择阿里云", async (t) => {
const { state } = await mountComposable(t, () => useSetupWizard({ status: {} }, () => {}),
async () => Response.json({ items: [aliyunProvider] }));
assert.equal(state.form.value.dns_provider, "aliyun");
});
test("已有初始化服务商选择保持兼容", async (t) => {
const provider = { ...aliyunProvider, id: "cloudflare" };
const { state } = await mountComposable(t, () => useSetupWizard({ status: { dns_provider: "cloudflare" } }, () => {}),
async () => Response.json({ items: [provider, aliyunProvider] }));
assert.equal(state.form.value.dns_provider, "cloudflare");
});
async function setup(t, request) {
const { state, unmount } = await mountComposable(t, () => useSetupWizard({ status: { dns_provider: "aliyun" } }, () => {}),
async (path) => path.endsWith("/providers") ? Response.json({ items: [aliyunProvider] }) : request());