This commit is contained in:
parent
a4280e7abc
commit
d193324e6d
@ -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"`
|
||||
|
||||
@ -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...),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
14
backend/config/public_https.go
Normal file
14
backend/config/public_https.go
Normal file
@ -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
|
||||
}
|
||||
101
backend/config/public_https_test.go
Normal file
101
backend/config/public_https_test.go
Normal file
@ -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("公网端口覆盖了显式配置的安全密钥来源")
|
||||
}
|
||||
}
|
||||
@ -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...),
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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("非法公网端口仍生成了邮件恢复链接")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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` 并推送;尚未据此签署最终发布验收。
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
### 当前候选:可信代理客户端地址整改
|
||||
|
||||
- [✓] 可信代理地址解析候选及真实隔离 Nginx 双来源验证通过,默认不信任任何代理;登录限流和安全验证共用解析,原始连接地址不变。
|
||||
- [~] 控制器和 HTTP 层完整远程回归正在执行,详情见《可信代理地址整改记录》。候选尚未构建为正式应用或部署,不能将线上代理 IP 问题标为验收完成。
|
||||
- [✓] 控制器 85 项、HTTP 层 65 项完整远程回归通过,另有 1 项浏览器夹具跳过;详情见《可信代理地址整改记录》。候选尚未构建为正式应用或部署,不能将线上代理 IP 问题标为验收完成。
|
||||
- [!] 公网恢复地址仍继承内部 8002,浏览器业务流程、管理员恢复撤销及其他生产门槛保持未完成。
|
||||
|
||||
### 当前状态:远程主机 2026 年 9 月 18 日凌晨无停机续期整改
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
])
|
||||
]
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
27
test/frontend/public-https-port.test.mjs
Normal file
27
test/frontend/public-https-port.test.mjs
Normal file
@ -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/')
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user