Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions server/ssl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -60,26 +64,27 @@ 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()
for key, state := range a.certs {
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 {
Expand All @@ -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
Expand Down
217 changes: 217 additions & 0 deletions server/ssl_test.go
Original file line number Diff line number Diff line change
@@ -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()
Expand Down
83 changes: 83 additions & 0 deletions tests/webui/ssl.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
const { test, expect } = require('@playwright/test');

const statsURL = /\/json\/stats\.json(?:\?|$)/;
const error = 'lookup "example.test": <img src=x onerror="alert(1)"> & 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-/);
});
});
}
Loading
Loading