diff --git a/backend/README.md b/backend/README.md index 4f5c872..b333e33 100644 --- a/backend/README.md +++ b/backend/README.md @@ -52,6 +52,7 @@ SMTP 通知是 Scert 的出站连接,不需要为 Scert 开放 SMTP 入站端 SCERT_SERVER_HOST=127.0.0.1 SCERT_SERVER_PORT=9004 SCERT_SERVER_HTTPS_MODE=proxy +SCERT_WEB_ENABLED=true SCERT_WEB_HOSTNAME=web.example.com SCERT_WEB_PUBLIC_IP=192.168.2.3 SCERT_DATABASE_DRIVER=sqlite diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 0b4f3b6..0e39fe4 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -169,6 +169,9 @@ func (a *App) Close() error { return nil } var first error + if a.Renewal != nil { + a.Renewal.Stop() + } if a.HTTP != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) if err := a.HTTP.Shutdown(ctx); err != nil && first == nil { diff --git a/backend/internal/certificate/renewal.go b/backend/internal/certificate/renewal.go index 4a7057c..c309eed 100644 --- a/backend/internal/certificate/renewal.go +++ b/backend/internal/certificate/renewal.go @@ -36,6 +36,8 @@ type RenewalScheduler struct { policyMu sync.RWMutex runMu sync.Mutex running bool + cancel context.CancelFunc + done chan struct{} wake chan struct{} } @@ -138,20 +140,31 @@ func (s *RenewalScheduler) Start(ctx context.Context) { if s == nil { return } + if ctx == nil { + ctx = context.Background() + } + runCtx, cancel := context.WithCancel(ctx) s.mu.Lock() if s.running { s.mu.Unlock() + cancel() return } s.running = true + s.cancel = cancel + s.done = make(chan struct{}) + done := s.done s.mu.Unlock() go func() { defer func() { s.mu.Lock() s.running = false + s.cancel = nil + s.done = nil s.mu.Unlock() + close(done) }() - report, err := s.RunOnce(ctx) + report, err := s.RunOnce(runCtx) for { interval := s.Policy().CheckInterval if err == nil && report.NearExpiry > 0 { @@ -159,7 +172,7 @@ func (s *RenewalScheduler) Start(ctx context.Context) { } timer := time.NewTimer(interval) select { - case <-ctx.Done(): + case <-runCtx.Done(): if !timer.Stop() { <-timer.C } @@ -167,14 +180,35 @@ func (s *RenewalScheduler) Start(ctx context.Context) { case <-timer.C: case <-s.wake: } - if ctx.Err() != nil { + if runCtx.Err() != nil { return } - report, err = s.RunOnce(ctx) + report, err = s.RunOnce(runCtx) } }() } +// Stop 停止后台续期调度并等待当前检查安全退出。 +func (s *RenewalScheduler) Stop() { + if s == nil { + return + } + s.mu.Lock() + if !s.running { + s.mu.Unlock() + return + } + cancel := s.cancel + done := s.done + s.mu.Unlock() + if cancel != nil { + cancel() + } + if done != nil { + <-done + } +} + func (s *RenewalScheduler) issueWithRetry(ctx context.Context, domainID string, urgent bool, policy RenewalPolicy) error { attempts := 1 if urgent { diff --git a/backend/internal/certificate/renewal_test.go b/backend/internal/certificate/renewal_test.go index 7eb5562..5644d15 100644 --- a/backend/internal/certificate/renewal_test.go +++ b/backend/internal/certificate/renewal_test.go @@ -165,3 +165,34 @@ func TestRenewalSchedulerUpdatePolicyWakesRunningScheduler(t *testing.T) { t.Fatal("\u66f4\u65b0\u7eed\u671f\u7b56\u7565\u540e\u8c03\u5ea6\u5668\u672a\u88ab\u7acb\u5373\u5524\u9192") } } + +func TestRenewalSchedulerStopStopsRunningScheduler(t *testing.T) { + lister := &wakeRenewalLister{calls: make(chan struct{}, 4)} + scheduler := NewRenewalSchedulerWithPolicy(lister, &renewalService{}, RenewalPolicy{ + CheckInterval: time.Hour, + RetryInterval: time.Nanosecond, + }) + scheduler.Start(context.Background()) + select { + case <-lister.calls: + case <-time.After(time.Second): + t.Fatal("续期调度器启动后未执行首次检查") + } + + scheduler.Stop() + scheduler.UpdatePolicy(RenewalPolicy{CheckInterval: time.Hour}) + select { + case <-lister.calls: + t.Fatal("续期调度器停止后不应继续执行检查") + case <-time.After(30 * time.Millisecond): + } +} + +func TestRenewalSchedulerStopIsIdempotent(t *testing.T) { + scheduler := NewRenewalSchedulerWithPolicy(&wakeRenewalLister{calls: make(chan struct{}, 1)}, &renewalService{}, RenewalPolicy{CheckInterval: time.Hour}) + scheduler.Stop() + scheduler.Stop() + scheduler.Start(context.Background()) + scheduler.Stop() + scheduler.Stop() +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index be8ffe6..2443c90 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -263,6 +263,23 @@ func (c *Config) ApplyEnvironment() error { *override.target = strings.TrimSpace(value) } } + boolOverrides := []struct { + name string + target *bool + }{ + {"SCERT_WEB_ENABLED", &c.Web.Enabled}, + } + for _, override := range boolOverrides { + value, ok := os.LookupEnv(override.name) + if !ok { + continue + } + parsed, err := parseEnvironmentBool(override.name, value) + if err != nil { + return err + } + *override.target = parsed + } intOverrides := []struct { name string target *int @@ -291,6 +308,17 @@ func (c *Config) ApplyEnvironment() error { return nil } +func parseEnvironmentBool(name, value string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "true": + return true, nil + case "false": + return false, nil + default: + return false, fmt.Errorf("环境变量 %s 必须是 true 或 false", name) + } +} + 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 6841d6c..8de25a6 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -265,6 +265,7 @@ func TestLoadWithEnvOverridesRuntimeSettings(t *testing.T) { 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_ENABLED", "false") t.Setenv("SCERT_WEB_PUBLIC_IP", "192.168.2.3") t.Setenv("SCERT_ACME_PROPAGATION_TIMEOUT_SECS", "1200") loaded, err := LoadWithEnv(path) @@ -274,7 +275,7 @@ func TestLoadWithEnvOverridesRuntimeSettings(t *testing.T) { 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 { + if loaded.Web.Enabled || 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) @@ -286,6 +287,19 @@ func TestLoadWithEnvOverridesRuntimeSettings(t *testing.T) { } } +func TestLoadWithEnvRejectsInvalidBoolean(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_WEB_ENABLED", "是") + if _, err := LoadWithEnv(path); err == nil { + t.Fatal("无效 Web 开关环境变量未被拒绝") + } +} + func TestLoadWithEnvRejectsInvalidInteger(t *testing.T) { dir := t.TempDir() cfg := Default(dir) diff --git a/deploy/README.md b/deploy/README.md index 11edf0f..d17b006 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -65,6 +65,19 @@ systemctl stop Scert-admin `Scert-admin` 不监听任何 TCP/UDP 端口。启动后,它只维护 Scert 主服务 `/reset` 路径的短时激活租约,并将一次性校验码写入 `/run/scert-admin/reset-code` 受保护临时文件,不写入标准输出或 systemd 日志;停止后入口立即失效,异常退出后入口会在租约过期时失效。管理员可使用 `sudo cat /run/scert-admin/reset-code` 读取校验码,校验码在成功消费、服务停止或租约失效后自动销毁。云安全组只需由管理员根据初始化提示放行主服务端口。 +### 环境变量覆盖 + +`Scert` 启动时支持使用 `SCERT_*` 环境变量覆盖当前进程配置,适合写入 systemd 的 `EnvironmentFile`。例如: + +```text +SCERT_SERVER_HOST=127.0.0.1 +SCERT_SERVER_PORT=9004 +SCERT_SERVER_HTTPS_MODE=proxy +SCERT_WEB_ENABLED=true +``` + +`SCERT_WEB_ENABLED=false` 只关闭 Scert 提供的 Web 静态页面,不会关闭核心 API 或桌面端本地管理能力。环境变量不会回写 `scert.json`;修改后需要重启 `Scert` 服务。 + ## 参数说明 - `--source-dir`:包含两个 Linux 可执行文件和 `web/index.html` 的目录,默认是安装器所在目录。