From 54b19a19f8129f5355d6c7196d267e53f67e7180 Mon Sep 17 00:00:00 2001 From: xuans Date: Thu, 17 Sep 2026 22:44:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=8F=AF=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=8F=8D=E4=BB=A3HTTPS=E7=AB=AF=E5=8F=A3=E5=B9=B6=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E7=AE=A1=E7=90=86=E4=B8=8E=E9=87=8D=E7=BD=AE=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/admin/server_test.go | 2 +- backend/internal/bootstrap/complete.go | 18 ++----- .../internal/bootstrap/notification_test.go | 23 +++++--- backend/internal/bootstrap/proxy_port_test.go | 27 ++++++++++ backend/internal/bootstrap/server.go | 2 +- backend/internal/cli/operations.go | 6 +++ backend/internal/cli/root.go | 4 +- backend/internal/config/config.go | 40 +++++++++++--- backend/internal/config/config_test.go | 12 +++-- backend/internal/config/proxy_port_test.go | 43 +++++++++++++++ backend/internal/httpapi/settings_api.go | 19 ++++--- backend/internal/httpapi/settings_api_test.go | 5 +- backend/internal/httpapi/setup.go | 3 ++ backend/internal/httpapi/setup_test.go | 2 +- backend/internal/setup/setup.go | 5 ++ backend/internal/setup/setup_test.go | 2 +- deploy/README.md | 54 +++++++++++++++++++ frontend/src/views/Dashboard/Dashboard.js | 2 +- frontend/src/views/Dashboard/Dashboard.vue | 4 ++ .../src/views/SetupWizard/BootstrapPayload.js | 1 + frontend/src/views/SetupWizard/SetupWizard.js | 9 ++++ .../src/views/SetupWizard/SetupWizard.vue | 12 +++++ .../SetupWizard/SetupWizardBootstrap.test.js | 23 ++++++++ 23 files changed, 268 insertions(+), 50 deletions(-) create mode 100644 backend/internal/bootstrap/proxy_port_test.go create mode 100644 backend/internal/config/proxy_port_test.go diff --git a/backend/internal/admin/server_test.go b/backend/internal/admin/server_test.go index ebfdbc8..c43a86e 100644 --- a/backend/internal/admin/server_test.go +++ b/backend/internal/admin/server_test.go @@ -69,7 +69,7 @@ func TestControllerActivatesAndStopsResetPath(t *testing.T) { if err != nil { t.Fatalf("启用密码重置入口失败:%v", err) } - if session.State.ResetURL != "https://scert.example.test/reset" || session.Code == "" { + if session.State.ResetURL != "https://scert.example.test:8443/reset" || session.Code == "" { t.Fatalf("密码重置会话内容不正确:%+v", session) } if session.State.CodeHash == session.Code || session.State.CodeHash == "" { diff --git a/backend/internal/bootstrap/complete.go b/backend/internal/bootstrap/complete.go index 08c51be..c273e55 100644 --- a/backend/internal/bootstrap/complete.go +++ b/backend/internal/bootstrap/complete.go @@ -5,11 +5,8 @@ import ( "database/sql" "errors" "fmt" - "net" - "net/url" "os" "path/filepath" - "strconv" "strings" "sync" "time" @@ -66,6 +63,7 @@ type CompleteRequest struct { AdminEmail string `json:"admin_email"` AdminPassword string `json:"admin_password"` ServerPort int `json:"server_port"` + ProxyHTTPSPort int `json:"proxy_https_port"` WebHostname string `json:"web_hostname"` WebPublicIP string `json:"web_public_ip"` WebEnabled bool `json:"web_enabled"` @@ -264,7 +262,7 @@ func (completer *Completer) Complete(ctx context.Context, request CompleteReques PropagationPollingIntervalSecs: request.PropagationPollingIntervalSecs, CertificateRoot: candidate.Paths.Certificate, WebHostname: request.WebHostname, WebPublicIP: request.WebPublicIP, - WebEnabled: request.WebEnabled, HTTPSMode: request.HTTPSMode, + WebEnabled: request.WebEnabled, HTTPSMode: request.HTTPSMode, ProxyHTTPSPort: request.ProxyHTTPSPort, } applyNotificationInput(&input, notification) if err := setup.Apply(&candidate, input); err != nil { @@ -503,17 +501,7 @@ func (completer *Completer) cleanupCredential(cleanup func() error, message stri } func managementURL(candidate config.Config) string { - host := strings.TrimSpace(candidate.Web.Hostname) - if host == "" { - host = strings.TrimSpace(candidate.Web.PublicIP) - } - if ip := net.ParseIP(strings.Trim(host, "[]")); ip != nil && ip.To4() == nil { - host = "[" + ip.String() + "]" - } - if candidate.Server.HTTPSMode != "proxy" && candidate.Server.Port != 443 { - host = net.JoinHostPort(strings.Trim(host, "[]"), strconv.Itoa(candidate.Server.Port)) - } - return (&url.URL{Scheme: "https", Host: host, Path: "/"}).String() + return candidate.ManagementURL() } func completeError(kind CompleteErrorKind, message string, err error) error { diff --git a/backend/internal/bootstrap/notification_test.go b/backend/internal/bootstrap/notification_test.go index 8d2ebe6..aa8436f 100644 --- a/backend/internal/bootstrap/notification_test.go +++ b/backend/internal/bootstrap/notification_test.go @@ -176,22 +176,29 @@ func TestManagementURLHandlesDirectAndProxyEntrypoints(t *testing.T) { for _, item := range []struct { hostname, ip, mode string port int + proxyPort int want string }{ - {"admin.example.test", "", "direct", 443, "https://admin.example.test/"}, - {"admin.example.test", "", "direct", 8443, "https://admin.example.test:8443/"}, - {"admin.example.test", "", "proxy", 8080, "https://admin.example.test/"}, - {"", "203.0.113.70", "direct", 8443, "https://203.0.113.70:8443/"}, - {"", "2001:db8::70", "direct", 443, "https://[2001:db8::70]/"}, - {"", "2001:db8::70", "direct", 8443, "https://[2001:db8::70]:8443/"}, - {"", "2001:db8::70", "proxy", 8080, "https://[2001:db8::70]/"}, + {"admin.example.test", "", "direct", 443, 8004, "https://admin.example.test/"}, + {"admin.example.test", "", "direct", 8443, 8004, "https://admin.example.test:8443/"}, + {"admin.example.test", "", "proxy", 8080, 0, "https://admin.example.test/"}, + {"admin.example.test", "", "proxy", 9004, 8004, "https://admin.example.test:8004/"}, + {"other.example.test", "", "proxy", 9004, 443, "https://other.example.test/"}, + {"", "203.0.113.70", "direct", 8443, 0, "https://203.0.113.70:8443/"}, + {"", "2001:db8::70", "direct", 443, 0, "https://[2001:db8::70]/"}, + {"", "2001:db8::70", "direct", 8443, 0, "https://[2001:db8::70]:8443/"}, + {"", "2001:db8::70", "proxy", 8080, 0, "https://[2001:db8::70]/"}, + {"", "2001:db8::70", "proxy", 9004, 8004, "https://[2001:db8::70]:8004/"}, } { candidate := config.Config{ Web: config.WebConfig{Hostname: item.hostname, PublicIP: item.ip}, - Server: config.ServerConfig{Port: item.port, HTTPSMode: item.mode}, + Server: config.ServerConfig{Port: item.port, HTTPSMode: item.mode, ProxyHTTPSPort: item.proxyPort}, } if actual := managementURL(candidate); actual != item.want { t.Fatalf("管理入口不正确:实际=%s,预期=%s", actual, item.want) } + if actual := candidate.ResetURL(); actual != item.want+"reset" { + t.Fatalf("重置入口与管理入口不一致:%s", actual) + } } } diff --git a/backend/internal/bootstrap/proxy_port_test.go b/backend/internal/bootstrap/proxy_port_test.go new file mode 100644 index 0000000..8207d97 --- /dev/null +++ b/backend/internal/bootstrap/proxy_port_test.go @@ -0,0 +1,27 @@ +package bootstrap + +import ( + "context" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/config" +) + +func TestCompleterPreservesExternalProxyPort(t *testing.T) { + fixture := newCompletionFixture(t, "admin.example.test") + request := fixture.request("admin.example.test") + request.HTTPSMode = "proxy" + request.ServerPort = 9004 + request.ProxyHTTPSPort = 8004 + result, err := fixture.completer(nil).Complete(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if result.ManagementURL != "https://admin.example.test:8004/" { + t.Fatalf("初始化结果未保留代理端口:%s", result.ManagementURL) + } + cfg, err := config.Load(fixture.configPath) + if err != nil || cfg.Server.ProxyHTTPSPort != 8004 || cfg.Server.Port != 9004 { + t.Fatalf("保存代理端口失败:%v", err) + } +} diff --git a/backend/internal/bootstrap/server.go b/backend/internal/bootstrap/server.go index 65d0200..31babd5 100644 --- a/backend/internal/bootstrap/server.go +++ b/backend/internal/bootstrap/server.go @@ -187,7 +187,7 @@ func bootstrapHandler(router *Router, cfg config.Config) (http.Handler, func()) writeBootstrapJSON(writer, http.StatusOK, map[string]any{ "mode": "bootstrap", "initialized": false, "server_port": cfg.Server.Port, "web_enabled": true, "web_hostname": cfg.Web.Hostname, "web_public_ip": cfg.Web.PublicIP, - "https_mode": cfg.Server.HTTPSMode, "certificate_root": cfg.Paths.Certificate, + "https_mode": cfg.Server.HTTPSMode, "proxy_https_port": cfg.Server.ProxyHTTPSPort, "certificate_root": cfg.Paths.Certificate, "dns_provider": dnsprovider.Aliyun, "acme_environment": cfg.ACME.Environment, "propagation_timeout_secs": cfg.ACME.PropagationTimeoutSecs, "propagation_polling_interval_secs": cfg.ACME.PropagationPollingIntervalSecs, diff --git a/backend/internal/cli/operations.go b/backend/internal/cli/operations.go index 5a69e7e..65a3fa6 100644 --- a/backend/internal/cli/operations.go +++ b/backend/internal/cli/operations.go @@ -654,6 +654,12 @@ func setConfigValue(cfg *config.Config, key, value string) error { cfg.Server.Port = parsed case "server.https_mode": cfg.Server.HTTPSMode = value + case "server.proxy_https_port": + parsed, err := parseInt() + if err != nil { + return fmt.Errorf("代理 HTTPS 端口格式无效:%w", err) + } + cfg.Server.ProxyHTTPSPort = parsed case "web.enabled": parsed, err := parseBool() if err != nil { diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index 9cd57fd..306734f 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -128,6 +128,7 @@ func newInitCommandWithDependencies(firewallManager firewall.Manager, issue init recipients, dnsProvider, dnsCredentialRef, certificateRoot string webHostname, webPublicIP, httpsMode, acmeEnvironment string serverPort, emailTimeoutSecs, emailRetryCount, emailSMTPPort int + proxyHTTPSPort int emailHTTPEnabled, emailSMTPEnabled, emailSMTPAllowPlaintextAuth, emailIncludeDeploymentDetails bool emailIncludeCertificateSerial, emailIncludeManagementURL bool dnsVerified, webEnabled bool @@ -176,7 +177,7 @@ func newInitCommandWithDependencies(firewallManager firewall.Manager, issue init EmailRetryIntervalsSecs: emailIntervals, EmailRecipients: splitValues(recipients), DNSProvider: dnsProvider, DNSCredentialRef: dnsCredentialRef, DNSVerified: dnsVerified, CertificateRoot: certificateRoot, WebHostname: webHostname, - WebPublicIP: webPublicIP, WebEnabled: webEnabled, HTTPSMode: httpsMode, + WebPublicIP: webPublicIP, WebEnabled: webEnabled, HTTPSMode: httpsMode, ProxyHTTPSPort: proxyHTTPSPort, } if !cmd.Flags().Changed("admin-username") { if err := readInteractiveInit(cmd.InOrStdin(), cmd.OutOrStdout(), &input, cfg); err != nil { @@ -218,6 +219,7 @@ func newInitCommandWithDependencies(firewallManager firewall.Manager, issue init command.Flags().StringVar(&adminEmail, "admin-email", "", "超级管理员绑定邮箱") command.Flags().StringVar(&adminPassword, "admin-password", "", "超级管理员密码(仅建议临时使用参数,优先交互输入)") command.Flags().IntVar(&serverPort, "server-port", 8080, "Scert 主服务端口") + command.Flags().IntVar(&proxyHTTPSPort, "proxy-https-port", 0, "代理公开 HTTPS 端口,0 表示默认 443") command.Flags().BoolVar(&emailHTTPEnabled, "email-http-enabled", false, "是否启用 HTTP 邮件通知") command.Flags().BoolVar(&emailSMTPEnabled, "email-smtp-enabled", false, "是否启用 SMTP 邮件通知") command.Flags().StringVar(&emailSMTPHost, "email-smtp-host", "", "SMTP 服务器主机") diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 9a59da0..c9997e2 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -42,11 +42,12 @@ type AdminConfig struct { } type ServerConfig struct { - Host string `json:"host"` - Port int `json:"port"` - HTTPSMode string `json:"https_mode"` - TLSCert string `json:"tls_cert"` - TLSKey string `json:"tls_key"` + Host string `json:"host"` + Port int `json:"port"` + HTTPSMode string `json:"https_mode"` + ProxyHTTPSPort int `json:"proxy_https_port,omitempty"` + TLSCert string `json:"tls_cert"` + TLSKey string `json:"tls_key"` } type WebConfig struct { @@ -287,6 +288,7 @@ func (c *Config) ApplyEnvironment() error { target *int }{ {"SCERT_SERVER_PORT", &c.Server.Port}, + {"SCERT_SERVER_PROXY_HTTPS_PORT", &c.Server.ProxyHTTPSPort}, {"SCERT_ACME_RENEWAL_CHECK_INTERVAL_DAYS", &c.ACME.RenewalCheckIntervalDays}, {"SCERT_ACME_RENEWAL_NEAR_EXPIRY_DAYS", &c.ACME.RenewalNearExpiryDays}, {"SCERT_ACME_RENEWAL_THRESHOLD_DAYS", &c.ACME.RenewalThresholdDays}, @@ -459,6 +461,9 @@ func (c Config) Validate() error { if c.Server.HTTPSMode != "direct" && c.Server.HTTPSMode != "proxy" { return fmt.Errorf("HTTPS 模式无效:%s", c.Server.HTTPSMode) } + if c.Server.ProxyHTTPSPort < 0 || c.Server.ProxyHTTPSPort > 65535 { + return fmt.Errorf("代理 HTTPS 端口必须为 1 至 65535,或使用 0 表示默认 443") + } if c.Database.Driver != "sqlite" && c.Database.Driver != "mysql" && c.Database.Driver != "postgres" { return fmt.Errorf("数据库驱动无效:%s", c.Database.Driver) } @@ -642,8 +647,8 @@ func databaseDSNContainsPassword(driver, dsn string) bool { return false } -// ResetURL 返回通过主 Web 服务访问密码重置入口的公开地址。 -func (c Config) ResetURL() string { +// ManagementURL 使用公开入口端口,代理模式不暴露内部监听端口。 +func (c Config) ManagementURL() string { rawHost := strings.TrimSpace(c.Web.Hostname) if rawHost == "" { rawHost = strings.TrimSpace(c.Web.PublicIP) @@ -656,7 +661,26 @@ func (c Config) ResetURL() string { if ip := net.ParseIP(host); ip != nil && ip.To4() == nil { host = "[" + host + "]" } - return (&url.URL{Scheme: "https", Host: host, Path: "/reset"}).String() + port := c.Server.Port + if c.Server.HTTPSMode == "proxy" { + port = c.Server.ProxyHTTPSPort + if port == 0 { + port = 443 + } + } + if port != 443 { + host = net.JoinHostPort(strings.Trim(host, "[]"), strconv.Itoa(port)) + } + return (&url.URL{Scheme: "https", Host: host, Path: "/"}).String() +} + +// ResetURL 与管理入口共用域名和端口,不增加独立监听服务。 +func (c Config) ResetURL() string { + address := c.ManagementURL() + if address == "" { + return "" + } + return address + "reset" } func splitResetHost(rawHost string) (string, string) { diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index f38aff8..d556e33 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -113,12 +113,12 @@ func TestValidateProtectsExternalDatabasePasswords(t *testing.T) { } } -func TestResetURLAlwaysUsesWebHostnameWithoutPort(t *testing.T) { +func TestResetURLUsesDirectEntrypointPort(t *testing.T) { cfg := Default(t.TempDir()) cfg.Web.Hostname = "scert.example.test" cfg.Server.Port = 8443 - if value := cfg.ResetURL(); value != "https://scert.example.test/reset" { - t.Fatalf("密码重置地址不得暴露主服务端口:%s", value) + if value := cfg.ResetURL(); value != "https://scert.example.test:8443/reset" { + t.Fatalf("密码重置地址应使用直接监听端口:%s", value) } cfg.Server.Port = 443 if value := cfg.ResetURL(); value != "https://scert.example.test/reset" { @@ -126,11 +126,11 @@ func TestResetURLAlwaysUsesWebHostnameWithoutPort(t *testing.T) { } } -func TestResetURLSupportsPublicIPv6WithoutPort(t *testing.T) { +func TestResetURLSupportsPublicIPv6WithPort(t *testing.T) { cfg := Default(t.TempDir()) cfg.Web.PublicIP = "2001:db8::10" cfg.Server.Port = 8443 - if value := cfg.ResetURL(); value != "https://[2001:db8::10]/reset" { + if value := cfg.ResetURL(); value != "https://[2001:db8::10]:8443/reset" { t.Fatalf("IPv6 密码重置地址不正确:%s", value) } } @@ -156,6 +156,7 @@ func TestResetURLIgnoresExplicitPortAndURLInput(t *testing.T) { cfg := Default(t.TempDir()) cfg.Web.Hostname = "https://scert.example.test:9443" cfg.Server.Port = 5200 + cfg.Server.HTTPSMode = "proxy" if value := cfg.ResetURL(); value != "https://scert.example.test/reset" { t.Fatalf("外部地址中的显式端口不得出现在重置地址中:%s", value) } @@ -165,6 +166,7 @@ func TestResetURLIgnoresPathFromWebHostname(t *testing.T) { cfg := Default(t.TempDir()) cfg.Web.Hostname = "scert.example.test/manage" cfg.Server.Port = 8443 + cfg.Server.HTTPSMode = "proxy" if value := cfg.ResetURL(); value != "https://scert.example.test/reset" { t.Fatalf("管理页面域名附带路径时重置地址不正确:%s", value) } diff --git a/backend/internal/config/proxy_port_test.go b/backend/internal/config/proxy_port_test.go new file mode 100644 index 0000000..994e603 --- /dev/null +++ b/backend/internal/config/proxy_port_test.go @@ -0,0 +1,43 @@ +package config + +import ( + "path/filepath" + "testing" +) + +func TestProxyHTTPSPortValidationAndPersistence(t *testing.T) { + for _, port := range []int{-1, 65536} { + cfg := Default(t.TempDir()) + cfg.Server.ProxyHTTPSPort = port + if err := cfg.Validate(); err == nil { + t.Fatalf("非法代理端口未被拒绝:%d", port) + } + } + for _, port := range []int{0, 1, 443, 8004, 65535} { + cfg := Default(t.TempDir()) + cfg.Server.HTTPSMode = "proxy" + cfg.Server.ProxyHTTPSPort = port + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + path := filepath.Join(cfg.Paths.Config, "scert.json") + if err := cfg.Save(path); err != nil { + t.Fatal(err) + } + loaded, err := Load(path) + if err != nil || loaded.Server.ProxyHTTPSPort != port { + t.Fatalf("代理端口保存失败:%d,%v", port, err) + } + } +} + +func TestProxyHTTPSPortEnvironment(t *testing.T) { + t.Setenv("SCERT_SERVER_PROXY_HTTPS_PORT", "8004") + cfg := Default(t.TempDir()) + if err := cfg.ApplyEnvironment(); err != nil { + t.Fatal(err) + } + if cfg.Server.ProxyHTTPSPort != 8004 { + t.Fatal("环境变量未覆盖代理端口") + } +} diff --git a/backend/internal/httpapi/settings_api.go b/backend/internal/httpapi/settings_api.go index f88fd91..62f6d60 100644 --- a/backend/internal/httpapi/settings_api.go +++ b/backend/internal/httpapi/settings_api.go @@ -24,9 +24,10 @@ type settingsView struct { } type settingsServerView struct { - Host string `json:"host"` - Port int `json:"port"` - HTTPSMode string `json:"https_mode"` + ProxyHTTPSPort int `json:"proxy_https_port"` + Host string `json:"host"` + Port int `json:"port"` + HTTPSMode string `json:"https_mode"` } type settingsWebView struct { @@ -96,9 +97,10 @@ type settingsRequest struct { } type settingsServerRequest struct { - Host *string `json:"host"` - Port *int `json:"port"` - HTTPSMode *string `json:"https_mode"` + ProxyHTTPSPort *int `json:"proxy_https_port"` + Host *string `json:"host"` + Port *int `json:"port"` + HTTPSMode *string `json:"https_mode"` } type settingsWebRequest struct { @@ -329,7 +331,7 @@ func nonRenewalACMEConfig(cfg config.ACMEConfig) config.ACMEConfig { func settingsViewFromConfig(cfg config.Config) settingsView { return settingsView{ - Server: settingsServerView{Host: cfg.Server.Host, Port: cfg.Server.Port, HTTPSMode: cfg.Server.HTTPSMode}, + Server: settingsServerView{Host: cfg.Server.Host, Port: cfg.Server.Port, HTTPSMode: cfg.Server.HTTPSMode, ProxyHTTPSPort: cfg.Server.ProxyHTTPSPort}, Web: settingsWebView{Enabled: cfg.Web.Enabled, Hostname: cfg.Web.Hostname, PublicIP: cfg.Web.PublicIP}, Paths: settingsPathsView{Certificate: cfg.Paths.Certificate}, ACME: settingsACMEView{ @@ -373,6 +375,9 @@ func applySettingsRequest(cfg *config.Config, request settingsRequest) { if request.Server.HTTPSMode != nil { cfg.Server.HTTPSMode = strings.TrimSpace(*request.Server.HTTPSMode) } + if request.Server.ProxyHTTPSPort != nil { + cfg.Server.ProxyHTTPSPort = *request.Server.ProxyHTTPSPort + } } if request.Web != nil { if request.Web.Enabled != nil { diff --git a/backend/internal/httpapi/settings_api_test.go b/backend/internal/httpapi/settings_api_test.go index 6290a1d..7a1e4c7 100644 --- a/backend/internal/httpapi/settings_api_test.go +++ b/backend/internal/httpapi/settings_api_test.go @@ -47,7 +47,7 @@ func TestSettingsAPIUpdatesSafeFieldsWithoutReturningSecrets(t *testing.T) { cookie := loginForSettingsTest(t, server) request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{ - "server": {"port": 5443}, + "server": {"port": 5443, "proxy_https_port": 8004}, "web": {"hostname": "admin.example.test"}, "acme": {"renewal_threshold_days": 10} }`)) @@ -85,6 +85,9 @@ func TestSettingsAPIUpdatesSafeFieldsWithoutReturningSecrets(t *testing.T) { if saved.Admin.PasswordHash != cfg.Admin.PasswordHash || saved.Database.DSN != cfg.Database.DSN { t.Fatal("更新设置不应覆盖管理员密码或数据库 DSN") } + if payload.Settings.Server.ProxyHTTPSPort != 8004 || saved.Server.ProxyHTTPSPort != 8004 { + t.Fatal("代理公开端口未保存或未返回") + } } func TestSettingsAPIWebAddressChangeDoesNotRequireRestart(t *testing.T) { diff --git a/backend/internal/httpapi/setup.go b/backend/internal/httpapi/setup.go index d48df79..1c81d42 100644 --- a/backend/internal/httpapi/setup.go +++ b/backend/internal/httpapi/setup.go @@ -70,6 +70,7 @@ type setupRequest struct { WebPublicIP string `json:"web_public_ip"` WebEnabled *bool `json:"web_enabled"` HTTPSMode string `json:"https_mode"` + ProxyHTTPSPort int `json:"proxy_https_port"` } type dnsTestRequest struct { @@ -122,6 +123,7 @@ func (s *Server) setupStatus(c *gin.Context) { "server_port": cfg.Server.Port, "reset_url": cfg.ResetURL(), "https_mode": cfg.Server.HTTPSMode, + "proxy_https_port": cfg.Server.ProxyHTTPSPort, "web_enabled": cfg.Web.Enabled, "web_hostname": cfg.Web.Hostname, "web_public_ip": cfg.Web.PublicIP, @@ -378,6 +380,7 @@ func (s *Server) applySetup(c *gin.Context) { WebPublicIP: request.WebPublicIP, WebEnabled: webEnabled, HTTPSMode: request.HTTPSMode, + ProxyHTTPSPort: request.ProxyHTTPSPort, }) if err != nil { s.rollbackSetupCredentialsWithLog(createdCredentialRefs) diff --git a/backend/internal/httpapi/setup_test.go b/backend/internal/httpapi/setup_test.go index 33c250e..d749ab3 100644 --- a/backend/internal/httpapi/setup_test.go +++ b/backend/internal/httpapi/setup_test.go @@ -358,7 +358,7 @@ func TestSetupApplyPersistsConfigurationWithoutPlainPassword(t *testing.T) { if !strings.Contains(response.Body.String(), `"firewall"`) || !strings.Contains(response.Body.String(), `"created":true`) { t.Fatalf("初始化响应应返回本机防火墙结果:%s", response.Body.String()) } - if !strings.Contains(response.Body.String(), `"reset_url":"https://scert.example.test/reset"`) { + if !strings.Contains(response.Body.String(), `"reset_url":"https://scert.example.test:8443/reset"`) { t.Fatalf("初始化响应应返回根据 Web 地址生成的密码重置路径:%s", response.Body.String()) } diff --git a/backend/internal/setup/setup.go b/backend/internal/setup/setup.go index a0e3bb1..c05e0f3 100644 --- a/backend/internal/setup/setup.go +++ b/backend/internal/setup/setup.go @@ -17,6 +17,7 @@ type Input struct { AdminEmail string AdminPassword string ServerPort int + ProxyHTTPSPort int EmailHTTPEnabled bool EmailSMTPEnabled bool EmailSMTPHost string @@ -74,6 +75,9 @@ func Apply(cfg *config.Config, input Input) error { if input.ServerPort < 1 || input.ServerPort > 65535 { return fmt.Errorf("主服务端口无效:%d", input.ServerPort) } + if input.ProxyHTTPSPort < 0 || input.ProxyHTTPSPort > 65535 { + return fmt.Errorf("代理 HTTPS 端口必须为 1 至 65535,或使用 0 表示默认 443") + } if strings.TrimSpace(input.DNSProvider) == "" { return fmt.Errorf("DNS 服务商不能为空") } @@ -116,6 +120,7 @@ func Apply(cfg *config.Config, input Input) error { cfg.ACME.PropagationPollingIntervalSecs = input.PropagationPollingIntervalSecs } cfg.Server.Port = input.ServerPort + cfg.Server.ProxyHTTPSPort = input.ProxyHTTPSPort cfg.Server.HTTPSMode = input.HTTPSMode cfg.Web.Enabled = input.WebEnabled cfg.Web.Hostname = strings.TrimSpace(input.WebHostname) diff --git a/backend/internal/setup/setup_test.go b/backend/internal/setup/setup_test.go index d4af884..2a91174 100644 --- a/backend/internal/setup/setup_test.go +++ b/backend/internal/setup/setup_test.go @@ -42,7 +42,7 @@ func TestApplyBuildsPendingConfig(t *testing.T) { if cfg.Admin.PasswordHash == "password123" || cfg.Admin.PasswordHash == "" { t.Fatal("管理员密码不应明文保存") } - if cfg.ResetURL() != "https://panel.example.com/reset" { + if cfg.ResetURL() != "https://panel.example.com:8080/reset" { t.Fatalf("密码重置地址生成错误:%s", cfg.ResetURL()) } if cfg.ACME.PropagationTimeoutSecs != 1200 || cfg.ACME.PropagationPollingIntervalSecs != 15 { diff --git a/deploy/README.md b/deploy/README.md index 5b5e4a0..0e90c96 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -164,6 +164,60 @@ location / { ## 开发阶段反向代理部署 +### 自定义公开端口 + +管理域名由部署配置决定,不固定为示例域名。使用非标准 HTTPS 端口时,必须设置 `server.proxy_https_port`,初始化向导和管理设置均提供对应字段。省略该字段或设为 `0` 时,代理公开端口仍为 `443`;直接模式忽略该字段并使用 `server.port`。 + +例如本次授权测试入口为 `web.apmmk.cn`,HTTP `8003` 跳转至 HTTPS `8004`,核心服务仅监听 `127.0.0.1:9004`: + +```json +{ + "server": { + "host": "127.0.0.1", + "port": 9004, + "https_mode": "proxy", + "proxy_https_port": 8004 + }, + "web": { + "hostname": "web.apmmk.cn" + } +} +``` + +```nginx +server { + listen 8003; + server_name web.apmmk.cn; + return 308 https://web.apmmk.cn:8004$request_uri; +} + +server { + listen 8004 ssl; + server_name web.apmmk.cn; + ssl_certificate /实际证书目录/fullchain.pem; + ssl_certificate_key /实际证书目录/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + location / { + proxy_pass https://127.0.0.1:9004; + proxy_ssl_server_name on; + proxy_ssl_name web.apmmk.cn; + proxy_ssl_verify on; + proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; + proxy_ssl_verify_depth 3; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +`Host` 必须保留外部端口,否则同源安全检查可能拒绝请求。跳转目标使用明确配置的域名,不直接信任来访 `Host`。证书路径须替换为实际签发路径;证书更新后需要执行配置检查并平滑重载 Nginx。安全组仅开放所需公开端口,不开放内部 `9004`,不改动其他应用占用的 `80/443`。先验证真实证书、DNS、登录、会话和重置链接,再判定部署通过。 + +### 默认端口示例 + 开发阶段可以使用测试域名 `web.xuans.com` 验证 Web 页面和反向代理链路。推荐让 Scert 只监听本机端口 `9004`,由 Nginx 监听 `9003` 接收测试访问并转发: ```text diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index eacce34..f4f6c1d 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -365,7 +365,7 @@ export function useDashboard() { function createEmptySettingsForm() { return { - server: { host: "", port: 5200, https_mode: "direct" }, + server: { host: "", port: 5200, https_mode: "direct", proxy_https_port: 0 }, web: { enabled: true, hostname: "", public_ip: "" }, paths: { certificate: "" }, acme: { diff --git a/frontend/src/views/Dashboard/Dashboard.vue b/frontend/src/views/Dashboard/Dashboard.vue index 24e96cb..7363d36 100644 --- a/frontend/src/views/Dashboard/Dashboard.vue +++ b/frontend/src/views/Dashboard/Dashboard.vue @@ -439,6 +439,10 @@ const {
+
diff --git a/frontend/src/views/SetupWizard/BootstrapPayload.js b/frontend/src/views/SetupWizard/BootstrapPayload.js index 7c31ca5..3fb5a7e 100644 --- a/frontend/src/views/SetupWizard/BootstrapPayload.js +++ b/frontend/src/views/SetupWizard/BootstrapPayload.js @@ -18,6 +18,7 @@ export function bootstrapPayload(legacy, databaseToken, environment) { web_public_ip: legacy.web_public_ip, web_enabled: legacy.web_enabled, https_mode: legacy.https_mode, + proxy_https_port: legacy.proxy_https_port, acme_environment: environment, propagation_timeout_secs: legacy.propagation_timeout_secs, propagation_polling_interval_secs: legacy.propagation_polling_interval_secs, diff --git a/frontend/src/views/SetupWizard/SetupWizard.js b/frontend/src/views/SetupWizard/SetupWizard.js index e775622..b80efc4 100644 --- a/frontend/src/views/SetupWizard/SetupWizard.js +++ b/frontend/src/views/SetupWizard/SetupWizard.js @@ -109,6 +109,7 @@ export function useSetupWizard(props, emit) { web_public_ip: props.status.web_public_ip || "", web_enabled: props.status.web_enabled !== false, https_mode: props.status.https_mode || "direct", + proxy_https_port: props.status.proxy_https_port || 443, acme_environment: props.status.acme_environment || "staging", }); @@ -673,6 +674,13 @@ export function useSetupWizard(props, emit) { } if (currentStep.value === 4) { + if (form.value.https_mode === "proxy") { + const port = Number(form.value.proxy_https_port); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + error.value = "代理公开 HTTPS 端口必须为 1 至 65535 的整数。"; + return false; + } + } if (!form.value.certificate_root.trim()) { error.value = "请填写证书部署根目录。"; return false; @@ -804,6 +812,7 @@ export function useSetupWizard(props, emit) { web_public_ip: publicIP, web_enabled: form.value.web_enabled, https_mode: form.value.https_mode, + proxy_https_port: form.value.https_mode === "proxy" ? Number(form.value.proxy_https_port) : 0, })), }); const result = await response.json().catch(() => ({})); diff --git a/frontend/src/views/SetupWizard/SetupWizard.vue b/frontend/src/views/SetupWizard/SetupWizard.vue index 55ed1d0..695d817 100644 --- a/frontend/src/views/SetupWizard/SetupWizard.vue +++ b/frontend/src/views/SetupWizard/SetupWizard.vue @@ -913,6 +913,18 @@ const { +