保存统一Web域名恢复入口接手基线
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-16 19:17:55 +08:00
parent 74493f0d5b
commit 606861822b
18 changed files with 129 additions and 90 deletions

View File

@ -133,8 +133,8 @@ func CurrentWebDomain() string {
return cfg.WebDomain
}
// CurrentAdminRecoveryDomain 返回超级管理员本机恢复通道的部署标识域名。
// 此值不用于提供公网重置路由;未配置时沿用主邮件域名。
// CurrentAdminRecoveryDomain 仅用于读取旧配置。
// 公网一次性恢复入口统一使用 CurrentWebDomain,不再使用独立恢复域名。
func CurrentAdminRecoveryDomain() string {
if manager := CurrentManager(); manager != nil {
runtime := manager.Snapshot()

View File

@ -192,9 +192,6 @@ func validationCertificateDomains(candidate *RuntimeConfig) []string {
if candidate.Root.Domains.Web != "" {
domains = append(domains, candidate.Root.Domains.Web)
}
if candidate.Root.Domains.AdminRecovery != "" {
domains = append(domains, candidate.Root.Domains.AdminRecovery)
}
for _, domain := range candidate.Root.Domains.Accepted {
domains = append(domains, "smtp."+domain, "imap."+domain, "pop."+domain)
}
@ -204,11 +201,11 @@ func validationCertificateDomains(candidate *RuntimeConfig) []string {
}
func runtimeCertificateDomains(candidate *RuntimeConfig) []string {
return SSLCertificateDomains(candidate.Root.Domains.Primary, candidate.Root.Domains.Web, candidate.Root.Domains.Accepted, candidate.Root.Domains.AdminRecovery)
return SSLCertificateDomains(candidate.Root.Domains.Primary, candidate.Root.Domains.Web, candidate.Root.Domains.Accepted)
}
// SSLCertificateDomains 统一初始化和配置管理所需的 Web、主域、邮件协议及恢复通道域名。
func SSLCertificateDomains(primary, web string, accepted []string, adminRecovery ...string) []string {
// SSLCertificateDomains 统一初始化和配置管理所需的 Web、主域及邮件协议域名。
func SSLCertificateDomains(primary, web string, accepted []string) []string {
seen := make(map[string]bool)
var domains []string
appendDomain := func(value string) {
@ -220,9 +217,6 @@ func SSLCertificateDomains(primary, web string, accepted []string, adminRecovery
}
appendDomain(web)
appendDomain(primary)
for _, domain := range adminRecovery {
appendDomain(domain)
}
for _, domain := range accepted {
domain = strings.TrimSpace(domain)
if domain == "" {

View File

@ -191,9 +191,9 @@ func TestValidateSSLMaterialRejectsDomainExpiryAndWeakKeys(t *testing.T) {
}
}
func TestSSLCertificateDomainsIncludesConfiguredAdminRecoveryDomain(t *testing.T) {
domains := SSLCertificateDomains("example.com", "mail.example.com", []string{"example.com"}, "recovery.example.com")
for _, want := range []string{"example.com", "mail.example.com", "recovery.example.com", "smtp.example.com", "imap.example.com", "pop.example.com"} {
func TestSSLCertificateDomainsIncludesWebAndMailServiceDomains(t *testing.T) {
domains := SSLCertificateDomains("example.com", "mail.example.com", []string{"example.com"})
for _, want := range []string{"example.com", "mail.example.com", "smtp.example.com", "imap.example.com", "pop.example.com"} {
found := false
for _, got := range domains {
if got == want {

View File

@ -43,13 +43,13 @@ func PublicRecoveryEnabled() bool {
return hasConfiguredRecoveryHost(hosts...)
}
// PublicMailRecoveryEnabled 仅判断主邮件域名下匿名恢复链接入口是否可用。
// PublicMailRecoveryEnabled 仅判断 Web 域名下匿名恢复链接入口是否可用。
// 旧主站恢复流程仍使用 PublicRecoveryEnabled,以保持原有兼容行为。
func PublicMailRecoveryEnabled() bool {
if !config.IsSetupReady() || !config.CurrentEmailPolicy().ExternalRecoveryEnabled {
return false
}
return hasConfiguredRecoveryHost(config.CurrentMailDomains().Primary)
return hasConfiguredRecoveryHost(config.CurrentWebDomain())
}
func hasConfiguredRecoveryHost(values ...string) bool {
@ -655,7 +655,7 @@ func CompletePasswordRecovery(ctx *context.Context, w http.ResponseWriter, req *
response.NewSuccessResponse(map[string]any{"reset": true}).FPrint(w)
}
// BeginPublicLinkPasswordRecovery 是主邮件域名 /rest/ 的无会话恢复入口。
// BeginPublicLinkPasswordRecovery 是 Web 域名 /rest/ 的无会话恢复入口。
// 所有申请结果统一返回 accepted,避免泄露账户、邮箱绑定和角色状态。
func BeginPublicLinkPasswordRecovery(w http.ResponseWriter, req *http.Request) {
writePublicRecoveryJSON(w, http.StatusOK, map[string]bool{"accepted": true})

View File

@ -121,7 +121,7 @@ func enablePublicRecoveryForTest(t *testing.T) {
func invokePublicLinkRequirements(t *testing.T, token string) (int, map[string]bool, http.Header) {
t.Helper()
request := httptest.NewRequest(http.MethodPost, "https://mail.example.com/rest/api/recovery/requirements", strings.NewReader(`{"token":"`+token+`"}`))
request := httptest.NewRequest(http.MethodPost, "https://web.example.com/rest/api/recovery/requirements", strings.NewReader(`{"token":"`+token+`"}`))
recorder := httptest.NewRecorder()
PublicLinkPasswordRecoveryRequirements(recorder, request)
result := map[string]bool{}

View File

@ -53,7 +53,7 @@ func (h *publicRecoveryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
return
}
if !isPublicRecoveryMailHost(r) {
if !isPublicRecoveryWebHost(r) {
http.NotFound(w, r)
return
}
@ -133,7 +133,7 @@ func publicRecoveryOrSessionHandler(publicRecovery, mainSite http.Handler) http.
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := normalizedPublicRecoveryPath(r.URL.Path)
if path == "/public-password-recovery" || isPublicRecoveryReservedPath(path) ||
(isPublicRecoveryMailHost(r) && isPublicRecoveryAssetPath(path)) {
(controllers.PublicMailRecoveryEnabled() && isPublicRecoveryWebHost(r) && isPublicRecoveryAssetPath(path)) {
publicRecovery.ServeHTTP(w, r)
return
}
@ -213,7 +213,7 @@ func isPublicRecoveryOrigin(value string) bool {
if err != nil || u.User != nil || !strings.EqualFold(u.Scheme, "https") || u.Host == "" {
return false
}
expectedHost, expectedPort, ok := configuredHTTPSAuthority(config.CurrentMailDomains().Primary)
expectedHost, expectedPort, ok := configuredHTTPSAuthority(config.CurrentWebDomain())
if !ok {
return false
}
@ -221,8 +221,8 @@ func isPublicRecoveryOrigin(value string) bool {
return ok && originHost == expectedHost && originPort == expectedPort
}
func isPublicRecoveryMailHost(r *http.Request) bool {
return requestMatchesHTTPSAuthority(r, config.CurrentMailDomains().Primary)
func isPublicRecoveryWebHost(r *http.Request) bool {
return requestMatchesHTTPSAuthority(r, config.CurrentWebDomain())
}
func isCurrentWebHost(r *http.Request) bool {
@ -255,7 +255,7 @@ func currentHTTPSPort() int {
}
func publicRecoveryBaseURL() (string, bool) {
return configuredHTTPSURL(config.CurrentMailDomains().Primary, "/rest/", "")
return configuredHTTPSURL(config.CurrentWebDomain(), "/rest/", "")
}
func publicRecoveryLoginURL() (string, bool) {

View File

@ -38,7 +38,7 @@ func publicRecoveryRequest(method, target string) *http.Request {
return request
}
func TestPublicRecoveryPageIsServedOnlyFromMailDomain(t *testing.T) {
func TestPublicRecoveryPageIsServedOnlyFromWebDomain(t *testing.T) {
configurePublicRecoveryRouteTest(t)
handler, err := newPublicRecoveryHandler()
if err != nil {
@ -46,9 +46,9 @@ func TestPublicRecoveryPageIsServedOnlyFromMailDomain(t *testing.T) {
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://mx.example.com/rest/"))
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://web.example.com/rest/"))
if recorder.Code != http.StatusOK {
t.Fatalf("mail recovery page returned %d", recorder.Code)
t.Fatalf("web recovery page returned %d", recorder.Code)
}
if !strings.Contains(recorder.Body.String(), `data-login-url="https://web.example.com/#/login"`) {
t.Fatal("mail recovery page did not contain the configured web login URL")
@ -58,9 +58,9 @@ func TestPublicRecoveryPageIsServedOnlyFromMailDomain(t *testing.T) {
}
recorder = httptest.NewRecorder()
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://web.example.com/rest/"))
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://mx.example.com/rest/"))
if recorder.Code != http.StatusNotFound {
t.Fatalf("web domain recovery page returned %d, want 404", recorder.Code)
t.Fatalf("mail domain recovery page returned %d, want 404", recorder.Code)
}
}
@ -84,7 +84,7 @@ func TestPublicRecoveryServesUnderscorePrefixedBuildAssets(t *testing.T) {
}
recorder := httptest.NewRecorder()
request := publicRecoveryRequest(http.MethodGet, "https://mx.example.com/"+matches[0])
request := publicRecoveryRequest(http.MethodGet, "https://web.example.com/"+matches[0])
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("underscore-prefixed asset returned %d, want %d", recorder.Code, http.StatusOK)
@ -100,14 +100,14 @@ func TestPublicRecoveryPageRequiresConfiguredHTTPSPort(t *testing.T) {
}
allowed := httptest.NewRecorder()
handler.ServeHTTP(allowed, publicRecoveryRequest(http.MethodGet, "https://mx.example.com:8443/rest/"))
handler.ServeHTTP(allowed, publicRecoveryRequest(http.MethodGet, "https://web.example.com:8443/rest/"))
if allowed.Code != http.StatusOK {
t.Fatalf("configured HTTPS port returned %d, want %d", allowed.Code, http.StatusOK)
}
for _, target := range []string{
"https://mx.example.com/rest/",
"https://mx.example.com:9443/rest/",
"https://web.example.com/rest/",
"https://web.example.com:9443/rest/",
} {
t.Run(target, func(t *testing.T) {
recorder := httptest.NewRecorder()
@ -128,7 +128,7 @@ func TestPublicRecoveryLoginEntryRedirectsOnlyFromWebDomain(t *testing.T) {
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://web.example.com/public-password-recovery"))
if recorder.Code != http.StatusFound || recorder.Header().Get("Location") != "https://mx.example.com/rest/" {
if recorder.Code != http.StatusFound || recorder.Header().Get("Location") != "https://web.example.com/rest/" {
t.Fatalf("unexpected login entry redirect: status=%d location=%q", recorder.Code, recorder.Header().Get("Location"))
}
@ -158,7 +158,7 @@ func TestPublicRecoveryAPIRejectsCookiesAndCrossSiteRequests(t *testing.T) {
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
request := publicRecoveryRequest(http.MethodPost, "https://mx.example.com/rest/api/recovery/begin")
request := publicRecoveryRequest(http.MethodPost, "https://web.example.com/rest/api/recovery/begin")
request.Header.Set("Content-Type", "application/json")
test.prepare(request)
recorder := httptest.NewRecorder()
@ -182,7 +182,7 @@ func TestPublicRecoveryAPIUsesNoMainSessionHandler(t *testing.T) {
w.WriteHeader(http.StatusNoContent)
}))
request := publicRecoveryRequest(http.MethodPost, "https://mx.example.com/rest/api/recovery/begin")
request := publicRecoveryRequest(http.MethodPost, "https://web.example.com/rest/api/recovery/begin")
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
@ -207,8 +207,56 @@ func TestPublicRecoveryStaticAssetsBypassMainSessionHandler(t *testing.T) {
}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://mx.example.com/smail-logo.png"))
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://web.example.com/smail-logo.png"))
if recorder.Code != http.StatusOK || mainCalls != 0 {
t.Fatalf("public recovery asset used main handler: status=%d calls=%d", recorder.Code, mainCalls)
}
}
func TestPublicRecoveryDoesNotInterceptUnrelatedMainSitePaths(t *testing.T) {
configurePublicRecoveryRouteTest(t)
publicRecovery, err := newPublicRecoveryHandler()
if err != nil {
t.Fatal(err)
}
mainCalls := 0
handler := publicRecoveryOrSessionHandler(publicRecovery, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mainCalls++
w.WriteHeader(http.StatusNoContent)
}))
for _, target := range []string{
"https://web.example.com/",
"https://web.example.com/api/v1/user/info",
"https://mx.example.com/assets/main.js",
} {
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, target))
if recorder.Code != http.StatusNoContent {
t.Fatalf("unrelated path %q returned %d, want %d", target, recorder.Code, http.StatusNoContent)
}
}
if mainCalls != 3 {
t.Fatalf("main handler calls = %d, want 3", mainCalls)
}
}
func TestDisabledPublicRecoveryAssetsUseMainSite(t *testing.T) {
configurePublicRecoveryRouteTest(t)
config.Instance.ExternalRecoveryEnabled = false
publicRecovery, err := newPublicRecoveryHandler()
if err != nil {
t.Fatal(err)
}
mainCalls := 0
handler := publicRecoveryOrSessionHandler(publicRecovery, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mainCalls++
w.WriteHeader(http.StatusNoContent)
}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://web.example.com/smail-logo.png"))
if recorder.Code != http.StatusNoContent || mainCalls != 1 {
t.Fatalf("disabled recovery intercepted main asset: status=%d calls=%d", recorder.Code, mainCalls)
}
}

View File

@ -142,24 +142,25 @@ func deliverVerificationWithProjectMail(challenge verification.Challenge, code s
// newPublicRecoveryLinkDelivery 固定使用项目自身邮件服务,不能走外部 SMTP 中继。
func newPublicRecoveryLinkDelivery() recovery.PublicLinkDelivery {
return func(email, token string) error {
domain := strings.TrimSpace(config.CurrentMailDomains().Primary)
if domain == "" || strings.TrimSpace(email) == "" || strings.TrimSpace(token) == "" {
mailDomain := strings.TrimSpace(config.CurrentMailDomains().Primary)
webDomain := strings.TrimSpace(config.CurrentWebDomain())
if mailDomain == "" || webDomain == "" || strings.TrimSpace(email) == "" || strings.TrimSpace(token) == "" {
return verification.ErrProjectMailUnavailable
}
link, err := publicRecoveryLink(domain, token)
link, err := publicRecoveryLink(webDomain, token)
if err != nil {
return err
}
messageID, err := parsemail.GenerateMsgID(domain)
messageID, err := parsemail.GenerateMsgID(mailDomain)
if err != nil {
return fmt.Errorf("generate public recovery message id: %w", err)
}
sender := &parsemail.User{Name: "Smail 安全中心", EmailAddress: "no-reply@" + domain}
sender := &parsemail.User{Name: "Smail 安全中心", EmailAddress: "no-reply@" + mailDomain}
message := &parsemail.Email{
From: sender, Sender: sender, To: []*parsemail.User{{EmailAddress: email}},
Subject: "Smail 密码恢复链接",
Text: []byte("您好,您正在恢复 Smail 密码。请在五分钟内打开以下一次性链接完成重置:\r\n" + link + "\r\n\r\n如果并非本人操作,请忽略此邮件。"),
MsgID: messageID,
Text: []byte("您好,您正在恢复 Smail 密码。请在五分钟内打开以下一次性链接完成重置:\r\n" + link + "\r\n\r\n如果并非本人操作,请忽略此邮件。"),
MsgID: messageID,
}
ctx := &appcontext.Context{Context: context.Background(), Values: make(map[string]any)}
if deliveryErr, _ := send.SendTriggered(ctx, message, "系统密码恢复链接"); deliveryErr != nil {
@ -169,10 +170,10 @@ func newPublicRecoveryLinkDelivery() recovery.PublicLinkDelivery {
}
}
func publicRecoveryLink(domain, token string) (string, error) {
parsed, err := url.Parse("https://" + strings.TrimSpace(domain))
func publicRecoveryLink(webDomain, token string) (string, error) {
parsed, err := url.Parse("https://" + strings.TrimSpace(webDomain))
if err != nil || parsed.Hostname() == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", fmt.Errorf("invalid primary mail domain")
return "", fmt.Errorf("invalid web domain")
}
if port := config.CurrentHTTPConfig().HTTPSPort; port > 0 && port != 443 && parsed.Port() == "" {
parsed.Host = parsed.Hostname() + fmt.Sprintf(":%d", port)

View File

@ -74,3 +74,16 @@ func TestVerificationDeliveryPreservesSettingsError(t *testing.T) {
t.Fatalf("error = %v, want wrapped settings error", err)
}
}
func TestPublicRecoveryLinkUsesWebDomain(t *testing.T) {
link, err := publicRecoveryLink("web.example.com", "one-time-token")
if err != nil {
t.Fatal(err)
}
if link != "https://web.example.com/rest/#/reset-password/one-time-token" {
t.Fatalf("public recovery link = %q", link)
}
if _, err := publicRecoveryLink("https://web.example.com/path", "one-time-token"); err == nil {
t.Fatal("invalid web domain was accepted")
}
}

View File

@ -306,7 +306,7 @@ func sslDomains(cfg *config.Config) []string {
if cfg == nil {
return nil
}
return config.SSLCertificateDomains(cfg.Domain, cfg.WebDomain, cfg.Domains, cfg.AdminRecoveryDomain)
return config.SSLCertificateDomains(cfg.Domain, cfg.WebDomain, cfg.Domains)
}
func certificateMatchesConfiguredDomains(cert *x509.Certificate, cfg *config.Config) bool {
@ -343,9 +343,6 @@ func validateWildcardConfiguration(cfg *config.Config) error {
if cfg.WebDomain != "" && !covered(cfg.WebDomain) {
return newUserCertificateError("Web 域名必须是主域名的一级子域名,泛域名证书不覆盖根域名或多级子域名")
}
if cfg.AdminRecoveryDomain != "" && !covered(cfg.AdminRecoveryDomain) {
return newUserCertificateError("超级管理员恢复域名必须是主邮件域名的一级子域名,才能由 DNS 泛域名证书覆盖")
}
for _, domain := range cfg.Domains {
if !covered("smtp."+domain) || !covered("imap."+domain) || !covered("pop."+domain) {
return newUserCertificateError("附加邮箱域名的服务地址不在主域泛域名证书覆盖范围内")

View File

@ -52,18 +52,17 @@ func TestWildcardCertificateDomains(t *testing.T) {
func TestCertificateDomainsFollowChallengeMode(t *testing.T) {
cfg := &config.Config{
Domain: "xuans.com",
WebDomain: "web.xuans.com",
AdminRecoveryDomain: "recovery.xuans.com",
Domains: []string{"xuans.com"},
Domain: "xuans.com",
WebDomain: "web.xuans.com",
Domains: []string{"xuans.com"},
}
cfg.SSLType = config.SSLTypeAutoHTTP
httpDomains := certificateDomains(cfg)
if len(httpDomains) != 6 {
t.Fatalf("got %d HTTP domains, want 6: %v", len(httpDomains), httpDomains)
if len(httpDomains) != 5 {
t.Fatalf("got %d HTTP domains, want 5: %v", len(httpDomains), httpDomains)
}
if httpDomains[0] != "web.xuans.com" || httpDomains[1] != "xuans.com" || httpDomains[2] != "recovery.xuans.com" {
if httpDomains[0] != "web.xuans.com" || httpDomains[1] != "xuans.com" || httpDomains[2] != "smtp.xuans.com" {
t.Fatalf("unexpected HTTP domains: %v", httpDomains)
}
@ -74,7 +73,7 @@ func TestCertificateDomainsFollowChallengeMode(t *testing.T) {
}
}
func TestDNSWildcardRejectsUncoveredAdminRecoveryDomain(t *testing.T) {
func TestDNSWildcardIgnoresLegacyAdminRecoveryDomain(t *testing.T) {
base := &config.Config{
Domain: "example.com",
WebDomain: "mail.example.com",
@ -83,13 +82,13 @@ func TestDNSWildcardRejectsUncoveredAdminRecoveryDomain(t *testing.T) {
AdminRecoveryDomain: "recovery.example.com",
}
if err := validateWildcardConfiguration(base); err != nil {
t.Fatalf("covered recovery domain was rejected: %v", err)
t.Fatalf("legacy recovery domain changed wildcard validation: %v", err)
}
for _, value := range []string{"example.com", "a.recovery.example.com", "recovery.other.example"} {
candidate := *base
candidate.AdminRecoveryDomain = value
if err := validateWildcardConfiguration(&candidate); err == nil {
t.Fatalf("uncovered recovery domain %q was accepted", value)
if err := validateWildcardConfiguration(&candidate); err != nil {
t.Fatalf("legacy recovery domain %q changed wildcard validation: %v", value, err)
}
}
}

View File

@ -90,15 +90,10 @@ export function readDevelopmentRuntime(root = runtimeRoot, { previous = false }
if (typeof webDomain !== 'string' || !/^[a-z0-9.-]+(?::\d+)?$/i.test(webDomain)) {
throw new Error('请先在 yunx-test/config/config.json 配置有效的 Web 域名')
}
const mailDomain = config.domains?.primary || webDomain
if (typeof mailDomain !== 'string' || !/^[a-z0-9.-]+(?::\d+)?$/i.test(mailDomain)) {
throw new Error('请先在 yunx-test/config/config.json 配置有效的主邮件域名')
}
const certificatePath = resolveDevelopmentCertificate(root, previous ? ssl.tls?.previousCertificateRef : ssl.tls?.certificateRef)
const privateKeyPath = resolveDevelopmentCertificate(root, previous ? ssl.tls?.previousPrivateKeyRef : ssl.tls?.privateKeyRef, true)
return {
origin: `https://${webDomain}`,
mailOrigin: `https://${mailDomain}`,
certificatePath,
privateKeyPath,
generation: config.generation

View File

@ -5,7 +5,7 @@
- [~] 管理员、普通用户通过已验证外部邮箱及离线安全凭据恢复密码。
- [~] 超级管理员登录后通过外部安全邮箱验证码修改密码。
- [~] 超级管理员通过部署端受保护本机通道重置密码。
- [!] 独立恢复域名与邮箱主域名联动:待确认是恢复指引入口还是新增网页重置入口,不能在确认前把本机通道暴露到公网。
- [x] 管理员、普通用户的一次性恢复页面复用现有 Web 域名和 HTTPS 证书,固定入口为 `https://Web域名/rest/`。
## 当前实现边界
@ -13,6 +13,8 @@
- 管理员邮箱恢复通过后仍需要未使用的离线恢复码。
- 离线恢复需要密保问题、密保答案和离线恢复码,不是只填写一个安全码。
- 超级管理员不能通过公开忘记密码接口恢复。登录后安全中心使用已验证外部邮箱验证码改密。
- 公开恢复邮件由 `no-reply@主邮件域名` 发送,邮件中的一次性链接使用 Web 域名,不再配置独立恢复域名或额外证书。
- 旧 `adminRecovery` 配置仅保留兼容读取,不在配置页面展示,也不参与恢复路由和证书域名计算。
- 改密成功会使旧会话和相关恢复、客户端凭据失效;测试后需要重新配置下一轮所需凭据。
## Linux 本机通道部署整改

View File

@ -88,7 +88,6 @@ const baseGroups = [
group('site', '站点与域名', '', [
field('domains.primary', '主邮件域名', 'text', { required: true, placeholder: 'example.com' }),
field('domains.web', '网页访问域名', 'text', { required: true, placeholder: 'mail.example.com' }),
field('domains.adminRecovery', '超级管理员恢复域名', 'text', { placeholder: 'recovery.example.com', hint: '留空时使用主邮件域名。该配置仅用于本机恢复通道的部署标识与证书规划,不开放公网重置页面。' }),
field('domains.accepted', '接收邮件的域名', 'list', { required: true, wide: false, hint: '需包含主邮件域名,每个域名单独填写。', placeholder: 'example.com' }),
field('logLevel', '日志级别', 'select', { options: options([['', '默认级别'], ['debug', '调试'], ['info', '信息'], ['warn', '警告'], ['error', '错误']]) })
]),
@ -327,9 +326,6 @@ export function validateFormConfig(section, config) {
for (const name of ['primary', 'web']) {
if (!validDomain(domains[name])) errors[`domains.${name}`] = '请填写有效域名,不包含协议、路径或空格'
}
if (domains.adminRecovery !== undefined && domains.adminRecovery !== '' && !validDomain(domains.adminRecovery)) {
errors['domains.adminRecovery'] = '请填写有效域名,不包含协议、路径或空格'
}
if (Array.isArray(domains.accepted) && domains.accepted.every(validDomain)) {
const accepted = domains.accepted.map(normalizedDomain)
if (new Set(accepted).size !== accepted.length) errors['domains.accepted'] = '接收域名不能重复,大小写和末尾点视为同一域名'

View File

@ -52,7 +52,6 @@ export default defineConfig(({ mode, command }) => {
const developmentTLS = command === 'serve' ? readDevelopmentTLS() : undefined
const runtime = developmentTLS?.runtime
const backendOrigin = runtime?.origin
const publicRecoveryOrigin = runtime?.mailOrigin
const frontendOrigin = runtime?.origin
return {
@ -81,9 +80,9 @@ export default defineConfig(({ mode, command }) => {
cors: true,
proxy: {
"/api": developmentBackendProxy(backendUrl, backendOrigin),
// 匿名恢复入口由后端按 Web 域名计算跳转,恢复 API 则必须保持主邮件域名的同源校验。
// 匿名恢复页面与恢复 API 均复用 Web 域名和现有 HTTPS 证书。
"/public-password-recovery": developmentBackendProxy(backendUrl, backendOrigin),
"/rest/api/recovery": developmentBackendProxy(backendUrl, publicRecoveryOrigin),
"/rest/api/recovery": developmentBackendProxy(backendUrl, backendOrigin),
"/attachments": {
target: backendUrl,
changeOrigin: true,

View File

@ -278,23 +278,18 @@ test('域名校验兼容大小写和末尾点,拒绝重复项、空白和路
const valid = { database: { type: 'sqlite' }, domains: { primary: 'Example.com.', web: 'mail.example.com', accepted: ['example.com'] } }
assert.deepEqual(validateFormConfig('base', valid), {})
const fields = getFieldGroups('base', valid).flatMap(group => group.fields)
const recovery = fields.find(field => field.path.join('.') === 'domains.adminRecovery')
assert.ok(recovery)
assert.match(recovery.hint, /不开放公网重置页面/)
assert.deepEqual(validateFormConfig('base', { ...valid, domains: { ...valid.domains, adminRecovery: '' } }), {})
assert.deepEqual(validateFormConfig('base', { ...valid, domains: { ...valid.domains, adminRecovery: 'recovery.example.com' } }), {})
assert.equal(fields.some(field => field.path.join('.') === 'domains.adminRecovery'), false)
for (const accepted of [[], ['example.com', 'Example.com.'], [''], [' https://example.com'], ['other.example.com'], 'example.com', [null]]) {
assert.ok(validateFormConfig('base', { ...valid, domains: { ...valid.domains, accepted } })['domains.accepted'])
}
for (const primary of ['https://example.com', 'example.com/path', 'bad..com', '-bad.com', '127.0.0.1', '例子.com']) {
assert.ok(validateFormConfig('base', { ...valid, domains: { ...valid.domains, primary } })['domains.primary'])
}
for (const adminRecovery of ['https://example.com', 'example.com/path', 'bad..com', '-bad.com', '127.0.0.1', '例子.com', ' recovery.example.com']) {
assert.ok(validateFormConfig('base', { ...valid, domains: { ...valid.domains, adminRecovery } })['domains.adminRecovery'])
}
const text = JSON.stringify({ ...valid, domains: { ...valid.domains, accepted: ['example.com'], accessHosts: ['192.0.2.10'], adminRecovery: '' } })
const updated = readFormConfig(updateFormConfig(text, 'base', ['domains', 'adminRecovery'], 'recovery.example.com'))
assert.equal(updated.domains.adminRecovery, 'recovery.example.com')
const text = JSON.stringify({ ...valid, domains: { ...valid.domains, accepted: ['example.com'], accessHosts: ['192.0.2.10'], adminRecovery: 'legacy.example.com' } })
const unchanged = updateFormConfig(text, 'base', ['domains', 'adminRecovery'], 'recovery.example.com')
assert.equal(unchanged, text)
const updated = readFormConfig(unchanged)
assert.equal(updated.domains.adminRecovery, 'legacy.example.com')
assert.deepEqual(updated.domains.accepted, ['example.com'])
assert.deepEqual(updated.domains.accessHosts, ['192.0.2.10'])
})

View File

@ -45,7 +45,8 @@ test('前端入口按需加载 Element Plus,并延迟设置页和写信编辑
test('开发服务器将匿名恢复入口和接口安全转发到后端', async () => {
const vite = await read('frontend/vite.config.js')
assert.match(vite, /"\/public-password-recovery": developmentBackendProxy\(backendUrl, backendOrigin\)/)
assert.match(vite, /"\/rest\/api\/recovery": developmentBackendProxy\(backendUrl, publicRecoveryOrigin\)/)
assert.match(vite, /"\/rest\/api\/recovery": developmentBackendProxy\(backendUrl, backendOrigin\)/)
assert.doesNotMatch(vite, /publicRecoveryOrigin|mailOrigin/)
})
test('开发启动必须固定运行配置根目录并集中保存日志', async () => {

View File

@ -21,7 +21,6 @@ test('开发配置只从模拟部署目录读取且不返回凭据字段', async
await writeFile(path.join(root, 'ssl/private.key'), 'key', { mode: 0o600 })
const runtime = readDevelopmentRuntime(root)
assert.equal(runtime.origin, 'https://mail.example.test')
assert.equal(runtime.mailOrigin, 'https://mail.example.test')
assert.equal(runtime.generation, 9)
assert.equal(runtime.privateKeyPath, path.join(root, 'ssl/private.key'))
assert.equal(JSON.stringify(runtime).includes('not-exported'), false)
@ -32,7 +31,7 @@ test('开发配置只从模拟部署目录读取且不返回凭据字段', async
assert.throws(() => readDevelopmentRuntime(root), /事务尚未完成/)
})
test('开发运行配置单独保留主邮件域名供匿名恢复接口校验', async () => {
test('开发恢复接口与页面统一使用 Web 域名', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'smail-runtime-recovery-domain-'))
await mkdir(path.join(root, 'config'))
await mkdir(path.join(root, 'ssl'))
@ -49,7 +48,7 @@ test('开发运行配置单独保留主邮件域名供匿名恢复接口校验',
const runtime = readDevelopmentRuntime(root)
assert.equal(runtime.origin, 'https://web.example.test')
assert.equal(runtime.mailOrigin, 'https://mail.example.test')
assert.equal('mailOrigin' in runtime, false)
})
test('源码指纹检测编辑且忽略编译嵌入目录', async () => {