diff --git a/server/ssl.go b/server/ssl.go
index b45e0f70..32f04b9f 100644
--- a/server/ssl.go
+++ b/server/ssl.go
@@ -33,12 +33,16 @@ func (a *App) reconcileCerts(configs []SSLCertConfig) {
next := make(map[string]*CertState, len(configs))
for _, config := range configs {
key := certKey(config)
+ // A reload requests a fresh check, even when the config is unchanged.
+ // Replace the state so an in-flight check cannot publish an old result.
+ state := &CertState{Config: config}
if existing := a.certs[key]; existing != nil {
- existing.Config = config
- next[key] = existing
- } else {
- next[key] = &CertState{Config: config}
+ state.LastAlarm7 = existing.LastAlarm7
+ state.LastAlarm3 = existing.LastAlarm3
+ state.LastAlarm1 = existing.LastAlarm1
+ state.LastAlarmMismatch = existing.LastAlarmMismatch
}
+ next[key] = state
}
a.certs = next
}
@@ -60,8 +64,8 @@ func (a *App) sslLoop() {
func (a *App) runDueSSLChecks() {
now := time.Now()
type dueCheck struct {
- key string
- config SSLCertConfig
+ key string
+ state *CertState
}
due := make([]dueCheck, 0)
a.certMu.Lock()
@@ -69,17 +73,18 @@ func (a *App) runDueSSLChecks() {
interval := secondsDuration(state.Config.Interval)
if !state.Checking && (state.LastCheck.IsZero() || now.Sub(state.LastCheck) >= interval) {
state.Checking = true
- due = append(due, dueCheck{key: key, config: state.Config})
+ due = append(due, dueCheck{key: key, state: state})
}
}
a.certMu.Unlock()
for _, check := range due {
check := check
- go a.executeSSLCheck(check.key, check.config)
+ go a.executeSSLCheck(check.key, check.state)
}
}
-func (a *App) executeSSLCheck(key string, config SSLCertConfig) {
+func (a *App) executeSSLCheck(key string, state *CertState) {
+ config := state.Config
expireTS, mismatch, err := checkCertificate(config)
now := time.Now()
type notification struct {
@@ -88,14 +93,15 @@ func (a *App) executeSSLCheck(key string, config SSLCertConfig) {
alerts := make([]notification, 0, 2)
a.certMu.Lock()
- state := a.certs[key]
- if state == nil {
+ if a.certs[key] != state {
a.certMu.Unlock()
return
}
state.Checking = false
state.LastCheck = now
if err != nil {
+ state.ExpireTS = 0
+ state.Mismatch = false
state.LastError = err.Error()
a.certMu.Unlock()
return
diff --git a/server/ssl_test.go b/server/ssl_test.go
index fc65fc30..0f04afa1 100644
--- a/server/ssl_test.go
+++ b/server/ssl_test.go
@@ -1,15 +1,232 @@
package main
import (
+ "crypto/tls"
+ "errors"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
+ "sync"
"sync/atomic"
"testing"
"time"
)
+func sslTestServer(t *testing.T, handshake func(*tls.ClientHelloInfo) (*tls.Config, error)) (*httptest.Server, ConfigDocument) {
+ t.Helper()
+ server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) }))
+ server.TLS = &tls.Config{GetConfigForClient: handshake}
+ server.StartTLS()
+ t.Cleanup(server.Close)
+ parsed, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ port, err := strconv.Atoi(parsed.Port())
+ if err != nil {
+ t.Fatal(err)
+ }
+ doc := minimalTestConfig()
+ doc["sslcerts"] = []any{map[string]any{
+ "name": "local", "domain": server.URL, "port": port, "interval": 7200,
+ }}
+ return server, doc
+}
+
+func sslTestState(app *App, key string) CertState {
+ app.certMu.RLock()
+ defer app.certMu.RUnlock()
+ return *app.certs[key]
+}
+
+func waitForSSLCheck(t *testing.T, app *App, config SSLCertConfig, previous time.Time) CertState {
+ t.Helper()
+ eventually(t, 2*time.Second, func() bool {
+ state := sslTestState(app, certKey(config))
+ return !state.Checking && state.LastCheck.After(previous)
+ })
+ return sslTestState(app, certKey(config))
+}
+
+func TestSSLFailureAndRecovery(t *testing.T) {
+ var rejectHandshake atomic.Bool
+ server, doc := sslTestServer(t, func(*tls.ClientHelloInfo) (*tls.Config, error) {
+ if rejectHandshake.Load() {
+ return nil, errors.New("test TLS failure")
+ }
+ return nil, nil
+ })
+ app := newTestApp(t, doc)
+ config := app.RuntimeSnapshot().SSLCerts[0]
+ key := certKey(config)
+ app.runDueSSLChecks()
+ state := waitForSSLCheck(t, app, config, time.Time{})
+ if state.ExpireTS != server.Certificate().NotAfter.Unix() || state.LastError != "" {
+ t.Fatalf("initial certificate check: %+v", state)
+ }
+
+ for _, phase := range []string{"TLS failure", "recovery", "connection failure"} {
+ t.Run(phase, func(t *testing.T) {
+ switch phase {
+ case "TLS failure":
+ rejectHandshake.Store(true)
+ case "recovery":
+ rejectHandshake.Store(false)
+ case "connection failure":
+ server.Close()
+ }
+ previous := state.LastCheck
+ app.certMu.Lock()
+ // Simulate the next scheduled interval without waiting two hours.
+ app.certs[key].LastCheck = time.Time{}
+ if phase != "recovery" {
+ // A failed check must also discard any prior hostname mismatch.
+ app.certs[key].Mismatch = true
+ }
+ app.certMu.Unlock()
+ app.runDueSSLChecks()
+ state = waitForSSLCheck(t, app, config, previous)
+ entry := app.sslSnapshot([]SSLCertConfig{config}, time.Now())[0].(map[string]any)
+ if phase == "recovery" {
+ if state.LastError != "" || state.ExpireTS != server.Certificate().NotAfter.Unix() || state.Mismatch {
+ t.Errorf("certificate did not recover: %+v", state)
+ }
+ if _, hasError := entry["error"]; hasError || entry["expire_days"].(int) <= 0 {
+ t.Errorf("recovered snapshot: %#v", entry)
+ }
+ } else if state.LastError == "" || state.ExpireTS != 0 || state.Mismatch ||
+ entry["error"] == nil || entry["expire_ts"] != int64(0) || entry["expire_days"] != 0 || entry["mismatch"] != false {
+ t.Errorf("failed check retained a certificate result: state=%+v snapshot=%#v", state, entry)
+ }
+ })
+ }
+}
+
+func TestSSLConfigReloadRechecks(t *testing.T) {
+ for _, change := range []string{"unchanged reload", "interval", "callback"} {
+ t.Run(change, func(t *testing.T) {
+ _, doc := sslTestServer(t, nil)
+ app := newTestApp(t, doc)
+ config := app.RuntimeSnapshot().SSLCerts[0]
+ key := certKey(config)
+ app.runDueSSLChecks()
+ before := waitForSSLCheck(t, app, config, time.Time{})
+ alarmTime := time.Now()
+ app.certMu.Lock()
+ app.certs[key].LastAlarm7 = alarmTime
+ app.certs[key].LastAlarm3 = alarmTime
+ app.certs[key].LastAlarm1 = alarmTime
+ app.certs[key].LastAlarmMismatch = alarmTime
+ app.certMu.Unlock()
+
+ if change == "unchanged reload" {
+ if apiErr := app.ReloadConfig(); apiErr != nil {
+ t.Fatal(apiErr)
+ }
+ } else {
+ updated := app.ConfigSnapshot()
+ cert := updated["sslcerts"].([]any)[0].(map[string]any)
+ if change == "interval" {
+ cert["interval"] = 14400
+ } else {
+ cert["callback"] = "http://127.0.0.1:1/?message="
+ }
+ if _, apiErr := app.ReplaceConfig(updated); apiErr != nil {
+ t.Fatal(apiErr)
+ }
+ }
+ state := sslTestState(app, key)
+ if !state.LastCheck.IsZero() || state.Checking || state.ExpireTS != 0 || state.Mismatch || state.LastError != "" {
+ t.Errorf("reload did not reset the certificate check: %+v", state)
+ }
+ if state.LastAlarm7 != alarmTime || state.LastAlarm3 != alarmTime || state.LastAlarm1 != alarmTime || state.LastAlarmMismatch != alarmTime {
+ t.Errorf("reload reset notification cooldowns: %+v", state)
+ }
+ app.runDueSSLChecks()
+ state = waitForSSLCheck(t, app, config, before.LastCheck)
+ if state.ExpireTS == 0 || state.LastError != "" {
+ t.Errorf("reload did not publish a fresh result: %+v", state)
+ }
+ })
+ }
+}
+
+func TestSSLDiscardInFlightResultAfterConfigChange(t *testing.T) {
+ for _, change := range []string{"reload", "remove and readd"} {
+ t.Run(change, func(t *testing.T) {
+ oldStarted, newStarted := make(chan struct{}), make(chan struct{})
+ releaseOld, releaseNew := make(chan struct{}), make(chan struct{})
+ var oldOnce, newOnce sync.Once
+ unblockOld := func() { oldOnce.Do(func() { close(releaseOld) }) }
+ unblockNew := func() { newOnce.Do(func() { close(releaseNew) }) }
+ var handshakes atomic.Int32
+ _, doc := sslTestServer(t, func(*tls.ClientHelloInfo) (*tls.Config, error) {
+ switch handshakes.Add(1) {
+ case 1:
+ close(oldStarted)
+ <-releaseOld
+ case 2:
+ close(newStarted)
+ <-releaseNew
+ }
+ return nil, nil
+ })
+ // Unblock handshakes before httptest.Server cleanup, including failures.
+ t.Cleanup(unblockOld)
+ t.Cleanup(unblockNew)
+ app := newTestApp(t, doc)
+ config := app.RuntimeSnapshot().SSLCerts[0]
+ key := certKey(config)
+ app.certMu.Lock()
+ oldState := app.certs[key]
+ oldState.Checking = true
+ app.certMu.Unlock()
+ oldDone := make(chan struct{})
+ go func() {
+ app.executeSSLCheck(key, oldState)
+ close(oldDone)
+ }()
+ await := func(channel <-chan struct{}) {
+ t.Helper()
+ select {
+ case <-channel:
+ case <-time.After(2 * time.Second):
+ t.Fatal("certificate handshake did not reach the expected stage")
+ }
+ }
+ await(oldStarted)
+ if change == "reload" {
+ if apiErr := app.ReloadConfig(); apiErr != nil {
+ t.Fatal(apiErr)
+ }
+ } else {
+ withoutCert := app.ConfigSnapshot()
+ withoutCert["sslcerts"] = []any{}
+ if _, apiErr := app.ReplaceConfig(withoutCert); apiErr != nil {
+ t.Fatal(apiErr)
+ }
+ if _, apiErr := app.ReplaceConfig(doc); apiErr != nil {
+ t.Fatal(apiErr)
+ }
+ }
+ app.runDueSSLChecks()
+ await(newStarted)
+ unblockOld()
+ await(oldDone)
+ state := sslTestState(app, key)
+ if !state.Checking || !state.LastCheck.IsZero() || state.ExpireTS != 0 || state.Mismatch || state.LastError != "" {
+ t.Errorf("old check changed the new in-flight state: %+v", state)
+ }
+ unblockNew()
+ state = waitForSSLCheck(t, app, config, time.Time{})
+ if state.ExpireTS == 0 || state.LastError != "" {
+ t.Errorf("new check did not publish its result: %+v", state)
+ }
+ })
+ }
+}
+
func TestCertificateCheckAndSnapshot(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) }))
defer server.Close()
diff --git a/tests/webui/ssl.spec.js b/tests/webui/ssl.spec.js
new file mode 100644
index 00000000..0ccbb588
--- /dev/null
+++ b/tests/webui/ssl.spec.js
@@ -0,0 +1,83 @@
+const { test, expect } = require('@playwright/test');
+
+const statsURL = /\/json\/stats\.json(?:\?|$)/;
+const error = 'lookup "example.test": & offline';
+const certificate = {
+ name: 'certificate', domain: 'example.test', port: 443,
+ expire_ts: 2_000_000_000, expire_days: 30, mismatch: false
+};
+
+for (const compact of [false, true]) {
+ test.describe(compact ? 'SSL cards' : 'SSL table', () => {
+ test.use({ viewport: compact ? { width: 390, height: 844 } : { width: 1280, height: 800 } });
+
+ const items = page => page.locator(compact ? '#sslCards .card' : '#sslBody tr');
+ const badge = item => item.locator(compact ? '.card-header .badge' : 'td:last-child .badge');
+ const remaining = item => item.locator(compact ? '.kvlist > div:nth-child(3) > span:last-child' : 'td:nth-child(4)');
+ const expiry = item => item.locator(compact ? '.kvlist > div:nth-child(4) > span:last-child' : 'td:nth-child(5)');
+ const risk = page => page.locator('#overviewCards .overview-card', { hasText: '证书风险' }).locator('.value');
+
+ async function openSSL(page, getCertificates) {
+ await page.route(statsURL, route => route.fulfill({
+ contentType: 'application/json',
+ body: JSON.stringify({ updated: String(Math.floor(Date.now() / 1000)), servers: [], sslcerts: getCertificates() })
+ }));
+ await page.goto('/');
+ await page.locator('#navTabs button[data-tab="ssl"]').click();
+ }
+
+ test('errors, pending checks and expiry states agree with the overview', async ({ page }) => {
+ const cases = [
+ { name: 'healthy', expire_days: 8, status: '正常', cls: 'ok' },
+ { name: 'failed-stale', error, mismatch: true, status: '检测失败', cls: 'err', unknown: true },
+ { name: 'failed-empty', error, expire_ts: 0, expire_days: 0, status: '检测失败', cls: 'err', unknown: true },
+ { name: 'pending', expire_ts: 0, expire_days: 0, status: '待检测', cls: 'warn', unknown: true },
+ { name: 'expired', expire_days: 0, status: '已过期', cls: 'err' },
+ { name: 'mismatch', mismatch: true, status: '域名不匹配', cls: 'err' },
+ { name: 'expiring', expire_days: 7, status: '将到期', cls: 'warn' }
+ ];
+ await openSSL(page, () => cases.map(({ status, cls, unknown, ...fields }) => ({ ...certificate, ...fields })));
+ await expect(items(page)).toHaveCount(cases.length);
+ await expect(risk(page)).toHaveText('6');
+ for (const entry of cases) {
+ const item = items(page).filter({ hasText: entry.name });
+ await expect(badge(item)).toHaveText(entry.status);
+ await expect(badge(item)).toHaveClass(`badge ${entry.cls}`);
+ await expect(item).toHaveClass(entry.cls === 'ok' ? (compact ? 'card' : '') :
+ `${compact ? 'card ' : ''}alert-${entry.cls === 'err' ? 'critical' : 'warning'}`);
+ if (entry.unknown) {
+ await expect(remaining(item)).toHaveText('-');
+ await expect(expiry(item)).toHaveText('-');
+ }
+ if (entry.error) {
+ await expect(badge(item)).toHaveAttribute('title', error);
+ await expect(item.locator('img')).toHaveCount(0);
+ }
+ }
+ });
+
+ test('polling updates success, failure and recovery without stale expiry or errors', async ({ page }) => {
+ let current = { ...certificate };
+ await openSSL(page, () => [current]);
+ const item = items(page);
+ await expect(badge(item)).toHaveText('正常');
+ await expect(risk(page)).toHaveText('0');
+
+ current = { ...certificate, error };
+ await expect(badge(item)).toHaveText('检测失败');
+ await expect(remaining(item)).toHaveText('-');
+ await expect(expiry(item)).toHaveText('-');
+ await expect(risk(page)).toHaveText('1');
+
+ current = { ...certificate, expire_ts: 2_100_000_000, expire_days: 90 };
+ await expect(badge(item)).toHaveText('正常');
+ await expect(badge(item)).toHaveClass('badge ok');
+ await expect(badge(item)).toHaveAttribute('title', '');
+ await expect(remaining(item)).toHaveText(compact ? '90 天' : '90');
+ const date = new Date(current.expire_ts * 1000).toISOString();
+ await expect(expiry(item)).toHaveText(compact ? date.slice(0, 10) : date.replace('T', ' ').replace(/\.\d+Z/, ''));
+ await expect(risk(page)).toHaveText('0');
+ await expect(item).not.toHaveClass(/alert-/);
+ });
+ });
+}
diff --git a/web/js/app.js b/web/js/app.js
index b52ed9bf..2849e8ed 100644
--- a/web/js/app.js
+++ b/web/js/app.js
@@ -336,12 +336,12 @@ function renderOverview(){
const total = S.servers.length;
const online = S.servers.filter(s => metrics(s).online).length;
const alerts = alertStats();
- const sslWarn = S.ssl.filter(c => c.mismatch || c.expire_days <= 7).length;
+ const sslWarn = S.ssl.filter(c => sslStatus(c).cls !== 'ok').length;
const monthDown = S.servers.reduce((sum, s) => sum + metrics(s).monthIn, 0);
const monthUp = S.servers.reduce((sum, s) => sum + metrics(s).monthOut, 0);
$('overviewCards').innerHTML = [
card('在线主机', `${online}/${total}`, '当前在线节点', online === total ? 'ok' : 'warn'),
- card('证书风险', sslWarn, sslWarn ? '过期或域名不匹配' : '证书正常', sslWarn ? 'warn' : 'ok'),
+ card('证书风险', sslWarn, sslWarn ? '异常、临期或待检测' : '证书正常', sslWarn ? 'warn' : 'ok'),
card('本月上行', humanMinMBFromB(monthUp), '上传累计', 'traffic-up'),
card('本月下行', humanMinMBFromB(monthDown), '下载累计', 'traffic-down'),
card('活跃告警', alerts.total, `离线 ${alerts.offline} / 异常 ${alerts.abnormal} / 被墙 ${alerts.blocked}`, alerts.total ? (alerts.offline || alerts.blocked ? 'err' : 'warn') : 'ok')
@@ -584,23 +584,31 @@ function renderMonitorsCards(){
const wrap = $('monitorsCards');
wrap.innerHTML = S.servers.map(s => `