支持环境变量覆盖运行配置

This commit is contained in:
xuans 2026-08-30 21:01:35 +08:00
parent 8ba644d7b3
commit fc2397935b
5 changed files with 148 additions and 2 deletions

View File

@ -42,6 +42,41 @@ go run ./cmd/scert serve --base-dir C:\ScertData
SMTP 通知是 Scert 的出站连接,不需要为 Scert 开放 SMTP 入站端口。公网防火墙通常只需放行 Nginx 使用的 `443`,若配置 HTTP 跳转再放行 `80`;内部端口 `9004` 和 Nginx 测试端口 `9003` 都不应对公网开放。 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 ```powershell

View File

@ -31,7 +31,7 @@ func main() {
if path == "" { if path == "" {
path = filepath.Join(config.DefaultPaths(*baseDir).Config, "scert.json") path = filepath.Join(config.DefaultPaths(*baseDir).Config, "scert.json")
} }
cfg, err := config.Load(path) cfg, err := config.LoadWithEnv(path)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "读取配置失败:", err) fmt.Fprintln(os.Stderr, "读取配置失败:", err)
os.Exit(1) os.Exit(1)

View File

@ -855,7 +855,7 @@ func printJSON(cmd *cobra.Command, value any) error {
return err return err
} }
func loadConfig() (config.Config, error) { return config.Load(resolveConfigPath()) } func loadConfig() (config.Config, error) { return config.LoadWithEnv(resolveConfigPath()) }
func resolveConfigPath() string { func resolveConfigPath() string {
if configPath != "" { if configPath != "" {

View File

@ -209,6 +209,73 @@ func Load(path string) (Config, error) {
return cfg, nil 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() { func (c *Config) Normalize() {
if c == nil { if c == nil {
return return

View File

@ -208,3 +208,47 @@ func TestSaveAndLoadACMEPropagationSettings(t *testing.T) {
t.Fatalf("DNS 传播参数持久化不一致:%+v", loaded.ACME) 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("无效端口环境变量未被拒绝")
}
}