完善续期调度生命周期和Web开关配置

This commit is contained in:
xuans 2026-08-31 01:09:04 +08:00
parent 1f987eb8be
commit f6ce85b174
7 changed files with 129 additions and 5 deletions

View File

@ -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

View File

@ -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 {

View File

@ -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 {

View File

@ -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()
}

View File

@ -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

View File

@ -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)

View File

@ -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` 的目录,默认是安装器所在目录。