修复跨源请求与长操作超时并加固DNS验证和错误脱敏

This commit is contained in:
xuans 2026-09-15 21:11:46 +08:00
parent b0a846df0f
commit 00ea12cbd9
14 changed files with 673 additions and 34 deletions

View File

@ -366,5 +366,11 @@ func DiagnoseAliyunError(err error, domainName string) string {
if strings.Contains(lowered, "invalidaccesskeyid.notfound") || strings.Contains(lowered, "signaturedoesnotmatch") {
return "阿里云 AccessKey ID 或 AccessKey Secret 无效,请检查系统 DNS 凭据配置"
}
return fmt.Sprintf("阿里云 RAM 权限验证未通过:%s", msg)
if errors.Is(err, context.Canceled) {
return "阿里云 RAM 权限验证已取消,当前权限尚未确认"
}
if errors.Is(err, context.DeadlineExceeded) {
return "阿里云 RAM 权限验证超时,请检查网络后重新验证"
}
return "阿里云 RAM 权限验证未通过,请检查网络、凭据和目标域名权限;不要将未确认状态视为已授权"
}

View File

@ -137,13 +137,17 @@ func (s *Server) checkAliyunDNSDomain(c *gin.Context) {
}
report, verifyErr := s.aliyunChecker.Verify(c.Request.Context(), targetDomain, credentials)
report = sanitizeAliyunReport(report, targetDomain)
if verifyErr != nil {
diagnosis := dnsprovider.DiagnoseAliyunError(verifyErr, targetDomain)
report.Verified = false
report.Diagnosis = diagnosis
c.JSON(http.StatusOK, gin.H{
"verified": false,
"domain": targetDomain,
"matched_domain": report.MatchedDomain,
"diagnosis": report.Diagnosis,
"error": verifyErr.Error(),
"error": diagnosis,
"report": report,
})
return
@ -180,7 +184,7 @@ func (s *Server) validateDomainAliyunAuthorization(ctx context.Context, domainNa
credentials, err := s.readDNSCredentials(ctx, credentialRef)
if err != nil {
return fmt.Errorf("读取阿里云 DNS 凭据失败,不能确认域名授权:%w", err)
return errors.New("读取阿里云 DNS 凭据失败,不能确认域名授权,请检查受保护的凭据存储")
}
if _, err := dnsprovider.ValidateCredentials(dnsprovider.Aliyun, credentials); err != nil {
return fmt.Errorf("阿里云 DNS 凭据无效,不能确认域名授权:%w", err)
@ -193,6 +197,25 @@ func (s *Server) validateDomainAliyunAuthorization(ctx context.Context, domainNa
return nil
}
func sanitizeAliyunReport(report dnsprovider.AuthorizationReport, requestedDomain string) dnsprovider.AuthorizationReport {
if report.Diagnosis != "" {
report.Diagnosis = dnsprovider.DiagnoseAliyunError(errors.New(report.Diagnosis), requestedDomain)
}
report.Domains = append([]dnsprovider.AuthorizationDomain(nil), report.Domains...)
for index := range report.Domains {
item := &report.Domains[index]
if item.Error == "" {
continue
}
if item.Writable && !item.Cleanup {
item.Error = "测试记录写入成功但清理失败,请检查删除权限并核对残留测试记录"
} else {
item.Error = dnsprovider.DiagnoseAliyunError(errors.New(item.Error), item.Domain)
}
}
return report
}
func (s *Server) readDNSCredentials(ctx context.Context, credentialRef string) (map[string]string, error) {
if s == nil || strings.TrimSpace(credentialRef) == "" {
return nil, errors.New("DNS 凭据引用未配置")

View File

@ -0,0 +1,51 @@
package httpapi
import (
"context"
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
const operationResponseGrace = 2 * time.Minute
// 仅为明确的长操作延长响应期限,普通请求仍受服务器默认写超时保护。
// 工作流使用有限上下文,额外响应时间留给 DNS 清理、部署回滚和结果序列化。
func (s *Server) longOperation(timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
deadline, _ := ctx.Deadline()
controller := http.NewResponseController(c.Writer)
if err := controller.SetWriteDeadline(deadline.Add(operationResponseGrace)); err != nil && !errors.Is(err, http.ErrNotSupported) {
s.writeError(c, http.StatusServiceUnavailable, "无法设置长操作响应期限,请稍后重试", err)
c.Abort()
return
}
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
func (s *Server) dnsOperation(c *gin.Context) {
s.longOperation(s.dnsVerificationTimeout())(c)
}
func (s *Server) requireSameOrigin(c *gin.Context) {
if !s.sameOriginForAPI(c.Request) {
s.writeError(c, http.StatusForbidden, "请求来源校验失败", nil)
c.Abort()
return
}
c.Next()
}
func (s *Server) requireSetupAccess(c *gin.Context) {
if !s.allowSetupRequest(c) {
c.Abort()
return
}
c.Next()
}

View File

@ -13,6 +13,14 @@ func (s *Server) runRenewalNow(c *gin.Context) {
return
}
report, err := s.runRenewal(c.Request.Context())
if len(report.Errors) > 0 {
// 保留失败数量,不将证书路径、凭据或下游 SDK 响应带回浏览器。
messages := make([]string, len(report.Errors))
for index := range messages {
messages[index] = "证书续期失败,请检查对应任务及受保护的服务日志"
}
report.Errors = messages
}
response := gin.H{"report": report}
if err != nil {
if s.logger != nil {

View File

@ -0,0 +1,61 @@
package httpapi
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
)
func TestRenewalReportHidesNestedWorkflowErrors(t *testing.T) {
server := New(Dependencies{
Config: readyTestConfig(t.TempDir()),
RunRenewal: func(context.Context) (certificate.RenewalReport, error) {
return certificate.RenewalReport{Failed: 1, Errors: []string{"internal-secret-sentinel"}}, nil
},
})
response := httptest.NewRecorder()
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/renewal/run", nil))
if response.Code != http.StatusOK || strings.Contains(response.Body.String(), "internal-secret-sentinel") {
t.Fatalf("续期报告不得返回底层敏感错误:%d", response.Code)
}
if !strings.Contains(response.Body.String(), `"failed":1`) {
t.Fatal("脱敏不能丢失续期失败计数")
}
}
type unsafeReportChecker struct {
fakeAliyunScopeChecker
}
func (f *unsafeReportChecker) Verify(context.Context, string, map[string]string) (dnsprovider.AuthorizationReport, error) {
err := errors.New("internal-secret-sentinel")
return dnsprovider.AuthorizationReport{
Diagnosis: err.Error(),
Domains: []dnsprovider.AuthorizationDomain{{Domain: "example.test", Writable: true, Cleanup: false, Error: err.Error()}},
}, err
}
func TestAliyunReportHidesSDKErrorAndPreservesCleanupFailure(t *testing.T) {
cfg := readyTestConfig(t.TempDir())
cfg.DNS.Provider = dnsprovider.Aliyun
cfg.DNS.CredentialRef = "secret://test/only"
server := New(Dependencies{
Config: cfg, AliyunChecker: &unsafeReportChecker{},
Secrets: &fakeSecretReaderStore{data: map[string]map[string]string{cfg.DNS.CredentialRef: {"access_key_id": "test-id", "access_key_secret": "test-secret"}}},
})
response := httptest.NewRecorder()
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/dns/aliyun/check", strings.NewReader(`{"domain":"example.test"}`)))
body := response.Body.String()
if response.Code != http.StatusOK || strings.Contains(body, "internal-secret-sentinel") {
t.Fatalf("阿里云验证报告不得返回 SDK 敏感错误:%d", response.Code)
}
if !strings.Contains(body, `"writable":true`) || !strings.Contains(body, `"cleanup":false`) || !strings.Contains(body, `"verified":false`) {
t.Fatal("脱敏必须保留写入成功但清理失败的验收事实")
}
}

View File

@ -0,0 +1,204 @@
package httpapi
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/setup"
)
func TestAuthMutationRejectsCrossOrigin(t *testing.T) {
cfg := readyTestConfig(t.TempDir())
cfg.Admin.Username = "admin"
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
server := New(Dependencies{Config: cfg})
for _, path := range []string{"/api/v1/auth/login", "/api/v1/auth/logout"} {
for _, header := range []string{"Origin", "Referer"} {
t.Run(path+"/"+header, func(t *testing.T) {
cookie := loginAuthTest(t, server, "admin", "AdminPass123", false).Result().Cookies()[0]
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"identifier":"admin","password":"AdminPass123"}`))
request.Header.Set("Content-Type", "text/plain")
request.Header.Set(header, "https://untrusted.example")
request.AddCookie(cookie)
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("跨源认证操作应被拒绝:%d", response.Code)
}
if len(response.Result().Cookies()) != 0 {
t.Fatal("跨源请求不应设置或清除会话 Cookie")
}
if _, exists := server.authSessions[cookie.Value]; !exists {
t.Fatal("跨源退出不应删除已有会话")
}
})
}
}
}
func TestAuthMutationAllowsSameOrigin(t *testing.T) {
cfg := readyTestConfig(t.TempDir())
cfg.Admin.Username = "admin"
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
server := New(Dependencies{Config: cfg})
for _, path := range []string{"/api/v1/auth/login", "/api/v1/auth/logout"} {
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"identifier":"admin","password":"AdminPass123"}`))
request.Header.Set("Origin", "http://example.com")
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("同源认证操作应保持可用:%s %d", path, response.Code)
}
}
}
func TestSetupMutationRejectsCrossOriginLoopback(t *testing.T) {
for _, path := range []string{"/api/v1/setup", "/api/v1/setup/dns/test"} {
server := New(Dependencies{Config: config.Default(t.TempDir()), SetupDNS: &fakeSetupDNSVerifier{}})
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
request.RemoteAddr = "127.0.0.1:12345"
request.Header.Set("Origin", "https://untrusted.example")
request.Header.Set("Content-Type", "text/plain")
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("本机初始化也必须拒绝跨源写入:%s %d", path, response.Code)
}
}
}
type delayedDNSVerifier struct {
fakeSetupDNSVerifier
}
func (f *delayedDNSVerifier) Verify(ctx context.Context, _ setup.DNSVerificationInput) (setup.DNSVerificationResult, error) {
select {
case <-time.After(150 * time.Millisecond):
return setup.DNSVerificationResult{}, nil
case <-ctx.Done():
return setup.DNSVerificationResult{}, ctx.Err()
}
}
func TestDNSLongResponseSurvivesOrdinaryWriteTimeout(t *testing.T) {
server := New(Dependencies{Config: config.Default(t.TempDir()), SetupDNS: &delayedDNSVerifier{}})
// 使用真实套接字而非 ResponseRecorder,缩短默认期限以稳定复现写入超时。
host := httptest.NewUnstartedServer(server.Handler())
host.Config = newHTTPServer("", server.Handler())
host.Config.WriteTimeout = 50 * time.Millisecond
host.Start()
defer host.Close()
client := host.Client()
client.Timeout = 5 * time.Second
response, err := client.Post(host.URL+"/api/v1/setup/dns/test", "application/json", strings.NewReader(`{"provider":"aliyun","test_domain":"example.test","credentials":{}}`))
if err != nil {
t.Fatalf("长操作完成后浏览器必须仍可读取响应:%v", err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil || response.StatusCode != http.StatusOK || !strings.Contains(string(body), `"verified":true`) {
t.Fatalf("长操作响应不完整:%d %v", response.StatusCode, err)
}
}
func TestLongOperationCancelsWorkAndAllowsFinalResponse(t *testing.T) {
server := New(Dependencies{Config: config.Default(t.TempDir())})
server.router.POST("/test-operation", server.longOperation(50*time.Millisecond), func(c *gin.Context) {
<-c.Request.Context().Done()
if !errors.Is(c.Request.Context().Err(), context.DeadlineExceeded) {
c.Status(http.StatusInternalServerError)
return
}
c.JSON(http.StatusGatewayTimeout, gin.H{"error": "测试工作流已停止"})
})
host := httptest.NewUnstartedServer(server.Handler())
host.Config = newHTTPServer("", server.Handler())
host.Config.WriteTimeout = 20 * time.Millisecond
host.Start()
defer host.Close()
client := host.Client()
client.Timeout = 5 * time.Second
response, err := client.Post(host.URL+"/test-operation", "application/json", nil)
if err != nil {
t.Fatalf("取消后应仍能返回明确错误:%v", err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil || response.StatusCode != http.StatusGatewayTimeout || !strings.Contains(string(body), "测试工作流已停止") {
t.Fatalf("超时结果未完整返回:%d %v", response.StatusCode, err)
}
}
func TestSetupIssuanceDoesNotBlockStatusOrAllowConcurrentSetup(t *testing.T) {
directory := t.TempDir()
started := make(chan struct{})
release := make(chan struct{})
server := New(Dependencies{
Config: config.Default(directory), ConfigPath: filepath.Join(directory, "config.json"),
SetupDNS: &fakeSetupDNSVerifier{},
IssueSetupCertificate: func(ctx context.Context, _ certificate.SetupCertificateRequest) (certificate.SavedResource, error) {
close(started)
select {
case <-release:
return certificate.SavedResource{}, errors.New("模拟签发失败")
case <-ctx.Done():
return certificate.SavedResource{}, ctx.Err()
}
},
})
payload := `{"admin_username":"admin","admin_email":"admin@example.test","admin_password":"AdminPass123","server_port":8443,"dns_provider":"aliyun","dns_credential_ref":"secret://dns/aliyun/test","dns_verification_token":"test-token","dns_test_domain":"scert.example.test","web_hostname":"scert.example.test","https_mode":"direct"}`
request := func() *http.Request {
r := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(payload))
r.RemoteAddr = "127.0.0.1:12345"
return r
}
done := make(chan struct{})
go func() {
defer close(done)
server.Handler().ServeHTTP(httptest.NewRecorder(), request())
}()
defer func() { close(release); <-done }()
select {
case <-started:
case <-done:
t.Fatal("初始化未到达模拟签发阶段")
case <-time.After(2 * time.Second):
t.Fatal("初始化启动超时")
}
for _, path := range []string{"/api/v1/status", "/api/v1/setup"} {
response := httptest.NewRecorder()
finished := make(chan struct{})
go func() {
defer close(finished)
r := request()
if path != "/api/v1/setup" {
r = httptest.NewRequest(http.MethodGet, path, nil)
}
server.Handler().ServeHTTP(response, r)
}()
select {
case <-finished:
case <-time.After(time.Second):
t.Fatalf("签发期间请求被全局配置锁阻塞:%s", path)
}
want := http.StatusOK
if path == "/api/v1/setup" {
want = http.StatusConflict
}
if response.Code != want {
t.Fatalf("签发期间响应不正确:%s %d", path, response.Code)
}
}
}

View File

@ -98,6 +98,7 @@ type Server struct {
taskMu sync.Mutex
taskCancels map[string]context.CancelFunc
issueTaskMu sync.Mutex
setupMu sync.Mutex
resetMu sync.Mutex
resetClosedHash string
resetLimiter *resetRateLimiter
@ -158,8 +159,8 @@ func (s *Server) registerRoutes() {
s.router.POST(admin.ResetPath, s.resetSubmit)
s.router.GET("/api/v1/status", s.status)
s.router.GET("/api/v1/auth/session", s.authSession)
s.router.POST("/api/v1/auth/login", s.authLogin)
s.router.POST("/api/v1/auth/logout", s.authLogout)
s.router.POST("/api/v1/auth/login", s.requireSameOrigin, s.authLogin)
s.router.POST("/api/v1/auth/logout", s.requireSameOrigin, s.authLogout)
s.router.POST("/api/v1/auth/password", s.requireAuthenticated, s.changePassword)
s.router.POST("/api/v1/password-reset/resolve", s.resolvePasswordResetAccount)
adminAPI := s.router.Group("/api/v1", s.requireAdmin)
@ -170,10 +171,10 @@ func (s *Server) registerRoutes() {
userAPI.PUT("/:id", s.updateUser)
userAPI.DELETE("/:id", s.deleteUser)
adminAPI.GET("/domains", s.listDomains)
adminAPI.POST("/domains", s.createDomain)
adminAPI.PUT("/domains/:id", s.updateDomain)
adminAPI.POST("/domains", s.dnsOperation, s.createDomain)
adminAPI.PUT("/domains/:id", s.dnsOperation, s.updateDomain)
adminAPI.DELETE("/domains/:id", s.deleteDomain)
adminAPI.POST("/domains/:id/issue", s.issueDomain)
adminAPI.POST("/domains/:id/issue", s.longOperation(certificateTaskTimeout), s.issueDomain)
adminAPI.GET("/tasks", s.listTasks)
adminAPI.GET("/tasks/:id", s.getTask)
adminAPI.DELETE("/tasks/:id", s.cancelTask)
@ -182,17 +183,17 @@ func (s *Server) registerRoutes() {
adminAPI.GET("/settings", s.getSettings)
adminAPI.PUT("/settings", s.updateSettings)
adminAPI.GET("/dns/providers", s.listAdminDNSProviders)
adminAPI.POST("/dns/test", s.testAdminDNS)
adminAPI.POST("/dns/test", s.dnsOperation, s.testAdminDNS)
adminAPI.GET("/dns/aliyun/scope", s.getAliyunDNSScope)
adminAPI.POST("/dns/aliyun/check", s.checkAliyunDNSDomain)
adminAPI.POST("/renewal/run", s.runRenewalNow)
adminAPI.POST("/dns/aliyun/check", s.dnsOperation, s.checkAliyunDNSDomain)
adminAPI.POST("/renewal/run", s.longOperation(certificateTaskTimeout), s.runRenewalNow)
adminAPI.GET("/certificates", s.listCertificates)
adminAPI.GET("/certificates/:id", s.getCertificate)
adminAPI.POST("/domains/:id/certificate/import", s.importCertificateFiles)
adminAPI.POST("/domains/:id/certificate/validate", s.validateCertificate)
adminAPI.POST("/domains/:id/deploy", s.deployCertificateCurrent)
adminAPI.POST("/domains/:id/certificate/import", s.longOperation(certificateTaskTimeout), s.importCertificateFiles)
adminAPI.POST("/domains/:id/certificate/validate", s.longOperation(certificateTaskTimeout), s.validateCertificate)
adminAPI.POST("/domains/:id/deploy", s.longOperation(certificateTaskTimeout), s.deployCertificateCurrent)
adminAPI.GET("/domains/:id/certificate/versions", s.listCertificateVersions)
adminAPI.POST("/domains/:id/certificate/versions/:version/restore", s.restoreCertificateVersion)
adminAPI.POST("/domains/:id/certificate/versions/:version/restore", s.longOperation(certificateTaskTimeout), s.restoreCertificateVersion)
adminAPI.GET("/domains/:id/servers", s.listDomainServers)
adminAPI.PUT("/domains/:id/servers", s.replaceDomainServers)
adminAPI.GET("/servers", s.listServers)

View File

@ -81,8 +81,8 @@ type dnsTestRequest struct {
func (s *Server) registerSetupRoutes() {
s.router.GET("/api/v1/setup/status", s.setupStatus)
s.router.GET("/api/v1/setup/dns/providers", s.setupDNSProviders)
s.router.POST("/api/v1/setup/dns/test", s.testSetupDNS)
s.router.POST("/api/v1/setup", s.applySetup)
s.router.POST("/api/v1/setup/dns/test", s.requireSameOrigin, s.requireSetupAccess, s.dnsOperation, s.testSetupDNS)
s.router.POST("/api/v1/setup", s.requireSameOrigin, s.requireSetupAccess, s.longOperation(12*time.Minute), s.applySetup)
}
func (s *Server) setupStatus(c *gin.Context) {
@ -212,13 +212,19 @@ const defaultDNSRequestTimeout = 45 * time.Second
const dnsCleanupRequestBuffer = 30 * time.Second
func (s *Server) applySetup(c *gin.Context) {
s.configMu.Lock()
defer s.configMu.Unlock()
if !s.setupMu.TryLock() {
s.writeError(c, http.StatusConflict, "初始化正在执行,请等待当前操作完成", nil)
return
}
defer s.setupMu.Unlock()
s.configMu.RLock()
cfg := s.config
s.configMu.RUnlock()
if !isLoopbackRequest(c.Request) {
s.writeError(c, http.StatusForbidden, "首次初始化接口仅允许从本机访问", nil)
return
}
if s.config.SetupReady {
if cfg.SetupReady {
s.writeError(c, http.StatusConflict, "初始化已经完成,不能重复提交", nil)
return
}
@ -266,7 +272,7 @@ func (s *Server) applySetup(c *gin.Context) {
emailCredentialRef := strings.TrimSpace(request.EmailHTTPCredentialRef)
smtpCredentialRef := ""
if emailSMTPEnabled {
smtpCredentialRef = strings.TrimSpace(s.config.Notification.SMTPCredentialRef)
smtpCredentialRef = strings.TrimSpace(cfg.Notification.SMTPCredentialRef)
if strings.TrimSpace(request.EmailSMTPPassword) != "" {
if strings.TrimSpace(request.EmailSMTPUsername) == "" {
s.writeError(c, http.StatusBadRequest, "填写 SMTP 密码前必须配置 SMTP 用户名", nil)
@ -313,7 +319,7 @@ func (s *Server) applySetup(c *gin.Context) {
return
}
}
updated := s.config
updated := cfg
err := setup.Apply(&updated, setup.Input{
AdminUsername: request.AdminUsername,
AdminEmail: request.AdminEmail,
@ -483,7 +489,9 @@ func (s *Server) applySetup(c *gin.Context) {
if err := s.setupDNS.Consume(request.DNSVerificationToken, request.DNSProvider, request.DNSCredentialRef, testDomain); err != nil && s.logger != nil {
s.logger.Warn("初始化配置已保存,但清理 DNS 验证令牌失败", "error", err)
}
s.configMu.Lock()
s.config = updated
s.configMu.Unlock()
c.JSON(http.StatusOK, gin.H{
"initialized": true,
"message": fmt.Sprintf(

View File

@ -4,6 +4,7 @@
"version": "0.1.0",
"type": "module",
"scripts": {
"test": "node --test src/views/SetupWizard/SetupWizard.test.js src/views/Dashboard/Dashboard.test.js",
"dev": "vite",
"build": "vite build",
"preview": "vite preview"

View File

@ -0,0 +1,70 @@
import { createRenderer, nextTick } from "vue";
export const aliyunProvider = {
id: "aliyun",
name: "阿里云 DNS",
available: true,
credential_fields: [{ name: "access_key_id", label: "访问密钥", required: true }],
};
export function deferred() {
let resolve;
let reject;
const promise = new Promise((accept, fail) => {
resolve = accept;
reject = fail;
});
return { promise, resolve, reject };
}
export function verifiedResponse(token = "test-token") {
return Response.json({
verified: true,
credential_ref: "secret://test/only",
verification_token: token,
expires_at: new Date(Date.now() + 60000).toISOString(),
});
}
// 运行真实组合式逻辑和生命周期,不加载浏览器,也不连接 DNS 或业务数据库。
export async function mountComposable(t, factory, fetcher) {
const originals = new Map();
for (const [key, value] of Object.entries({
window: { setInterval: () => 1, clearInterval() {}, location: { assign() {} } },
document: { body: { style: { overflow: "" } } },
})) {
originals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
t.mock.method(globalThis, "fetch", fetcher);
const renderer = createRenderer({
createComment: () => ({}),
insert() {},
remove() {},
parentNode: () => null,
nextSibling: () => null,
});
let state;
const app = renderer.createApp({
setup() {
state = factory();
return () => null;
},
});
app.mount({});
let mounted = true;
const unmount = () => {
if (mounted) app.unmount();
mounted = false;
};
t.after(() => {
unmount();
for (const [key, descriptor] of originals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete globalThis[key];
}
});
await new Promise((resolve) => setImmediate(resolve));
await nextTick();
return { state, unmount };
}

View File

@ -37,6 +37,9 @@ export function useDashboard() {
const settingsError = ref("");
const dnsTesting = ref(false);
const dnsTestError = ref("");
const verificationClock = ref(Date.now());
let verificationTimer;
let dnsRequestVersion = 0;
const settingsForm = ref(createEmptySettingsForm());
const showCertificateImport = ref(false);
const importDomain = ref(null);
@ -194,14 +197,25 @@ export function useDashboard() {
return (
current.provider !== saved?.provider ||
!saved?.credential_configured ||
Boolean(current.test_domain.trim()) ||
Object.values(current.credentials || {}).some((value) => String(value || "").trim()) ||
Boolean(current.verification_token) ||
current.verification_state === "pending"
);
});
const dnsReadyToSave = computed(
() =>
!dnsVerificationRequired.value ||
(Boolean(settingsForm.value.dns.credential_ref) &&
Boolean(settingsForm.value.dns.verification_token)),
const dnsReadyToSave = computed(() => {
verificationClock.value;
if (dnsTesting.value) return false;
if (!dnsVerificationRequired.value) return true;
const dns = settingsForm.value.dns;
return Boolean(dns.credential_ref && dns.verification_token) &&
new Date(dns.expires_at).getTime() > Date.now();
});
watch(
() => [settingsForm.value.dns.provider, settingsForm.value.dns.test_domain, settingsForm.value.dns.credentials],
handleDNSProviderChange,
{ deep: true, flush: "sync" },
);
const defaultCertificatePath = "/ssl/{域名}/{证书类型}/cert.pem";
@ -332,6 +346,7 @@ export function useDashboard() {
credentials: {},
credential_ref: "",
verification_token: "",
expires_at: "",
test_report: null,
},
notification: {
@ -379,6 +394,7 @@ export function useDashboard() {
credentials: {},
credential_ref: "",
verification_token: "",
expires_at: "",
test_domain: "",
test_report: null,
},
@ -696,13 +712,18 @@ export function useDashboard() {
}
function handleDNSProviderChange() {
dnsRequestVersion += 1;
dnsTesting.value = false;
const dns = settingsForm.value.dns;
const saved = settings.value?.dns;
dnsTestError.value = "";
dns.credential_ref = "";
dns.verification_token = "";
dns.expires_at = "";
dns.test_report = null;
if (dns.provider && dns.provider === saved?.provider && saved.credential_configured) {
const hasNewInput = Boolean(dns.test_domain.trim()) ||
Object.values(dns.credentials || {}).some((value) => String(value || "").trim());
if (!hasNewInput && dns.provider && dns.provider === saved?.provider && saved.credential_configured) {
dns.verification_state = saved.verification_state || "verified";
return;
}
@ -715,6 +736,7 @@ export function useDashboard() {
}
async function testDNS() {
handleDNSProviderChange();
const dns = settingsForm.value.dns;
const provider = selectedDNSProvider.value;
dnsTestError.value = "";
@ -740,6 +762,8 @@ export function useDashboard() {
return;
}
dnsTesting.value = true;
dns.verification_state = "pending";
const requestVersion = dnsRequestVersion;
try {
const response = await fetchAdmin("/api/v1/dns/test", {
method: "POST",
@ -751,22 +775,25 @@ export function useDashboard() {
}),
});
const result = await response.json().catch(() => ({}));
if (requestVersion !== dnsRequestVersion) return;
dns.test_report = result.test_report || null;
if (!response.ok) {
throw new Error(result.error || "DNS API 在线测试失败");
}
dns.credential_ref = result.credential_ref || "";
dns.verification_token = result.verification_token || "";
dns.expires_at = result.expires_at || "";
dns.verification_state = "verified";
dns.credential_configured = true;
notice.value = "DNS API 在线测试通过,可以保存系统设置";
} catch (err) {
if (requestVersion !== dnsRequestVersion) return;
dns.credential_ref = "";
dns.verification_token = "";
dns.verification_state = "pending";
dnsTestError.value = err.message || "DNS API 在线测试失败";
} finally {
dnsTesting.value = false;
if (requestVersion === dnsRequestVersion) dnsTesting.value = false;
}
}
@ -778,11 +805,15 @@ export function useDashboard() {
}
function closeSettings() {
if (!settingsSaving.value) showSettings.value = false;
if (!settingsSaving.value) {
handleDNSProviderChange();
showSettings.value = false;
}
}
async function saveSettings() {
settingsError.value = "";
verificationClock.value = Date.now();
if (!dnsReadyToSave.value) {
settingsError.value = "DNS 服务商配置尚未通过在线测试,请先完成测试";
return;
@ -1061,8 +1092,15 @@ export function useDashboard() {
}
}
onMounted(loadData);
onMounted(() => {
loadData();
verificationTimer = window.setInterval(() => {
verificationClock.value = Date.now();
}, 1000);
});
onBeforeUnmount(() => {
dnsRequestVersion += 1;
if (verificationTimer) window.clearInterval(verificationTimer);
document.body.style.overflow = previousBodyOverflow;
});

View File

@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import test from "node:test";
import { nextTick } from "vue";
import { useDashboard } from "./Dashboard.js";
import { aliyunProvider, deferred, mountComposable, verifiedResponse } from "../../test-support/mount.js";
async function setup(t, request) {
const { state } = await mountComposable(t, useDashboard, async (path) => {
if (path === "/api/v1/dns/test") return request();
if (path.endsWith("/dns/providers")) return Response.json({ items: [aliyunProvider] });
if (path.endsWith("/settings")) return Response.json({ dns: { provider: "aliyun", credential_configured: true, verification_state: "verified" } });
return Response.json({ items: [] });
});
state.openSettings();
await nextTick();
return state;
}
async function fill(state) {
state.settingsForm.value.dns.credentials = { access_key_id: "fake-key" };
state.settingsForm.value.dns.test_domain = "example.test";
await nextTick();
}
test("已保存凭据不变可保存,输入新凭据必须重新验证", async (t) => {
const state = await setup(t, () => verifiedResponse());
assert.equal(state.dnsReadyToSave.value, true);
await fill(state);
assert.equal(state.dnsReadyToSave.value, false);
await state.testDNS();
assert.equal(state.dnsReadyToSave.value, true);
state.settingsForm.value.dns.credentials.access_key_id = "changed-key";
assert.equal(state.dnsReadyToSave.value, false);
});
test("管理设置不接受旧输入的在途 DNS 响应", async (t) => {
const pending = deferred();
const state = await setup(t, () => pending.promise);
await fill(state);
const task = state.testDNS();
state.settingsForm.value.dns.test_domain = "other.test";
await nextTick();
pending.resolve(verifiedResponse());
await task;
assert.equal(state.dnsReadyToSave.value, false);
assert.equal(state.settingsForm.value.dns.verification_token, "");
});
test("管理设置拒绝过期验证令牌", async (t) => {
const state = await setup(t, () => Response.json({ credential_ref: "secret://test/only", verification_token: "expired", expires_at: "2000-01-01T00:00:00Z" }));
await fill(state);
await state.testDNS();
assert.equal(state.dnsReadyToSave.value, false);
});
test("关闭设置后不显示旧 DNS 请求的成功提示", async (t) => {
const pending = deferred();
const state = await setup(t, () => pending.promise);
await fill(state);
const task = state.testDNS();
state.closeSettings();
pending.resolve(verifiedResponse());
await task;
assert.equal(state.settingsForm.value.dns.verification_token, "");
assert.equal(state.notice.value, "");
});
test("管理设置新请求成功后旧失败不能删除令牌", async (t) => {
const first = deferred();
const second = deferred();
let count = 0;
const state = await setup(t, () => (++count === 1 ? first.promise : second.promise));
await fill(state);
const oldTask = state.testDNS();
const newTask = state.testDNS();
second.resolve(verifiedResponse("new-token"));
await newTask;
first.reject(new Error("旧请求失败"));
await oldTask;
assert.equal(state.settingsForm.value.dns.verification_token, "new-token");
assert.equal(state.dnsTestError.value, "");
assert.equal(state.dnsReadyToSave.value, true);
});

View File

@ -41,6 +41,7 @@ export function useSetupWizard(props, emit) {
const emailAutofillElement = ref(null);
let emailVisibleEdited = false;
let verificationTimer;
let dnsRequestVersion = 0;
let emailAutofillTimer;
const targetType = ref(props.status.web_public_ip ? "ip" : "hostname");
const form = ref({
@ -141,6 +142,8 @@ export function useSetupWizard(props, emit) {
});
function invalidateDNSVerification() {
dnsRequestVersion += 1;
dnsTesting.value = false;
dnsVerification.value = null;
dnsTestError.value = "";
}
@ -415,6 +418,7 @@ export function useSetupWizard(props, emit) {
}
dnsTesting.value = true;
const requestVersion = dnsRequestVersion;
try {
const response = await fetch("/api/v1/setup/dns/test", {
method: "POST",
@ -426,15 +430,17 @@ export function useSetupWizard(props, emit) {
}),
});
const result = await response.json().catch(() => ({}));
if (requestVersion !== dnsRequestVersion) return;
if (result.test_report) {
dnsVerification.value = { test_report: result.test_report };
}
if (!response.ok) throw new Error(result.error || "DNS API 在线验证失败");
dnsVerification.value = result;
} catch (err) {
if (requestVersion !== dnsRequestVersion) return;
dnsTestError.value = err.message || "DNS API 在线验证失败";
} finally {
dnsTesting.value = false;
if (requestVersion === dnsRequestVersion) dnsTesting.value = false;
}
}
@ -773,10 +779,11 @@ export function useSetupWizard(props, emit) {
dnsCredentials.value = {};
invalidateDNSVerification();
},
{ flush: "sync" },
);
watch(dnsCredentials, invalidateDNSVerification, { deep: true });
watch(dnsTestDomain, invalidateDNSVerification);
watch(dnsCredentials, invalidateDNSVerification, { deep: true, flush: "sync" });
watch(dnsTestDomain, invalidateDNSVerification, { flush: "sync" });
onMounted(() => {
loadDNSProviders();
@ -789,6 +796,7 @@ export function useSetupWizard(props, emit) {
});
onUnmounted(() => {
invalidateDNSVerification();
if (verificationTimer) window.clearInterval(verificationTimer);
if (emailAutofillTimer) window.clearInterval(emailAutofillTimer);
});

View File

@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import test from "node:test";
import { nextTick } from "vue";
import { useSetupWizard } from "./SetupWizard.js";
import { aliyunProvider, deferred, mountComposable, verifiedResponse } from "../../test-support/mount.js";
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());
state.dnsCredentials.value = { access_key_id: "fake-key" };
state.dnsTestDomain.value = "example.test";
await nextTick();
return { state, unmount };
}
test("初始化不接受输入修改前的在途 DNS 响应", async (t) => {
const pending = deferred();
const { state } = await setup(t, () => pending.promise);
const task = state.testDNS();
state.dnsCredentials.value.access_key_id = "changed-key";
await nextTick();
pending.resolve(verifiedResponse());
await task;
assert.equal(state.dnsVerificationValid.value, false);
assert.equal(state.dnsVerification.value, null);
});
test("初始化仅接受最新请求,旧失败不能覆盖新成功", async (t) => {
const first = deferred();
const second = deferred();
let count = 0;
const { state } = await setup(t, () => (++count === 1 ? first.promise : second.promise));
const oldTask = state.testDNS();
const newTask = state.testDNS();
second.resolve(verifiedResponse("new-token"));
await newTask;
first.reject(new Error("旧请求失败"));
await oldTask;
assert.equal(state.dnsVerification.value.verification_token, "new-token");
assert.equal(state.dnsTestError.value, "");
});
test("初始化当前 DNS 成功有效,修改域名立即失效", async (t) => {
const { state } = await setup(t, () => verifiedResponse());
await state.testDNS();
assert.equal(state.dnsVerificationValid.value, true);
state.dnsTestDomain.value = "other.test";
assert.equal(state.dnsVerificationValid.value, false);
});
test("初始化卸载后不恢复旧 DNS 验证结果", async (t) => {
const pending = deferred();
const { state, unmount } = await setup(t, () => pending.promise);
const task = state.testDNS();
unmount();
pending.resolve(verifiedResponse());
await task;
assert.equal(state.dnsVerification.value, null);
});
test("初始化旧请求结束不清除新请求的加载状态", async (t) => {
const first = deferred();
const second = deferred();
let count = 0;
const { state } = await setup(t, () => (++count === 1 ? first.promise : second.promise));
const oldTask = state.testDNS();
state.dnsTestDomain.value = "other.test";
const newTask = state.testDNS();
first.resolve(verifiedResponse());
await oldTask;
assert.equal(state.dnsTesting.value, true);
assert.equal(state.dnsVerification.value, null);
second.resolve(verifiedResponse("current"));
await newTask;
assert.equal(state.dnsTesting.value, false);
assert.equal(state.dnsVerification.value.verification_token, "current");
});