diff --git a/client_v2/src/__tests__/common/ui/Banners.test.tsx b/client_v2/src/__tests__/common/ui/Banners.test.tsx index 1468a289ed9..fc553b8c5c3 100644 --- a/client_v2/src/__tests__/common/ui/Banners.test.tsx +++ b/client_v2/src/__tests__/common/ui/Banners.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen } from '@solidjs/testing-library'; +import { fireEvent, render, screen } from '@solidjs/testing-library'; import userEvent from '@testing-library/user-event'; import { HashRouter, Route } from '@solidjs/router'; @@ -235,6 +235,29 @@ describe('Banners', () => { expect(screen.queryByTestId('banner-tls-expired')).not.toBeInTheDocument(); }); + it('shows the update banner after dismissing the TLS expiring banner', () => { + mockEncryptionState.enabled = true; + mockEncryptionState.valid_cert = true; + mockEncryptionState.not_after = new Date(Date.now() + 2 * 86400000).toISOString(); // 2 days + // Update available too, but TLS expiring takes priority + mockDashboardState.isUpdateAvailable = true; + mockDashboardState.newVersion = 'v1.0.0'; + mockDashboardState.canAutoUpdate = true; + + renderBanners(); + + expect(screen.getByTestId('banner-tls-expiring')).toBeInTheDocument(); + expect(screen.queryByTestId('banner-update-auto')).not.toBeInTheDocument(); + + // Dismiss the expiring warning while TLS is still within the warning window + const closeButton = screen.getByTestId('banner-tls-expiring-close'); + fireEvent.click(closeButton); + + // Lower-priority update banner should now appear even though TLS still takes priority + expect(screen.queryByTestId('banner-tls-expiring')).not.toBeInTheDocument(); + expect(screen.getByTestId('banner-update-auto')).toBeInTheDocument(); + }); + // ── forceBanner (dev test override) ── it('renders forced TLS expired banner regardless of store state', () => { diff --git a/client_v2/src/common/ui/Banners/Banners.tsx b/client_v2/src/common/ui/Banners/Banners.tsx index 895c1699d8d..f51be644226 100644 --- a/client_v2/src/common/ui/Banners/Banners.tsx +++ b/client_v2/src/common/ui/Banners/Banners.tsx @@ -39,15 +39,16 @@ export const Banners = (props: Props) => { return null; }); - const computeActiveBanner = (): BannerSpec | null => { + const getActiveBanners = (): BannerSpec[] => { + const active: BannerSpec[] = []; + if (encryptionState.enabled && encryptionState.valid_cert && encryptionState.not_after) { const expiry = new Date(encryptionState.not_after).getTime(); if (!Number.isNaN(expiry)) { if (Date.now() > expiry) { - return { type: 'tls_expired' }; - } - if (Date.now() > expiry - TLS_EXPIRY_WARNING_MS) { - return { type: 'tls_expiring' }; + active.push({ type: 'tls_expired' }); + } else if (Date.now() > expiry - TLS_EXPIRY_WARNING_MS) { + active.push({ type: 'tls_expiring' }); } } } @@ -57,22 +58,24 @@ export const Banners = (props: Props) => { version: dashboardState.newVersion, announcementUrl: dashboardState.announcementUrl, }; - return dashboardState.canAutoUpdate - ? { type: 'update_auto', ...spec } - : { type: 'update_manual', ...spec }; + active.push( + dashboardState.canAutoUpdate + ? { type: 'update_auto', ...spec } + : { type: 'update_manual', ...spec }, + ); } - return null; + return active; }; const banner = createMemo(() => { - const active = props.forceBanner ?? forceFromQuery() ?? computeActiveBanner(); - if (!active) return null; + const forced = props.forceBanner ?? forceFromQuery(); + const candidates = forced ? [forced] : getActiveBanners(); const dismissedValue = dismissed(); - if (dismissedValue && bannerSpecsEqual(dismissedValue, active)) { - return null; - } - return active; + return ( + candidates.find((b) => !(dismissedValue && bannerSpecsEqual(dismissedValue, b))) ?? + null + ); }); const announcementLinkHandler = (announcementUrl: string) => (text: string) => ( @@ -82,9 +85,8 @@ export const Banners = (props: Props) => { ); return ( - - {(spec) => { - const current = spec(); + + {(current) => { const renderBanner = (): JSX.Element => { switch (current.type) { diff --git a/internal/aghtls/defaultmanager.go b/internal/aghtls/defaultmanager.go index 04437207053..85f11dab63e 100644 --- a/internal/aghtls/defaultmanager.go +++ b/internal/aghtls/defaultmanager.go @@ -53,15 +53,13 @@ type DefaultManager struct { logger *slog.Logger // mu protects tlsConf, extTLSConf, certLastMod, tlsCert, and pair. - mu *sync.Mutex - tlsConf *tls.Config - extTLSConf *ExtendedTLSConfig - tlsCert *tls.Certificate - rootCerts *x509.CertPool - updates chan UpdateSignal - pair TLSPair - - // TODO(m.kazantsev): Add support for dynamic updates of custom ciphers. + mu *sync.Mutex + tlsConf *tls.Config + extTLSConf *ExtendedTLSConfig + tlsCert *tls.Certificate + rootCerts *x509.CertPool + updates chan UpdateSignal + pair TLSPair customCipherIDs []uint16 } @@ -435,7 +433,11 @@ func (mgr *DefaultManager) SetExtendedTLSConfig( return false, err } - updatePlainDNS(mgr.extTLSConf, newConf, servePlainDNS) + if servePlainDNS != aghalg.NBNull { + newConf.ServePlainDNS = servePlainDNS == aghalg.NBTrue + } else { + newConf.ServePlainDNS = mgr.extTLSConf.ServePlainDNS + } if !setPrivateFieldsAndCompare(mgr.extTLSConf, newConf) { mgr.logger.InfoContext(ctx, "config has changed, restarting https server") @@ -533,22 +535,6 @@ func setPrivateFieldsAndCompare( return cmp.Equal(currentTLSConf, newTLSConf) } -// updatePlainDNS checks the old value of -// [aghtls.ExtendedTLSConfig.ServePlainDNS] in currentTLSConf and if it differs -// from servePlain, sets the value of servePlain in newTLSConf.ServePlainDNS. -// currentTLSConf and newTLSConf must not be nil. -func updatePlainDNS( - currentTLSConf *ExtendedTLSConfig, - newTLSConf *ExtendedTLSConfig, - servePlain aghalg.NullBool, -) { - if servePlain != aghalg.NBNull { - newTLSConf.ServePlainDNS = servePlain == aghalg.NBTrue - } else { - newTLSConf.ServePlainDNS = currentTLSConf.ServePlainDNS - } -} - // setCertFileTime sets [tlsManager.certLastMod] from the certificate. If there // are errors, setCertFileTime logs them. m.mu is expected to be locked. func (mgr *DefaultManager) setCertFileTime(ctx context.Context) { diff --git a/internal/home/web.go b/internal/home/web.go index ff4d93053b1..61711d3363f 100644 --- a/internal/home/web.go +++ b/internal/home/web.go @@ -227,11 +227,9 @@ type webAPI struct { // hostsContainer is used for DNS initialization on updates. hostsContainer *aghnet.HostsContainer - // httpsServer is the server that handles HTTPS traffic. If it is not nil, - // [Web.http3Server] must also not be nil. - // - // TODO(d.kolyshev): Make it a pointer. - httpsServer httpsServer + // httpsServer is the server that handles HTTPS traffic. It must not be + // nil. + httpsServer *httpsServer // pidFilePath is used for cleanup. pidFilePath string @@ -284,9 +282,11 @@ func newWebAPI(ctx context.Context, conf *webAPIConfig) (w *webAPI) { w.registerControlHandlers() } - w.httpsServer.logger = conf.baseLogger.With(slogutil.KeyPrefix, "https_server") - w.httpsServer.mu = &sync.Mutex{} - w.httpsServer.reconfigured = make(chan unit, 1) + w.httpsServer = &httpsServer{ + logger: conf.baseLogger.With(slogutil.KeyPrefix, "https_server"), + mu: &sync.Mutex{}, + reconfigured: make(chan unit, 1), + } return w } @@ -513,6 +513,7 @@ func (web *webAPI) mustStartHTTP3(ctx context.Context, address string) { } // startPprof launches the debug and profiling server on the provided port. +// baseLogger must not be nil. func startPprof(baseLogger *slog.Logger, port uint16) { addr := netip.AddrPortFrom(netutil.IPv4Localhost(), port) @@ -599,15 +600,16 @@ func (web *webAPI) handleTLSValidate(w http.ResponseWriter, r *http.Request) { status, ) resp := &tlsConfig{ - tlsConfigSettingsExt: setts, + tlsConfigSettingsExt: *setts, tlsConfigStatus: tlsConfigStatusFromConf(status), } marshalTLS(ctx, web.logger, w, r, resp) } -// validateTLSSettings returns error if the setts are not valid. -func (web *webAPI) validateTLSSettings(setts tlsConfigSettingsExt) (err error) { +// validateTLSSettings returns error if the setts are not valid. setts must not +// be nil. +func (web *webAPI) validateTLSSettings(setts *tlsConfigSettingsExt) (err error) { if !setts.Enabled { if setts.ServePlainDNS == aghalg.NBFalse { // TODO(a.garipov): Support full disabling of all DNS. @@ -793,7 +795,7 @@ func (web *webAPI) handleTLSConfigure(w http.ResponseWriter, r *http.Request) { err = aghtls.LoadTLSConfig(ctx, web.logger, web.tlsManager, conf, status) if err != nil { resp := &tlsConfig{ - tlsConfigSettingsExt: req, + tlsConfigSettingsExt: *req, tlsConfigStatus: tlsConfigStatusFromConf(status), } @@ -842,7 +844,7 @@ func (web *webAPI) respondAndRestartTLS( restartHTTPS bool, ) { resp := &tlsConfig{ - tlsConfigSettingsExt: req, + tlsConfigSettingsExt: *req, tlsConfigStatus: tlsConfigStatusFromConf(status), } @@ -860,7 +862,7 @@ func (web *webAPI) respondAndRestartTLS( } // setServePlainDNS updates the ServePlainDNS field of [config.DNS]. -func setServePlainDNS(req tlsConfigSettingsExt) { +func setServePlainDNS(req *tlsConfigSettingsExt) { if req.ServePlainDNS == aghalg.NBNull { return } @@ -955,12 +957,18 @@ func marshalTLS( aghhttp.WriteJSONResponseOK(ctx, logger, w, r, *data) } -// unmarshalTLS handles base64-encoded certificates transparently. -func unmarshalTLS(r *http.Request) (data tlsConfigSettingsExt, err error) { - data = tlsConfigSettingsExt{} +// unmarshalTLS handles base64-encoded certificates transparently. r must not +// be nil. +func unmarshalTLS(r *http.Request) (data *tlsConfigSettingsExt, err error) { + data = &tlsConfigSettingsExt{} + err = json.NewDecoder(r.Body).Decode(&data) if err != nil { - return data, fmt.Errorf("failed to parse new TLS config json: %w", err) + return data, fmt.Errorf("failed to parse new tls config json: %w", err) + } + + if data == nil { + return &tlsConfigSettingsExt{}, nil } if data.tlsConfigSettings.CertificateChain != "" { @@ -972,7 +980,7 @@ func unmarshalTLS(r *http.Request) (data tlsConfigSettingsExt, err error) { data.tlsConfigSettings.CertificateChain = string(cert) if data.tlsConfigSettings.CertificatePath != "" { - return data, fmt.Errorf("certificate data and file can't be set together") + return data, errors.Error("certificate data and file can't be set together") } } @@ -987,7 +995,7 @@ func unmarshalTLS(r *http.Request) (data tlsConfigSettingsExt, err error) { data.tlsConfigSettings.PrivateKey = string(key) if data.tlsConfigSettings.PrivateKeyPath != "" { - return data, fmt.Errorf("private key data and file can't be set together") + return data, errors.Error("private key data and file can't be set together") } return data, nil diff --git a/internal/home/web_internal_test.go b/internal/home/web_internal_test.go index 6b7778b2db6..5caa9412932 100644 --- a/internal/home/web_internal_test.go +++ b/internal/home/web_internal_test.go @@ -132,7 +132,8 @@ func TestWebAPI_HandleTLSConfigure(t *testing.T) { }) res := &tlsConfig{ - tlsConfigStatus: &tlsConfigStatus{}, + tlsConfigStatus: &tlsConfigStatus{}, + tlsConfigSettingsExt: tlsConfigSettingsExt{}, } err = json.NewDecoder(w.Body).Decode(res) @@ -223,21 +224,21 @@ func TestWebAPI_ValidateTLSSettings(t *testing.T) { testCases := []struct { name string wantErr string - setts tlsConfigSettingsExt + setts *tlsConfigSettingsExt }{{ name: "basic", wantErr: "", - setts: tlsConfigSettingsExt{}, + setts: &tlsConfigSettingsExt{}, }, { name: "disabled_all", wantErr: "plain DNS is required in case encryption protocols are disabled", - setts: tlsConfigSettingsExt{ + setts: &tlsConfigSettingsExt{ ServePlainDNS: aghalg.NBFalse, }, }, { name: "busy_https_port", wantErr: fmt.Sprintf("port %d for HTTPS is not available", busyTCPPort), - setts: tlsConfigSettingsExt{ + setts: &tlsConfigSettingsExt{ tlsConfigSettings: tlsConfigSettings{ Enabled: true, PortHTTPS: uint16(busyTCPPort), @@ -246,7 +247,7 @@ func TestWebAPI_ValidateTLSSettings(t *testing.T) { }, { name: "busy_dot_port", wantErr: fmt.Sprintf("port %d for DNS-over-TLS is not available", busyTCPPort), - setts: tlsConfigSettingsExt{ + setts: &tlsConfigSettingsExt{ tlsConfigSettings: tlsConfigSettings{ Enabled: true, PortDNSOverTLS: uint16(busyTCPPort), @@ -255,7 +256,7 @@ func TestWebAPI_ValidateTLSSettings(t *testing.T) { }, { name: "busy_doq_port", wantErr: fmt.Sprintf("port %d for DNS-over-QUIC is not available", busyUDPPort), - setts: tlsConfigSettingsExt{ + setts: &tlsConfigSettingsExt{ tlsConfigSettings: tlsConfigSettings{ Enabled: true, PortDNSOverQUIC: uint16(busyUDPPort), @@ -264,7 +265,7 @@ func TestWebAPI_ValidateTLSSettings(t *testing.T) { }, { name: "duplicate_port", wantErr: "validating tcp ports: duplicated values: [4433]", - setts: tlsConfigSettingsExt{ + setts: &tlsConfigSettingsExt{ tlsConfigSettings: tlsConfigSettings{ Enabled: true, PortHTTPS: 4433,