From 13aaedcfbf6a9fbc312d81973fbfdb9eef08f0ac Mon Sep 17 00:00:00 2001 From: cppla Date: Wed, 4 Nov 2026 13:48:09 +0800 Subject: [PATCH] fix: refresh SSL state after failures and config reloads --- server/ssl.go | 28 ++++-- server/ssl_test.go | 217 ++++++++++++++++++++++++++++++++++++++++ tests/webui/ssl.spec.js | 83 +++++++++++++++ web/js/app.js | 28 ++++-- 4 files changed, 335 insertions(+), 21 deletions(-) create mode 100644 tests/webui/ssl.spec.js 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 => `
${esc(s.name || '-')} ${esc(s.location || '-')}
${protoPill(s)}
监测内容${monitorItems(s)}
`).join('') || '
无数据
'; } +function sslStatus(c){ + if(c.error) return { cls: 'err', status: '检测失败' }; + if(!c.expire_ts) return { cls: 'warn', status: '待检测' }; + if(c.mismatch) return { cls: 'err', status: '域名不匹配' }; + if(c.expire_days <= 0) return { cls: 'err', status: '已过期' }; + if(c.expire_days <= 7) return { cls: 'warn', status: '将到期' }; + return { cls: 'ok', status: '正常' }; +} function renderSSL(){ $('sslBody').innerHTML = S.ssl.map(c => { - const cls = c.mismatch || c.expire_days <= 0 ? 'err' : c.expire_days <= 7 ? 'warn' : 'ok'; - const status = c.mismatch ? '域名不匹配' : c.expire_days <= 0 ? '已过期' : c.expire_days <= 7 ? '将到期' : '正常'; - const dt = c.expire_ts ? new Date(c.expire_ts * 1000).toISOString().replace('T',' ').replace(/\.\d+Z/,'') : '-'; + const { cls, status } = sslStatus(c); + const hasExpiry = !c.error && !!c.expire_ts; + const dt = hasExpiry ? new Date(c.expire_ts * 1000).toISOString().replace('T',' ').replace(/\.\d+Z/,'') : '-'; const alertClass = cls === 'err' ? 'alert-critical' : cls === 'warn' ? 'alert-warning' : ''; - return `${esc(c.name || '-')}${esc(String(c.domain || '').replace(/^https?:\/\//,''))}${esc(c.port || 443)}${esc(c.expire_days ?? '-')}${dt}${status}`; + return `${esc(c.name || '-')}${esc(String(c.domain || '').replace(/^https?:\/\//,''))}${esc(c.port || 443)}${esc(hasExpiry ? c.expire_days : '-')}${dt}${status}`; }).join('') || `无证书数据`; } function renderSSLCards(){ const wrap = $('sslCards'); wrap.innerHTML = S.ssl.map(c => { - const cls = c.mismatch || c.expire_days <= 0 ? 'err' : c.expire_days <= 7 ? 'warn' : 'ok'; - const status = c.mismatch ? '域名不匹配' : c.expire_days <= 0 ? '已过期' : c.expire_days <= 7 ? '将到期' : '正常'; - const dt = c.expire_ts ? new Date(c.expire_ts * 1000).toISOString().slice(0,10) : '-'; + const { cls, status } = sslStatus(c); + const hasExpiry = !c.error && !!c.expire_ts; + const dt = hasExpiry ? new Date(c.expire_ts * 1000).toISOString().slice(0,10) : '-'; const alertClass = cls === 'err' ? ' alert-critical' : cls === 'warn' ? ' alert-warning' : ''; - return `
${esc(c.name || '-')}
${status}
域名${esc(String(c.domain || '').replace(/^https?:\/\//,''))}
端口${esc(c.port || 443)}
剩余${esc(c.expire_days ?? '-')} 天
到期${dt}
`; + return `
${esc(c.name || '-')}
${status}
域名${esc(String(c.domain || '').replace(/^https?:\/\//,''))}
端口${esc(c.port || 443)}
剩余${hasExpiry ? `${esc(c.expire_days)} 天` : '-'}
到期${dt}
`; }).join('') || '
无证书数据
'; }