From fc2397935bf5bf7e7c078a256c8d85e41d20381d Mon Sep 17 00:00:00 2001 From: xuans Date: Sun, 30 Aug 2026 21:01:35 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=8E=AF=E5=A2=83=E5=8F=98?= =?UTF-8?q?=E9=87=8F=E8=A6=86=E7=9B=96=E8=BF=90=E8=A1=8C=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/README.md | 35 ++++++++++++++ backend/cmd/scert-admin/main.go | 2 +- backend/internal/cli/root.go | 2 +- backend/internal/config/config.go | 67 ++++++++++++++++++++++++++ backend/internal/config/config_test.go | 44 +++++++++++++++++ 5 files changed, 148 insertions(+), 2 deletions(-) diff --git a/backend/README.md b/backend/README.md index 0a6dd4f..4f5c872 100644 --- a/backend/README.md +++ b/backend/README.md @@ -42,6 +42,41 @@ go run ./cmd/scert serve --base-dir C:\ScertData SMTP 通知是 Scert 的出站连接,不需要为 Scert 开放 SMTP 入站端口。公网防火墙通常只需放行 Nginx 使用的 `443`,若配置 HTTP 跳转再放行 `80`;内部端口 `9004` 和 Nginx 测试端口 `9003` 都不应对公网开放。 +### systemd 环境变量覆盖 + +服务启动时可以使用 `SCERT_*` 环境变量覆盖当前进程的配置文件值,适合在 systemd 的 `EnvironmentFile` 中配置端口和部署环境。覆盖只在本次进程生效,不会修改 `scert.json`,敏感凭据仍应使用加密凭据引用,不要直接写入日志。 + +常用变量如下: + +```text +SCERT_SERVER_HOST=127.0.0.1 +SCERT_SERVER_PORT=9004 +SCERT_SERVER_HTTPS_MODE=proxy +SCERT_WEB_HOSTNAME=web.example.com +SCERT_WEB_PUBLIC_IP=192.168.2.3 +SCERT_DATABASE_DRIVER=sqlite +SCERT_DATABASE_DSN=/scert/data/scert.db +SCERT_ACME_ENVIRONMENT=staging +SCERT_ACME_ACCOUNT_EMAIL=admin@example.com +SCERT_ACME_RENEWAL_THRESHOLD_DAYS=6 +SCERT_ACME_PROPAGATION_TIMEOUT_SECS=900 +SCERT_ACME_PROPAGATION_POLLING_INTERVAL_SECS=10 +SCERT_DNS_PROVIDER=cloudflare +SCERT_DNS_CREDENTIAL_REF=secret://dns/cloudflare +SCERT_NOTIFICATION_HTTP_CREDENTIAL_REF=secret://notification/http +SCERT_NOTIFICATION_SMTP_CREDENTIAL_REF=secret://notification/smtp +``` + +例如 Scert 内部服务使用 9004、Nginx 使用 9003 时,Scert 的环境文件可以写成: + +```text +SCERT_SERVER_HOST=127.0.0.1 +SCERT_SERVER_PORT=9004 +SCERT_SERVER_HTTPS_MODE=proxy +``` + +修改环境文件后必须重启 Scert 服务;Nginx 仍按现有配置监听 9003 并代理到 `https://127.0.0.1:9004`。 + ## 测试和构建 ```powershell diff --git a/backend/cmd/scert-admin/main.go b/backend/cmd/scert-admin/main.go index 9c12754..a294110 100644 --- a/backend/cmd/scert-admin/main.go +++ b/backend/cmd/scert-admin/main.go @@ -31,7 +31,7 @@ func main() { if path == "" { path = filepath.Join(config.DefaultPaths(*baseDir).Config, "scert.json") } - cfg, err := config.Load(path) + cfg, err := config.LoadWithEnv(path) if err != nil { fmt.Fprintln(os.Stderr, "读取配置失败:", err) os.Exit(1) diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index 1984d29..a79ec50 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -855,7 +855,7 @@ func printJSON(cmd *cobra.Command, value any) error { return err } -func loadConfig() (config.Config, error) { return config.Load(resolveConfigPath()) } +func loadConfig() (config.Config, error) { return config.LoadWithEnv(resolveConfigPath()) } func resolveConfigPath() string { if configPath != "" { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 82ba5b9..2bbf878 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -209,6 +209,73 @@ func Load(path string) (Config, error) { return cfg, nil } +// LoadWithEnv 读取配置文件,并应用受控的 SCERT_* 环境变量覆盖。 +// 环境变量只影响当前进程,不会回写配置文件。 +func LoadWithEnv(path string) (Config, error) { + cfg, err := Load(path) + if err != nil { + return Config{}, err + } + if err := cfg.ApplyEnvironment(); err != nil { + return Config{}, err + } + if err := cfg.Validate(); err != nil { + return Config{}, err + } + return cfg, nil +} + +// ApplyEnvironment 将部署环境常用配置映射到当前配置。 +// 未设置的变量保持配置文件中的值,敏感值只从环境读取,不写入日志或配置文件。 +func (c *Config) ApplyEnvironment() error { + if c == nil { + return fmt.Errorf("配置不能为空") + } + stringOverrides := []struct { + name string + target *string + }{ + {"SCERT_SERVER_HOST", &c.Server.Host}, + {"SCERT_SERVER_HTTPS_MODE", &c.Server.HTTPSMode}, + {"SCERT_WEB_HOSTNAME", &c.Web.Hostname}, + {"SCERT_WEB_PUBLIC_IP", &c.Web.PublicIP}, + {"SCERT_DATABASE_DRIVER", &c.Database.Driver}, + {"SCERT_DATABASE_DSN", &c.Database.DSN}, + {"SCERT_ACME_ENVIRONMENT", &c.ACME.Environment}, + {"SCERT_ACME_ACCOUNT_EMAIL", &c.ACME.AccountEmail}, + {"SCERT_DNS_PROVIDER", &c.DNS.Provider}, + {"SCERT_DNS_CREDENTIAL_REF", &c.DNS.CredentialRef}, + {"SCERT_NOTIFICATION_HTTP_CREDENTIAL_REF", &c.Notification.HTTPCredentialRef}, + {"SCERT_NOTIFICATION_SMTP_CREDENTIAL_REF", &c.Notification.SMTPCredentialRef}, + } + for _, override := range stringOverrides { + if value, ok := os.LookupEnv(override.name); ok { + *override.target = strings.TrimSpace(value) + } + } + intOverrides := []struct { + name string + target *int + }{ + {"SCERT_SERVER_PORT", &c.Server.Port}, + {"SCERT_ACME_RENEWAL_THRESHOLD_DAYS", &c.ACME.RenewalThresholdDays}, + {"SCERT_ACME_PROPAGATION_TIMEOUT_SECS", &c.ACME.PropagationTimeoutSecs}, + {"SCERT_ACME_PROPAGATION_POLLING_INTERVAL_SECS", &c.ACME.PropagationPollingIntervalSecs}, + } + for _, override := range intOverrides { + value, ok := os.LookupEnv(override.name) + if !ok { + continue + } + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + return fmt.Errorf("环境变量 %s 必须是整数:%w", override.name, err) + } + *override.target = parsed + } + return nil +} + func (c *Config) Normalize() { if c == nil { return diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 82b9709..8307cbe 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -208,3 +208,47 @@ func TestSaveAndLoadACMEPropagationSettings(t *testing.T) { t.Fatalf("DNS 传播参数持久化不一致:%+v", loaded.ACME) } } + +func TestLoadWithEnvOverridesRuntimeSettings(t *testing.T) { + dir := t.TempDir() + cfg := Default(dir) + path := filepath.Join(dir, "config", "scert.json") + if err := cfg.Save(path); err != nil { + t.Fatalf("保存配置失败:%v", err) + } + t.Setenv("SCERT_SERVER_HOST", "127.0.0.1") + t.Setenv("SCERT_SERVER_PORT", "9004") + t.Setenv("SCERT_SERVER_HTTPS_MODE", "proxy") + t.Setenv("SCERT_WEB_PUBLIC_IP", "192.168.2.3") + t.Setenv("SCERT_ACME_PROPAGATION_TIMEOUT_SECS", "1200") + loaded, err := LoadWithEnv(path) + if err != nil { + t.Fatalf("带环境覆盖读取配置失败:%v", err) + } + if loaded.Server.Host != "127.0.0.1" || loaded.Server.Port != 9004 || loaded.Server.HTTPSMode != "proxy" { + t.Fatalf("服务运行配置覆盖不正确:%+v", loaded.Server) + } + if loaded.Web.PublicIP != "192.168.2.3" || loaded.ACME.PropagationTimeoutSecs != 1200 { + t.Fatalf("运行时辅助配置覆盖不正确:web=%+v acme=%+v", loaded.Web, loaded.ACME) + } + fileConfig, err := Load(path) + if err != nil { + t.Fatalf("重新读取原配置失败:%v", err) + } + if fileConfig.Server.Port != 8080 || fileConfig.Web.PublicIP != "" { + t.Fatalf("环境覆盖不应回写配置文件:%+v", fileConfig) + } +} + +func TestLoadWithEnvRejectsInvalidInteger(t *testing.T) { + dir := t.TempDir() + cfg := Default(dir) + path := filepath.Join(dir, "config", "scert.json") + if err := cfg.Save(path); err != nil { + t.Fatalf("保存配置失败:%v", err) + } + t.Setenv("SCERT_SERVER_PORT", "九千零四") + if _, err := LoadWithEnv(path); err == nil { + t.Fatal("无效端口环境变量未被拒绝") + } +}