支持可配置反代HTTPS端口并统一管理与重置入口
This commit is contained in:
parent
b2b8951bcf
commit
54b19a19f8
@ -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 == "" {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
backend/internal/bootstrap/proxy_port_test.go
Normal file
27
backend/internal/bootstrap/proxy_port_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 服务器主机")
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
43
backend/internal/config/proxy_port_test.go
Normal file
43
backend/internal/config/proxy_port_test.go
Normal 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("环境变量未覆盖代理端口")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(() => ({}));
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user