支持可配置反代HTTPS端口并统一管理与重置入口

This commit is contained in:
xuans 2026-09-17 22:44:19 +08:00
parent b2b8951bcf
commit 54b19a19f8
23 changed files with 268 additions and 50 deletions

View File

@ -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 == "" {

View File

@ -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 {

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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,

View File

@ -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 {

View File

@ -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 服务器主机")

View File

@ -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) {

View File

@ -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)
}

View File

@ -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("环境变量未覆盖代理端口")
}
}

View File

@ -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 {

View File

@ -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) {

View File

@ -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)

View File

@ -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())
}

View File

@ -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)

View File

@ -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 {

View File

@ -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

View File

@ -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: {

View File

@ -439,6 +439,10 @@ const {
</div>
<div class="form-grid">
<label class="field"><span>HTTPS 模式</span><select v-model="settingsForm.server.https_mode"><option value="direct">直接终止 HTTPS</option><option value="proxy">反向代理 HTTPS</option></select></label>
<label v-if="settingsForm.server.https_mode === 'proxy'" class="field">
<span>代理公开 HTTPS 端口(0 为默认 443)</span>
<input v-model.number="settingsForm.server.proxy_https_port" type="number" min="0" max="65535" required />
</label>
<label class="field"><span>证书存储路径</span><input v-model="settingsForm.paths.certificate" required /></label>
</div>
<div class="form-grid">

View File

@ -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,

View File

@ -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(() => ({}));

View File

@ -913,6 +913,18 @@ const {
</option>
</select>
</label>
<label v-if="form.https_mode === 'proxy'">
<span>代理公开 HTTPS 端口</span>
<input
id="proxy-https-port"
name="proxy_https_port"
v-model.number="form.proxy_https_port"
type="number"
min="1"
max="65535"
required
/>
</label>
</div>
<label class="confirm-row">
<input

View File

@ -7,6 +7,29 @@ function completion() {
return Response.json({ completed: true, management_url: "https://203.0.113.20:8443/" });
}
test("代理模式提交公开端口并跳转至对应入口", async (t) => {
const address = "https://admin.example.test:8004/";
const { state, submitted, emitted } = await setup(t, () => Response.json({ completed: true, management_url: address }));
state.form.value.https_mode = "proxy";
state.form.value.proxy_https_port = 8004;
state.currentStep.value = 4;
await state.handleSubmit();
assert.equal(submitted[0].proxy_https_port, 8004);
assert.equal(emitted.at(-1)[1].management_url, address);
});
for (const port of [0, -1, 65536, 8004.5, ""]) {
test(`代理模式拒绝非法公开端口 ${port}`, async (t) => {
const { state, submitted } = await setup(t);
state.form.value.https_mode = "proxy";
state.form.value.proxy_https_port = port;
state.currentStep.value = 4;
await state.handleSubmit();
assert.equal(submitted.length, 0);
assert.match(state.error.value, /代理公开 HTTPS 端口/);
});
}
async function setup(t, complete = completion, driver = "sqlite") {
const submitted = [];
const emitted = [];