From 5f62762d979f484b8339af3d0a5079c18f9f35dd Mon Sep 17 00:00:00 2001 From: smail Date: Fri, 18 Sep 2026 15:05:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8C=89=E8=AF=81=E4=B9=A6=E6=89=98=E7=AE=A1?= =?UTF-8?q?=E6=96=B9=E5=BC=8F=E7=BB=9F=E4=B8=80=E8=87=AA=E5=8A=A8=E7=BB=AD?= =?UTF-8?q?=E6=9C=9F=E7=AD=96=E7=95=A5=E5=B9=B6=E8=A1=A5=E5=85=85=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/config/ssl_policy.go | 29 ++++++- backend/config/ssl_policy_test.go | 36 +++++++++ backend/listen/cron_server/ssl_policy_test.go | 58 ++++++++++++++ backend/listen/cron_server/ssl_update.go | 2 +- backend/services/setup/ssl/ssl.go | 19 ++--- backend/services/setup/ssl/ssl_policy_test.go | 78 +++++++++++++++++++ frontend/src/i18n/i18n.js | 4 +- .../ConfigSettings/ConfigForm/ConfigForm.js | 6 +- test/frontend/config-form.test.mjs | 6 +- 9 files changed, 217 insertions(+), 21 deletions(-) create mode 100644 backend/config/ssl_policy_test.go create mode 100644 backend/listen/cron_server/ssl_policy_test.go create mode 100644 backend/services/setup/ssl/ssl_policy_test.go diff --git a/backend/config/ssl_policy.go b/backend/config/ssl_policy.go index e4b57b2..92f08bc 100644 --- a/backend/config/ssl_policy.go +++ b/backend/config/ssl_policy.go @@ -7,7 +7,30 @@ func (c *Config) UsesProxyHTTPS() bool { return c != nil && strings.EqualFold(strings.TrimSpace(c.TLSMode), HTTPSTLSModeProxy) } -// AutomaticSSLEnabled 保留原证书策略,但反代模式下禁止 Smail 自动签发和续期。 -func (c *Config) AutomaticSSLEnabled() bool { - return c != nil && !c.UsesProxyHTTPS() && (c.SSLType == SSLTypeAutoHTTP || c.SSLType == SSLTypeAutoDNS) +// EffectiveSSLType 以证书管理责任为准,仅在旧配置未记录管理方式时兼容旧类型。 +func (c *Config) EffectiveSSLType() string { + if c == nil || c.UsesProxyHTTPS() { + return SSLTypeUser + } + management := c.SSL.Management + if management == "" && c.SSL.Mode != "" { + management = EffectiveSSLManagement(c.SSL) + } + switch management { + case SSLManagementProject: + if c.SSL.Challenge == SSLChallengeDNS || (c.SSL.Challenge == "" && c.SSLType == SSLTypeAutoDNS) { + return SSLTypeAutoDNS + } + return SSLTypeAutoHTTP + case "": + if c.SSLType == SSLTypeAutoHTTP || c.SSLType == SSLTypeAutoDNS { + return c.SSLType + } + } + return SSLTypeUser +} + +// AutomaticSSLEnabled 仅允许项目自托管证书自动签发和续期。 +func (c *Config) AutomaticSSLEnabled() bool { + return c.EffectiveSSLType() != SSLTypeUser } diff --git a/backend/config/ssl_policy_test.go b/backend/config/ssl_policy_test.go new file mode 100644 index 0000000..e016106 --- /dev/null +++ b/backend/config/ssl_policy_test.go @@ -0,0 +1,36 @@ +package config + +import "testing" + +func TestSSLPolicyFollowsManagement(t *testing.T) { + for _, test := range []struct { + name string + cfg *Config + want string + }{ + {name: "nil", want: SSLTypeUser}, + {name: "empty", cfg: &Config{}, want: SSLTypeUser}, + {name: "legacy_http", cfg: &Config{SSLType: SSLTypeAutoHTTP}, want: SSLTypeAutoHTTP}, + {name: "legacy_dns", cfg: &Config{SSLType: SSLTypeAutoDNS}, want: SSLTypeAutoDNS}, + {name: "legacy_manual", cfg: &Config{SSLType: SSLTypeUser}, want: SSLTypeUser}, + {name: "legacy_upload", cfg: &Config{SSLType: SSLTypeAutoHTTP, SSL: SSLConfig{Mode: ManagedSSLModeUpload}}, want: SSLTypeUser}, + {name: "legacy_default", cfg: &Config{SSLType: SSLTypeUser, SSL: SSLConfig{Mode: ManagedSSLModeDefault}}, want: SSLTypeAutoHTTP}, + {name: "project_http", cfg: &Config{SSLType: SSLTypeAutoDNS, SSL: SSLConfig{Management: SSLManagementProject, Challenge: SSLChallengeHTTP}}, want: SSLTypeAutoHTTP}, + {name: "project_dns", cfg: &Config{SSLType: SSLTypeUser, SSL: SSLConfig{Management: SSLManagementProject, Challenge: SSLChallengeDNS}}, want: SSLTypeAutoDNS}, + {name: "project_transition", cfg: &Config{SSLType: SSLTypeUser, SSL: SSLConfig{Management: SSLManagementProject, Mode: ManagedSSLModeUpload}}, want: SSLTypeAutoHTTP}, + {name: "project_legacy_dns", cfg: &Config{SSLType: SSLTypeAutoDNS, SSL: SSLConfig{Management: SSLManagementProject}}, want: SSLTypeAutoDNS}, + {name: "custom_stale_auto", cfg: &Config{SSLType: SSLTypeAutoHTTP, SSL: SSLConfig{Management: SSLManagementCustom, Mode: ManagedSSLModeDefault}}, want: SSLTypeUser}, + {name: "web_server_stale_auto", cfg: &Config{SSLType: SSLTypeAutoDNS, SSL: SSLConfig{Management: SSLManagementWebServer}}, want: SSLTypeUser}, + {name: "proxy_project", cfg: &Config{TLSMode: " PROXY ", SSL: SSLConfig{Management: SSLManagementProject}}, want: SSLTypeUser}, + {name: "invalid_management", cfg: &Config{SSLType: SSLTypeAutoHTTP, SSL: SSLConfig{Management: "invalid"}}, want: SSLTypeUser}, + } { + t.Run(test.name, func(t *testing.T) { + if got := test.cfg.EffectiveSSLType(); got != test.want { + t.Fatalf("证书策略错误:got=%s want=%s", got, test.want) + } + if test.cfg.AutomaticSSLEnabled() != (test.want != SSLTypeUser) { + t.Fatal("自动续期开关与托管策略不一致") + } + }) + } +} diff --git a/backend/listen/cron_server/ssl_policy_test.go b/backend/listen/cron_server/ssl_policy_test.go new file mode 100644 index 0000000..0321bac --- /dev/null +++ b/backend/listen/cron_server/ssl_policy_test.go @@ -0,0 +1,58 @@ +package cron_server + +import ( + "io" + "net/http" + "strings" + "testing" + + "git.pcux.cn/xuans/Smail/config" +) + +type policyHTTPTransport struct { + calls int +} + +func (transport *policyHTTPTransport) RoundTrip(request *http.Request) (*http.Response, error) { + transport.calls++ + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok")), Header: make(http.Header), Request: request}, nil +} + +func TestMaintenanceFollowsManagementSwitch(t *testing.T) { + previousRoot, previousSnapshot := config.ROOT_PATH, config.CurrentConfig() + previousManager, previousTransport := config.CurrentManager(), http.DefaultTransport + config.ROOT_PATH = t.TempDir() + config.SetRuntimeManager(nil) + transport := &policyHTTPTransport{} + http.DefaultTransport = transport + t.Cleanup(func() { + config.ROOT_PATH = previousRoot + config.PublishConfigSnapshot(previousSnapshot) + config.SetRuntimeManager(previousManager) + http.DefaultTransport = previousTransport + }) + cfg := &config.Config{ + HttpPort: 8000, SSLType: config.SSLTypeAutoHTTP, SetupState: config.SetupStateReady, + SSL: config.SSLConfig{Management: config.SSLManagementProject}, + } + config.PublishConfigSnapshot(cfg) + if !waitHTTPReady() || transport.calls != 1 { + t.Fatal("项目托管未启用 HTTP 就绪检查") + } + for _, management := range []string{config.SSLManagementCustom, config.SSLManagementWebServer} { + cfg.SSL.Management = management + config.PublishConfigSnapshot(cfg) + if waitHTTPReady() { + t.Fatal("外部托管仍等待 HTTP 验证") + } + maintainSSL() + if transport.calls != 1 { + t.Fatal("切换为外部托管后仍发起了网络请求") + } + } + cfg.SSL.Management = config.SSLManagementProject + config.PublishConfigSnapshot(cfg) + if !waitHTTPReady() || transport.calls != 2 { + t.Fatal("切回项目托管未恢复 HTTP 就绪检查") + } +} diff --git a/backend/listen/cron_server/ssl_update.go b/backend/listen/cron_server/ssl_update.go index 7add64d..192059f 100644 --- a/backend/listen/cron_server/ssl_update.go +++ b/backend/listen/cron_server/ssl_update.go @@ -46,7 +46,7 @@ func maintainSSL() { sslCheck() return } - if cfg.SSLType == config.SSLTypeAutoHTTP && !waitHTTPReady() { + if cfg.EffectiveSSLType() == config.SSLTypeAutoHTTP && !waitHTTPReady() { return } ssl.Update(true) diff --git a/backend/services/setup/ssl/ssl.go b/backend/services/setup/ssl/ssl.go index 3b6ba10..b7c94f6 100644 --- a/backend/services/setup/ssl/ssl.go +++ b/backend/services/setup/ssl/ssl.go @@ -89,11 +89,11 @@ func GetSSL() (string, error) { if cfg.UsesProxyHTTPS() { return config.SSLTypeUser, nil } - if cfg.SSLType == "" { + if cfg.SSLType == "" && cfg.SSL.Management == "" && cfg.SSL.Mode == "" { return config.SSLTypeAutoHTTP, nil } - return cfg.SSLType, nil + return cfg.EffectiveSSLType(), nil } func SetSSL(sslType, priKey, crtKey string) error { @@ -173,6 +173,7 @@ func applyManagedSSLReference(cfg *config.Config, reference config.ManagedSSLRef return } clearLegacySSLPaths(cfg) + sslType := cfg.EffectiveSSLType() management := cfg.SSL.Management if reference.Mode == config.ManagedSSLModeDefault { management = config.SSLManagementProject @@ -188,7 +189,7 @@ func applyManagedSSLReference(cfg *config.Config, reference config.ManagedSSLRef } if reference.Mode == config.ManagedSSLModeUpload { cfg.SSL.CustomDirectory = "ssl" - } else if cfg.SSLType == config.SSLTypeAutoDNS { + } else if sslType == config.SSLTypeAutoDNS { cfg.SSL.Challenge = config.SSLChallengeDNS } else { cfg.SSL.Challenge = config.SSLChallengeHTTP @@ -330,7 +331,7 @@ func certificateMatchesConfiguredDomains(cert *x509.Certificate, cfg *config.Con // 自动 DNS 只申请主域泛域名;不覆盖的服务地址是配置错误,不能靠重复续期修复。 func validateWildcardConfiguration(cfg *config.Config) error { - if cfg == nil || cfg.SSLType != config.SSLTypeAutoDNS { + if cfg == nil || cfg.EffectiveSSLType() != config.SSLTypeAutoDNS { return nil } domains := wildcardCertificateDomains(cfg) @@ -370,7 +371,7 @@ func wildcardCertificateDomains(cfg *config.Config) []string { } func certificateDomains(cfg *config.Config) []string { - if cfg != nil && cfg.SSLType == config.SSLTypeAutoDNS { + if cfg != nil && cfg.EffectiveSSLType() == config.SSLTypeAutoDNS { return wildcardCertificateDomains(cfg) } return sslDomains(cfg) @@ -393,12 +394,12 @@ func renewCertificate(privateKey *ecdsa.PrivateKey, cfg *config.Config) error { return errors.Wrap(err) } - if cfg.SSLType == config.SSLTypeAutoHTTP { + if cfg.EffectiveSSLType() == config.SSLTypeAutoHTTP { err = client.Challenge.SetHTTP01Provider(GetHttpChallengeInstance()) if err != nil { return errors.Wrap(err) } - } else if cfg.SSLType == config.SSLTypeAutoDNS { + } else if cfg.EffectiveSSLType() == config.SSLTypeAutoDNS { err = client.Challenge.SetDNS01Provider(GetDnsChallengeInstance(), dns01.AddDNSTimeout(60*time.Minute)) if err != nil { return errors.Wrap(err) @@ -456,12 +457,12 @@ func generateCertificate(privateKey *ecdsa.PrivateKey, cfg *config.Config, newAc return errors.Wrap(err) } - if cfg.SSLType == config.SSLTypeAutoHTTP { + if cfg.EffectiveSSLType() == config.SSLTypeAutoHTTP { err = client.Challenge.SetHTTP01Provider(GetHttpChallengeInstance()) if err != nil { return errors.Wrap(err) } - } else if cfg.SSLType == config.SSLTypeAutoDNS { + } else if cfg.EffectiveSSLType() == config.SSLTypeAutoDNS { err = client.Challenge.SetDNS01Provider(GetDnsChallengeInstance(), dns01.AddDNSTimeout(60*time.Minute)) if err != nil { return errors.Wrap(err) diff --git a/backend/services/setup/ssl/ssl_policy_test.go b/backend/services/setup/ssl/ssl_policy_test.go new file mode 100644 index 0000000..db8b10f --- /dev/null +++ b/backend/services/setup/ssl/ssl_policy_test.go @@ -0,0 +1,78 @@ +package ssl + +import ( + "bytes" + "errors" + "net/http" + "os" + "path/filepath" + "testing" + + "git.pcux.cn/xuans/Smail/config" +) + +type renewalBlockedTransport struct { + calls int +} + +func (transport *renewalBlockedTransport) RoundTrip(*http.Request) (*http.Response, error) { + transport.calls++ + return nil, errors.New("测试禁止外部网络请求") +} + +func TestExternalManagementBlocksAutomaticIssuance(t *testing.T) { + previousRoot, previousInstance := config.ROOT_PATH, config.Instance + previousSnapshot, previousManager := config.CurrentConfig(), config.CurrentManager() + previousTransport := http.DefaultTransport + config.SetRuntimeManager(nil) + t.Cleanup(func() { + config.ROOT_PATH, config.Instance = previousRoot, previousInstance + config.PublishConfigSnapshot(previousSnapshot) + config.SetRuntimeManager(previousManager) + http.DefaultTransport = previousTransport + }) + for _, management := range []string{config.SSLManagementCustom, config.SSLManagementWebServer} { + for _, sslType := range []string{config.SSLTypeAutoHTTP, config.SSLTypeAutoDNS} { + t.Run(management+sslType, func(t *testing.T) { + config.ROOT_PATH = t.TempDir() + cfg := &config.Config{ + SSLType: sslType, SetupState: config.SetupStateReady, + SSL: config.SSLConfig{Management: management}, + } + if err := config.WriteConfig(cfg); err != nil { + t.Fatal(err) + } + path := filepath.Join(config.ROOT_PATH, "config", "config.json") + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + transport := &renewalBlockedTransport{} + http.DefaultTransport = transport + if selected, err := GetSSL(); err != nil || selected != config.SSLTypeUser { + t.Fatalf("外部托管仍显示自动签发:type=%s err=%v", selected, err) + } + for _, update := range []bool{false, true} { + if err := GenSSL(update); UserCertificateErrorMessage(err) != "当前证书由外部维护,不能自动申请或续期" { + t.Fatalf("外部托管未拒绝签发或续期:%v", err) + } + } + Update(true) + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(before, after) || transport.calls != 0 { + t.Fatalf("外部托管产生了配置写入或网络请求:calls=%d err=%v", transport.calls, err) + } + }) + } + } +} + +func TestProjectDNSReferencePreservesChallenge(t *testing.T) { + cfg := &config.Config{SSLType: config.SSLTypeUser, SSL: config.SSLConfig{ + Management: config.SSLManagementProject, Challenge: config.SSLChallengeDNS, + }} + applyManagedSSLReference(cfg, config.ManagedSSLReference{Mode: config.ManagedSSLModeDefault}) + if cfg.SSL.Challenge != config.SSLChallengeDNS || cfg.EffectiveSSLType() != config.SSLTypeAutoDNS { + t.Fatal("保存签发结果丢失了项目托管的 DNS 验证方式") + } +} diff --git a/frontend/src/i18n/i18n.js b/frontend/src/i18n/i18n.js index 045de8c..4ae1d51 100644 --- a/frontend/src/i18n/i18n.js +++ b/frontend/src/i18n/i18n.js @@ -124,14 +124,14 @@ const lang = { "ssl_auto_http": "HTTP请求", "ssl_auto_dns": "DNS记录", "ssl_challenge_type": "验证方式", - "ssl_manuallyf": "手动配置SSL证书", + "ssl_manuallyf": "手动配置SSL证书(不自动续期)", "challenge_typ_desc": "HTTP验证要求域名可以从公网访问80端口;无法开放80端口时,请选择DNS验证并添加页面生成的TXT记录。", "wait_desc": "请稍等", "dns_challenge_wait": "DNS传播和缓存刷新时间较长,此处可能等待10-30分钟", "ssl_key_path": "SSL 私钥路径", "ssl_crt_path": "SSL 证书路径", "ssl_auto_storage_title": "自动证书保存位置", - "ssl_auto_storage_desc": "自动申请成功后,证书由系统统一保存和轮换,不需要手动填写证书路径。", + "ssl_auto_storage_desc": "证书由项目自身托管,启用内置自动续期;到期前 30 天内检查并续期,续期时仍需完成所选验证方式。", "ssl_auto_certificate_path": "证书:./data/ssl/managed/default/current/public.crt", "ssl_auto_private_key_path": "私钥:./data/ssl/managed/default/current/private.key", "ssl_auto_account_key_path": "ACME 账户密钥:./data/ssl/account_private.pem", diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js index 276b25c..6a23537 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js @@ -169,9 +169,9 @@ const sslGroups = [ wide: true, fallback: 'custom', options: options([ - ['web_server', 'Web 服务器管理'], - ['project', '项目自身管理'], - ['custom', '自定义'] + ['web_server', '代理服务器托管(不自动续期)'], + ['project', '项目自身托管(自动续期)'], + ['custom', '手动配置(不自动续期)'] ]) }), field('tls.challenge', '证书验证方式', 'select', { diff --git a/test/frontend/config-form.test.mjs b/test/frontend/config-form.test.mjs index 6307718..f5dbacb 100644 --- a/test/frontend/config-form.test.mjs +++ b/test/frontend/config-form.test.mjs @@ -343,9 +343,9 @@ test('证书管理方式只展示三种业务模式并同步底层托管配置', assert.deepEqual(validateFormConfig('ssl', { tls }), {}) const management = getFieldGroups('ssl', { tls }).flatMap(group => group.fields).find(field => field.path.join('.') === 'tls.management') assert.deepEqual(management.options.map(option => [option.value, option.label]), [ - ['web_server', 'Web 服务器管理'], - ['project', '项目自身管理'], - ['custom', '自定义'] + ['web_server', '代理服务器托管(不自动续期)'], + ['project', '项目自身托管(自动续期)'], + ['custom', '手动配置(不自动续期)'] ]) for (const customDirectory of ['C:/certs', '/etc/ssl', '../certs', 'config/../certs', 'config\\ssl', 'config//ssl']) { assert.ok(validateFormConfig('ssl', { tls: { ...tls, customDirectory } })['tls.customDirectory'])