diff --git a/backend/config/config.go b/backend/config/config.go index a8c3316..23a1023 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -44,6 +44,7 @@ type Config struct { HttpPort int `json:"httpPort"` //http服务端口设置,默认80 HttpsPort int `json:"httpsPort"` //https服务端口,默认443 TLSMode string `json:"tlsMode,omitempty"` + PublicHTTPSPort int `json:"publicHttpsPort,omitempty"` TrustedProxyCIDRs []string `json:"trustedProxyCidrs,omitempty"` WeChatPushAppId string `json:"weChatPushAppId"` WeChatPushSecret string `json:"weChatPushSecret"` diff --git a/backend/config/migrate.go b/backend/config/migrate.go index 658ef79..660549d 100644 --- a/backend/config/migrate.go +++ b/backend/config/migrate.go @@ -61,6 +61,7 @@ type legacyMigrationConfig struct { SpamFilterLevel int `json:"spamFilterLevel"` HttpPort int `json:"httpPort"` HttpsPort int `json:"httpsPort"` + PublicHTTPSPort int `json:"publicHttpsPort"` TLSMode string `json:"tlsMode"` TrustedProxyCIDRs []string `json:"trustedProxyCidrs"` WeChatPushAppID string `json:"weChatPushAppId"` @@ -92,6 +93,7 @@ var legacyMigrationKnownFields = map[string]struct{}{ "logLevel": {}, "domain": {}, "domains": {}, "webDomain": {}, "adminRecoveryDomain": {}, "accessHosts": {}, "dkimPrivateKeyPath": {}, "sslType": {}, "SSLPrivateKeyPath": {}, "SSLPublicKeyPath": {}, "dbDSN": {}, "dbType": {}, "httpsEnabled": {}, "spamFilterLevel": {}, "httpPort": {}, "httpsPort": {}, "tlsMode": {}, "trustedProxyCidrs": {}, + "publicHttpsPort": {}, "weChatPushAppId": {}, "weChatPushSecret": {}, "weChatPushTemplateId": {}, "weChatPushUserId": {}, "tgBotToken": {}, "tgChatId": {}, "isInit": {}, "setupState": {}, "webPushUrl": {}, "webPushToken": {}, "smtpHost": {}, "smtpPort": {}, "smtpUsername": {}, "smtpPassword": {}, "smtpSecurity": {}, "smtpFrom": {}, @@ -517,6 +519,7 @@ func legacyRuntimeConfig(root string, legacy legacyMigrationConfig) (RuntimeConf HTTP: HTTPConfig{ Port: legacy.HttpPort, HTTPSPort: httpsPort, + PublicHTTPSPort: legacy.PublicHTTPSPort, HTTPSEnabled: httpsEnabled, TLSMode: tlsMode, TrustedProxyCIDRs: append([]string(nil), legacy.TrustedProxyCIDRs...), diff --git a/backend/config/migrate_test.go b/backend/config/migrate_test.go index 46a87c2..709e9b6 100644 --- a/backend/config/migrate_test.go +++ b/backend/config/migrate_test.go @@ -43,6 +43,7 @@ func TestMigrateLegacyConfigPreservesBackupAndLoadsPartitions(t *testing.T) { "httpsEnabled": 1, "httpPort": 80, "httpsPort": 443, + "publicHttpsPort": 9443, "weChatPushAppId": "app-id", "weChatPushSecret": "wechat-secret", "weChatPushTemplateId": "template-id", @@ -79,6 +80,9 @@ func TestMigrateLegacyConfigPreservesBackupAndLoadsPartitions(t *testing.T) { if runtime.Root.Database.LocalRef != "smail.db" || runtime.Plugins.Plugins["wechat_push"].Secret.Ciphertext == "" { t.Fatalf("unexpected migrated runtime: %+v", runtime) } + if runtime.Root.HTTP.PublicHTTPSPort != 9443 || runtime.Root.HTTP.HTTPSPort != 443 { + t.Fatal("旧配置迁移丢失公网端口或修改了内部监听端口") + } manager, err := NewConfigManager(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}) if err != nil { t.Fatal(err) diff --git a/backend/config/partitioned_compatibility.go b/backend/config/partitioned_compatibility.go index c69408a..bd2a411 100644 --- a/backend/config/partitioned_compatibility.go +++ b/backend/config/partitioned_compatibility.go @@ -58,6 +58,7 @@ func runtimeCompatibility(runtime RuntimeConfig) (*Config, error) { IsInit: runtime.SetupState == SetupStateReady, HttpPort: root.HTTP.Port, HttpsPort: root.HTTP.HTTPSPort, HttpsEnabled: 2, TLSMode: root.HTTP.TLSMode, + PublicHTTPSPort: root.HTTP.PublicHTTPSPort, TrustedProxyCIDRs: append([]string(nil), root.HTTP.TrustedProxyCIDRs...), DkimPrivateKeyPath: filepath.Join(runtimepath.DKIMDirectory(ROOT_PATH), "dkim.priv"), SSLType: SSLTypeUser, diff --git a/backend/config/partitioned_compatibility_test.go b/backend/config/partitioned_compatibility_test.go index 8f10a84..b5f6f23 100644 --- a/backend/config/partitioned_compatibility_test.go +++ b/backend/config/partitioned_compatibility_test.go @@ -59,6 +59,7 @@ func TestPartitionedCompatibilityStartupAndWriteProtection(t *testing.T) { "dbType": "sqlite", "dbDSN": "./config/existing.db", "isInit": true, "sslType": "1", "SSLPublicKeyPath": "./config/ssl/site.crt", "SSLPrivateKeyPath": "./config/ssl/site.key", "httpPort": 80, "httpsPort": 443, "httpsEnabled": 1, + "publicHttpsPort": 9443, }) if _, err := MigrateLegacyConfig(root, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); err != nil { t.Fatal(err) @@ -73,6 +74,9 @@ func TestPartitionedCompatibilityStartupAndWriteProtection(t *testing.T) { t.Fatal(err) } cfg := CurrentConfig() + if cfg.PublicHTTPSPort != 9443 || cfg.HttpsPort != 443 { + t.Fatal("分区启动兼容快照丢失公网或内部端口") + } if !cfg.IsSetupReady() || cfg.DbType != DBTypeSQLite || cfg.DbDSN != filepath.Join(root, "data", "existing.db") { t.Fatal("partitioned startup lost initialization or database identity") } diff --git a/backend/config/public_https.go b/backend/config/public_https.go new file mode 100644 index 0000000..aa11299 --- /dev/null +++ b/backend/config/public_https.go @@ -0,0 +1,14 @@ +package config + +// ResolvePublicHTTPSPort 只从受控配置确定公网端口,不改变内部监听端口。 +// 未设置时保留直连端口及 HTTP 反代模式的 443 默认值;非法显式值交由调用方拒绝。 +func ResolvePublicHTTPSPort(value HTTPConfig) int { + if value.PublicHTTPSPort != 0 { + return value.PublicHTTPSPort + } + resolved := ResolveHTTPPortDefaults(value, 0) + if resolved.TLSMode == HTTPSTLSModeProxy { + return 443 + } + return resolved.HTTPSPort +} diff --git a/backend/config/public_https_test.go b/backend/config/public_https_test.go new file mode 100644 index 0000000..f322477 --- /dev/null +++ b/backend/config/public_https_test.go @@ -0,0 +1,101 @@ +package config + +import ( + "encoding/json" + "testing" +) + +func TestResolvePublicHTTPSPort(t *testing.T) { + t.Setenv("SMAIL_HTTP_TLS_MODE_OVERRIDE", "") + t.Setenv("SMAIL_HTTPS_PORT", "") + cases := []struct { + name string + config HTTPConfig + want int + }{ + {"default direct", HTTPConfig{}, 443}, + {"custom direct", HTTPConfig{HTTPSPort: 8443}, 8443}, + {"https upstream", HTTPConfig{HTTPSPort: 8002, PublicHTTPSPort: 443}, 443}, + {"custom public", HTTPConfig{HTTPSPort: 8002, PublicHTTPSPort: 9443}, 9443}, + {"http upstream", HTTPConfig{TLSMode: HTTPSTLSModeProxy}, 443}, + {"custom http upstream", HTTPConfig{TLSMode: HTTPSTLSModeProxy, PublicHTTPSPort: 9443}, 9443}, + {"invalid negative", HTTPConfig{PublicHTTPSPort: -1}, -1}, + {"invalid upper bound", HTTPConfig{PublicHTTPSPort: 65536}, 65536}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + if got := ResolvePublicHTTPSPort(test.config); got != test.want { + t.Fatalf("公网端口为 %d,预期 %d", got, test.want) + } + }) + } +} + +func TestPublicHTTPSPortValidationAndSerialization(t *testing.T) { + for _, port := range []int{0, 1, 443, 8002, 65535} { + value := HTTPConfig{Port: 8001, HTTPSPort: 8002, HTTPSEnabled: true, PublicHTTPSPort: port} + if err := validateHTTP(value); err != nil { + t.Fatal(err) + } + // 公网端口不是当前进程监听端口,不能参与本机端口冲突检查。 + if err := validateRuntimePorts(value, MailPorts{}); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + var restored HTTPConfig + if err := ParseStrict(data, &restored); err != nil { + t.Fatal(err) + } + if restored.PublicHTTPSPort != port || restored.HTTPSPort != 8002 { + t.Fatalf("序列化丢失公网或内部端口:%s", data) + } + } + for _, port := range []int{-1, 65536} { + if validateHTTP(HTTPConfig{PublicHTTPSPort: port}) == nil { + t.Fatalf("接受了非法公网端口 %d", port) + } + } +} + +func TestPublicHTTPSPortRuntimeAccess(t *testing.T) { + oldInstance, oldSnapshot, oldManager := Instance, CurrentConfig(), CurrentManager() + t.Cleanup(func() { + Instance = oldInstance + PublishConfigSnapshot(oldSnapshot) + SetRuntimeManager(oldManager) + }) + SetRuntimeManager(nil) + PublishConfigSnapshot(&Config{HttpPort: 8001, HttpsPort: 8002, PublicHTTPSPort: 443}) + if got := CurrentHTTPConfig(); got.PublicHTTPSPort != 443 || got.HTTPSPort != 8002 { + t.Fatalf("旧配置运行时映射丢失端口:%+v", got) + } + runtime := validRuntimeConfig() + runtime.Root.HTTP = HTTPConfig{Port: 8001, HTTPSPort: 8002, HTTPSEnabled: true, PublicHTTPSPort: 9443} + SetRuntimeManager(&ConfigManager{snapshots: runtimeAccessSnapshotStore{runtime: runtime}}) + if got := CurrentHTTPConfig(); got.PublicHTTPSPort != 9443 || got.HTTPSPort != 8002 { + t.Fatalf("分区配置运行时映射丢失端口:%+v", got) + } +} + +func TestWebAuthnUsesPublicHTTPSPort(t *testing.T) { + root := RootConfig{Domains: DomainConfig{Web: "mail.example.com"}, HTTP: HTTPConfig{HTTPSPort: 8002, PublicHTTPSPort: 443}} + for _, test := range []struct { + port int + want string + }{{443, "https://mail.example.com"}, {9443, "https://mail.example.com:9443"}, {0, "https://mail.example.com:8002"}} { + root.HTTP.PublicHTTPSPort = test.port + security, err := ResolveWebAuthnSecurity(root) + if err != nil || len(security.WebAuthnRPOrigins) != 1 || security.WebAuthnRPOrigins[0] != test.want { + t.Fatalf("安全密钥默认来源不匹配:%+v,%v", security, err) + } + } + root.HTTP.PublicHTTPSPort = 443 + root.Security.WebAuthnRPOrigins = []string{"https://mail.example.com:9443"} + security, err := ResolveWebAuthnSecurity(root) + if err != nil || security.WebAuthnRPOrigins[0] != root.Security.WebAuthnRPOrigins[0] { + t.Fatal("公网端口覆盖了显式配置的安全密钥来源") + } +} diff --git a/backend/config/runtime_access.go b/backend/config/runtime_access.go index dec8bec..77c5219 100644 --- a/backend/config/runtime_access.go +++ b/backend/config/runtime_access.go @@ -68,6 +68,7 @@ func CurrentHTTPConfig() HTTPConfig { return ResolveHTTPPortDefaults(HTTPConfig{ Port: cfg.HttpPort, HTTPSPort: cfg.HttpsPort, + PublicHTTPSPort: cfg.PublicHTTPSPort, HTTPSEnabled: cfg.HttpsEnabled != 2, TLSMode: mode, TrustedProxyCIDRs: append([]string(nil), cfg.TrustedProxyCIDRs...), diff --git a/backend/config/sections.go b/backend/config/sections.go index 1d2a2c3..65586be 100644 --- a/backend/config/sections.go +++ b/backend/config/sections.go @@ -45,6 +45,7 @@ type DomainConfig struct { type HTTPConfig struct { Port int `json:"port,omitempty"` HTTPSPort int `json:"httpsPort,omitempty"` + PublicHTTPSPort int `json:"publicHttpsPort,omitempty"` HTTPSEnabled bool `json:"httpsEnabled"` TLSMode string `json:"tlsMode,omitempty"` TrustedProxyCIDRs []string `json:"trustedProxyCidrs,omitempty"` diff --git a/backend/config/validator.go b/backend/config/validator.go index 532e177..add0d7b 100644 --- a/backend/config/validator.go +++ b/backend/config/validator.go @@ -239,6 +239,9 @@ func validateHTTP(http HTTPConfig) error { if http.HTTPSPort != 0 && !validPort(http.HTTPSPort) { return ErrInvalidHTTP } + if http.PublicHTTPSPort != 0 && !validPort(http.PublicHTTPSPort) { + return ErrInvalidHTTP + } if http.TLSMode != "" && http.TLSMode != HTTPSTLSModeDirect && http.TLSMode != HTTPSTLSModeProxy { return ErrInvalidHTTP } diff --git a/backend/config/webauthn.go b/backend/config/webauthn.go index 36243e0..76fec49 100644 --- a/backend/config/webauthn.go +++ b/backend/config/webauthn.go @@ -31,16 +31,12 @@ func ResolveWebAuthnSecurity(root RootConfig) (SecurityConfig, error) { } if len(security.WebAuthnRPOrigins) == 0 { origin := "https://" + web - httpConfig := ResolveHTTPPortDefaults(root.HTTP, 0) - // 反代的外部端口不等于内部 HTTP 端口,非标准外部端口需显式配置。 - if httpConfig.TLSMode != HTTPSTLSModeProxy { - port := httpConfig.HTTPSPort - if !validServicePort(port) { - return SecurityConfig{}, fmt.Errorf("安全密钥站点 HTTPS 端口无效") - } - if port != 443 { - origin += ":" + strconv.Itoa(port) - } + port := ResolvePublicHTTPSPort(root.HTTP) + if !validServicePort(port) { + return SecurityConfig{}, fmt.Errorf("安全密钥站点 HTTPS 端口无效") + } + if port != 443 { + origin += ":" + strconv.Itoa(port) } security.WebAuthnRPOrigins = []string{origin} } diff --git a/backend/controllers/setup.go b/backend/controllers/setup.go index 26b5c43..75190cd 100644 --- a/backend/controllers/setup.go +++ b/backend/controllers/setup.go @@ -40,14 +40,15 @@ func AcmeChallenge(w http.ResponseWriter, r *http.Request) { } type sslResponse struct { - Port int `json:"port"` - HTTPPort int `json:"httpPort"` - HTTPSPort int `json:"httpsPort"` - TLSMode string `json:"tlsMode"` - HTTPSEnabled bool `json:"httpsEnabled"` - WebDomain string `json:"webDomain"` - Type string `json:"type"` - Ready bool `json:"ready"` + Port int `json:"port"` + HTTPPort int `json:"httpPort"` + HTTPSPort int `json:"httpsPort"` + PublicHTTPSPort int `json:"publicHttpsPort"` + TLSMode string `json:"tlsMode"` + HTTPSEnabled bool `json:"httpsEnabled"` + WebDomain string `json:"webDomain"` + Type string `json:"type"` + Ready bool `json:"ready"` } func buildSetupSSLResponse(sslType string, cfg *config.Config, httpConfig config.HTTPConfig, webDomain string, ready bool) sslResponse { @@ -60,14 +61,15 @@ func buildSetupSSLResponse(sslType string, cfg *config.Config, httpConfig config httpsPort = 443 } return sslResponse{ - Port: httpPort, - HTTPPort: httpPort, - HTTPSPort: httpsPort, - TLSMode: httpConfig.TLSMode, - HTTPSEnabled: httpConfig.HTTPSEnabled, - WebDomain: webDomain, - Type: sslType, - Ready: ready, + Port: httpPort, + HTTPPort: httpPort, + HTTPSPort: httpsPort, + PublicHTTPSPort: config.ResolvePublicHTTPSPort(httpConfig), + TLSMode: httpConfig.TLSMode, + HTTPSEnabled: httpConfig.HTTPSEnabled, + WebDomain: webDomain, + Type: sslType, + Ready: ready, } } diff --git a/backend/controllers/setup_contract_test.go b/backend/controllers/setup_contract_test.go index c945598..97e3963 100644 --- a/backend/controllers/setup_contract_test.go +++ b/backend/controllers/setup_contract_test.go @@ -60,6 +60,15 @@ func TestBuildSetupSSLResponseUsesConfiguredProxyPorts(t *testing.T) { } } +func TestBuildSetupSSLResponseSeparatesPublicPort(t *testing.T) { + response := buildSetupSSLResponse("1", nil, config.HTTPConfig{ + Port: 8001, HTTPSPort: 8002, HTTPSEnabled: true, PublicHTTPSPort: 443, + }, "mail.example.com", true) + if response.HTTPSPort != 8002 || response.HTTPPort != 8001 || response.PublicHTTPSPort != 443 { + t.Fatalf("初始化响应混淆公网与内部端口:%+v", response) + } +} + func TestBuildSetupSSLResponseDefaultsHTTPSPort(t *testing.T) { response := buildSetupSSLResponse( config.SSLTypeAutoHTTP, diff --git a/backend/listen/http_server/public_recovery.go b/backend/listen/http_server/public_recovery.go index 206bc35..1baa492 100644 --- a/backend/listen/http_server/public_recovery.go +++ b/backend/listen/http_server/public_recovery.go @@ -242,16 +242,16 @@ func requestMatchesHTTPSAuthority(r *http.Request, configured string) bool { } func configuredHTTPSAuthority(value string) (string, string, bool) { - defaultPort := strconv.Itoa(currentHTTPSPort()) + port := currentPublicHTTPSPort() + if port < 1 || port > 65535 { + return "", "", false + } + defaultPort := strconv.Itoa(port) return normalizeAuthority(value, defaultPort) } -func currentHTTPSPort() int { - port := config.CurrentHTTPConfig().HTTPSPort - if port < 1 || port > 65535 { - return 443 - } - return port +func currentPublicHTTPSPort() int { + return config.ResolvePublicHTTPSPort(config.CurrentHTTPConfig()) } func publicRecoveryBaseURL() (string, bool) { @@ -263,6 +263,10 @@ func publicRecoveryLoginURL() (string, bool) { } func configuredHTTPSURL(value, path, fragment string) (string, bool) { + publicPort := currentPublicHTTPSPort() + if publicPort < 1 || publicPort > 65535 { + return "", false + } parsed, err := url.Parse("//" + strings.TrimSpace(value)) if err != nil || parsed.User != nil || parsed.Hostname() == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { return "", false @@ -272,8 +276,8 @@ func configuredHTTPSURL(value, path, fragment string) (string, bool) { return "", false } port := parsed.Port() - if port == "" && currentHTTPSPort() != 443 { - port = strconv.Itoa(currentHTTPSPort()) + if port == "" && publicPort != 443 { + port = strconv.Itoa(publicPort) } if port != "" { portNumber, err := strconv.Atoi(port) diff --git a/backend/listen/http_server/public_recovery_test.go b/backend/listen/http_server/public_recovery_test.go index ff82219..c6a35e6 100644 --- a/backend/listen/http_server/public_recovery_test.go +++ b/backend/listen/http_server/public_recovery_test.go @@ -119,6 +119,67 @@ func TestPublicRecoveryPageRequiresConfiguredHTTPSPort(t *testing.T) { } } +func TestPublicRecoveryUsesPublicPortBehindTLSProxy(t *testing.T) { + configurePublicRecoveryRouteTest(t) + config.Instance.HttpsPort = 8002 + config.Instance.PublicHTTPSPort = 443 + handler, err := newPublicRecoveryHandler() + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + authority string + port int + }{ + {"standard public port", "web.example.com", 443}, + {"custom public port", "web.example.com:9443", 9443}, + } { + t.Run(test.name, func(t *testing.T) { + config.Instance.PublicHTTPSPort = test.port + origin := "https://" + test.authority + request := publicRecoveryRequest(http.MethodGet, origin+"/rest/") + request.RemoteAddr = "127.0.0.1:1234" + request.Header.Set("Forwarded", "host=evil.example:8002;proto=https") + request.Header.Set("X-Forwarded-Host", "evil.example") + request.Header.Set("X-Forwarded-Port", "8002") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `data-login-url="`+origin+`/#/login"`) { + t.Fatalf("恢复页未使用公网登录地址,状态 %d", recorder.Code) + } + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, origin+"/public-password-recovery")) + if recorder.Code != http.StatusFound || recorder.Header().Get("Location") != origin+"/rest/" { + t.Fatal("恢复入口跳转携带了内部端口") + } + if !isPublicRecoveryOrigin(origin) || isPublicRecoveryOrigin("https://web.example.com:8002") || isPublicRecoveryOrigin("https://evil.example") { + t.Fatal("恢复接口的来源校验没有使用公网端口") + } + for _, target := range []string{"https://web.example.com:8002/rest/", "https://evil.example/rest/"} { + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, target)) + if recorder.Code != http.StatusNotFound { + t.Fatal("接受了内部端口或非本站域名") + } + } + }) + } +} + +func TestPublicRecoveryRejectsInvalidPublicPort(t *testing.T) { + configurePublicRecoveryRouteTest(t) + for _, port := range []int{-1, 65536} { + config.Instance.PublicHTTPSPort = port + if _, ok := publicRecoveryBaseURL(); ok { + t.Fatal("非法公网端口仍生成了恢复链接") + } + if isPublicRecoveryOrigin("https://web.example.com") { + t.Fatal("非法公网端口回退为可信来源") + } + } +} + func TestPublicRecoveryLoginEntryRedirectsOnlyFromWebDomain(t *testing.T) { configurePublicRecoveryRouteTest(t) handler, err := newPublicRecoveryHandler() diff --git a/backend/res_init/verification_delivery.go b/backend/res_init/verification_delivery.go index 58f89b4..b993cd5 100644 --- a/backend/res_init/verification_delivery.go +++ b/backend/res_init/verification_delivery.go @@ -3,7 +3,9 @@ package res_init import ( "context" "fmt" + "net" "net/url" + "strconv" "strings" "git.pcux.cn/xuans/Smail/config" @@ -175,8 +177,12 @@ func publicRecoveryLink(webDomain, token string) (string, error) { if err != nil || parsed.Hostname() == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { 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) + port := config.ResolvePublicHTTPSPort(config.CurrentHTTPConfig()) + if port < 1 || port > 65535 { + return "", fmt.Errorf("invalid public HTTPS port") + } + if port != 443 && parsed.Port() == "" { + parsed.Host = net.JoinHostPort(parsed.Hostname(), strconv.Itoa(port)) } parsed.Path = "/rest/" parsed.Fragment = "/reset-password/" + token diff --git a/backend/res_init/verification_delivery_test.go b/backend/res_init/verification_delivery_test.go index 544b919..afc1001 100644 --- a/backend/res_init/verification_delivery_test.go +++ b/backend/res_init/verification_delivery_test.go @@ -87,3 +87,33 @@ func TestPublicRecoveryLinkUsesWebDomain(t *testing.T) { t.Fatal("invalid web domain was accepted") } } + +func TestPublicRecoveryMailLinkUsesPublicHTTPSPort(t *testing.T) { + oldConfig, oldManager := config.Instance, config.CurrentManager() + config.SetRuntimeManager(nil) + config.Instance = &config.Config{HttpsPort: 8002} + t.Cleanup(func() { + config.Instance = oldConfig + config.SetRuntimeManager(oldManager) + }) + for _, test := range []struct { + port int + want string + }{ + {443, "https://web.example.com/rest/#/reset-password/one-time-token"}, + {9443, "https://web.example.com:9443/rest/#/reset-password/one-time-token"}, + {0, "https://web.example.com:8002/rest/#/reset-password/one-time-token"}, + } { + config.Instance.PublicHTTPSPort = test.port + link, err := publicRecoveryLink("web.example.com", "one-time-token") + if err != nil || link != test.want { + t.Fatalf("恢复邮件公网链接不匹配:%q,%v", link, err) + } + } + for _, port := range []int{-1, 65536} { + config.Instance.PublicHTTPSPort = port + if _, err := publicRecoveryLink("web.example.com", "one-time-token"); err == nil { + t.Fatal("非法公网端口仍生成了邮件恢复链接") + } + } +} diff --git a/docs/可信代理地址整改记录.md b/docs/可信代理地址整改记录.md index af753f4..39b74d8 100644 --- a/docs/可信代理地址整改记录.md +++ b/docs/可信代理地址整改记录.md @@ -7,7 +7,7 @@ - [✓] 明确配置的可信代理才允许传递客户端地址,空配置不隐式信任回环来源。 - [✓] 登录限流和安全验证统一使用客户端地址解析,不修改请求的原始连接地址。 - [✓] 26 个解析场景、空请求和真实隔离 Nginx 双来源测试通过。 -- [~] 控制器和 HTTP 层完整逐用例回归正在执行,最终结果以下方证据为准。 +- [✓] 控制器 85 项、HTTP 层 65 项完整逐用例回归通过;浏览器专用夹具 1 项跳过,不计作浏览器验收。 - [ ] 最新应用构建、隔离安装、可信代理配置迁移、实际登录和验证码流程、生产部署及回退验收。 本轮未清理生产数据库、未改生产程序、未调整邮件端口、未开启公共管理接口。当前生产应用仍为此前部署版本,不能把候选测试通过描述为线上 IP 限流问题已解决。 @@ -32,4 +32,6 @@ 客户端地址单元 `smail-tests-20260918-004453`:三个顶层测试通过、无跳过;包含 26 个解析子场景及两条真实 Nginx 来源链路。测试在独立网络创建自己的 Nginx 和后端,仅连接该网络中的回环地址;不读取宿主机 Nginx 配置,不连接线上后端。来自 `127.0.0.2` 和 `127.0.0.3` 的重复伪造转发头经 Nginx 覆盖后,后端仍分别识别真实来源,连接来源保持 `127.0.0.1`。 -完整回归单元:`smail-tests-20260918-004454`,使用 192 MiB 上限,逐用例独立进程,避免历史全局状态干扰;结果位于 `results-20260918-004454`。尚未据此签署最终发布验收。 +完整回归单元:`smail-tests-20260918-004454`,使用 192 MiB 上限,逐用例独立进程,避免历史全局状态干扰;结果位于 `results-20260918-004454`。控制器 85 项通过、无跳过,HTTP 层 65 项通过、浏览器专用夹具 1 项跳过;两包均无失败。单元成功退出,运行 1 分 59.158 秒,峰值 143.7 MiB,交换为零。客户端解析单元运行 403 毫秒,峰值 12 MiB,交换为零。 + +收尾直接读取远程两份 `results.json` 复核,Nginx 主进程 `578671`、Smail 主进程 `574584` 未变化,均为运行状态且自动重启计数为零。代码候选已封存为 `a4280e7` 并推送;尚未据此签署最终发布验收。 diff --git a/docs/生产整改验收清单.md b/docs/生产整改验收清单.md index 2d0ec33..0e95a42 100644 --- a/docs/生产整改验收清单.md +++ b/docs/生产整改验收清单.md @@ -5,7 +5,7 @@ ### 当前候选:可信代理客户端地址整改 - [✓] 可信代理地址解析候选及真实隔离 Nginx 双来源验证通过,默认不信任任何代理;登录限流和安全验证共用解析,原始连接地址不变。 -- [~] 控制器和 HTTP 层完整远程回归正在执行,详情见《可信代理地址整改记录》。候选尚未构建为正式应用或部署,不能将线上代理 IP 问题标为验收完成。 +- [✓] 控制器 85 项、HTTP 层 65 项完整远程回归通过,另有 1 项浏览器夹具跳过;详情见《可信代理地址整改记录》。候选尚未构建为正式应用或部署,不能将线上代理 IP 问题标为验收完成。 - [!] 公网恢复地址仍继承内部 8002,浏览器业务流程、管理员恢复撤销及其他生产门槛保持未完成。 ### 当前状态:远程主机 2026 年 9 月 18 日凌晨无停机续期整改 diff --git a/frontend/src/views/Setup/SetupNavigation.js b/frontend/src/views/Setup/SetupNavigation.js index e6786ac..b933cbc 100644 --- a/frontend/src/views/Setup/SetupNavigation.js +++ b/frontend/src/views/Setup/SetupNavigation.js @@ -46,7 +46,10 @@ export const buildFinalURL = (sslData, webDomain, location = currentLocation()) const proxyMode = isProxyTLSMode(sslData) const configuredPort = target.port const targetPort = Number(sslData?.httpsPort) - if (!proxyMode) { + const publicPort = Number(sslData?.publicHttpsPort) + if (Number.isInteger(publicPort) && publicPort > 0 && publicPort <= 65535) { + target.port = publicPort === DEFAULT_HTTPS_PORT ? '' : String(publicPort) + } else if (!proxyMode) { target.port = targetPort > 0 && targetPort !== DEFAULT_HTTPS_PORT ? String(targetPort) : '' } else if (!isNonDefaultPort(target.protocol, configuredPort)) { target.port = '' @@ -144,4 +147,4 @@ export const probeService = async (sslData, webDomain, location = currentLocatio } } return false -} \ No newline at end of file +} diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js index 082ddc6..276b25c 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js @@ -42,6 +42,7 @@ export function getRecommendedPort(config, path) { case 'http.port': return config.http?.tlsMode === 'proxy' ? 8080 : 80 case 'http.httpsPort': + case 'http.publicHttpsPort': return 443 case 'relay.relayPort': return smtpPorts.get(relaySecurityForMail(config)) @@ -113,7 +114,8 @@ const baseGroups = [ min: 1, show: config => config.http?.tlsMode !== 'proxy' && !!config.http?.httpsEnabled, hint: '默认使用 443;需要避开端口占用时可手动修改。' - }) + }), + port('http.publicHttpsPort', '公网 HTTPS 端口', { fallback: 0 }) ]) ] diff --git a/test/frontend/config-form.test.mjs b/test/frontend/config-form.test.mjs index c545d80..cad4daa 100644 --- a/test/frontend/config-form.test.mjs +++ b/test/frontend/config-form.test.mjs @@ -13,6 +13,26 @@ const { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig } = '\nreturn { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig }' )() +test('公网 HTTPS 端口独立编辑且保留内部监听与模式切换', () => { + const config = { + domains: { primary: 'example.com', web: 'mail.example.com', accepted: ['example.com'] }, + http: { port: 8001, httpsPort: 8002, httpsEnabled: true, tlsMode: 'direct' } + } + const updated = readFormConfig(updateFormConfig(JSON.stringify(config), 'base', ['http', 'publicHttpsPort'], 443)) + assert.equal(updated.http.publicHttpsPort, 443) + assert.equal(updated.http.httpsPort, 8002) + assert.equal(updated.http.port, 8001) + assert.deepEqual(validateFormConfig('base', updated), {}) + const proxy = readFormConfig(updateFormConfig(JSON.stringify(updated), 'base', ['http', 'tlsMode'], 'proxy')) + assert.equal(proxy.http.publicHttpsPort, 443) + for (const value of [-1, 65536, 1.5, '443']) { + updated.http.publicHttpsPort = value + assert.ok(validateFormConfig('base', updated)['http.publicHttpsPort']) + } + updated.http.publicHttpsPort = 0 + assert.deepEqual(validateFormConfig('base', updated), {}) +}) + test('基础配置不再展示冗余安全选项与开关,通行密钥由系统默认解析,用户权限仍由账号设置管理', () => { const config = { domains: { web: 'mail.old.com' }, security: { webAuthnEnabled: false, externalEmailEnabled: false, webAuthnRpId: 'mail.old.com' } } const groups = getFieldGroups('base', config) diff --git a/test/frontend/public-https-port.test.mjs b/test/frontend/public-https-port.test.mjs new file mode 100644 index 0000000..eb8c25b --- /dev/null +++ b/test/frontend/public-https-port.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { compileFunction } from 'node:vm' + +const source = readFileSync(new URL('../../frontend/src/views/Setup/SetupNavigation.js', import.meta.url), 'utf8') +const { buildFinalURL } = compileFunction( + source.replaceAll('export const ', 'const ') + '\nreturn { buildFinalURL }' +)() + +test('初始化完成跳转优先使用公网端口,保持路径和页面状态', () => { + const location = new URL('https://mail.example.com/setup?step=ssl#/finish') + for (const tlsMode of ['direct', 'proxy']) { + assert.equal(buildFinalURL({ tlsMode, httpsPort: 8002, publicHttpsPort: 443 }, 'mail.example.com', location), + 'https://mail.example.com/setup?step=ssl#/finish') + assert.equal(buildFinalURL({ tlsMode, httpsPort: 8002, publicHttpsPort: 9443 }, 'mail.example.com', location), + 'https://mail.example.com:9443/setup?step=ssl#/finish') + } +}) + +test('没有公网配置时保留直连和旧反代跳转行为', () => { + const location = new URL('https://mail.example.com/') + assert.equal(buildFinalURL({ tlsMode: 'direct', httpsPort: 8443 }, 'mail.example.com', location), + 'https://mail.example.com:8443/') + assert.equal(buildFinalURL({ tlsMode: 'proxy', httpsPort: 8002 }, 'mail.example.com', location), + 'https://mail.example.com/') +})