diff options
| author | Oskar <oskar@mullvad.net> | 2025-09-12 16:21:06 +0200 |
|---|---|---|
| committer | Oskar <oskar@mullvad.net> | 2025-10-01 13:19:43 +0200 |
| commit | f89775b79f7ce79cf1c8b0cb58d6212d4f9c09fa (patch) | |
| tree | 05098a9dc5c5faf5739f038869c3e9283dae4f04 | |
| parent | 34812fa0e81b275ef271a6027cf854aa124f21c4 (diff) | |
| download | mullvadvpn-f89775b79f7ce79cf1c8b0cb58d6212d4f9c09fa.tar.xz mullvadvpn-f89775b79f7ce79cf1c8b0cb58d6212d4f9c09fa.zip | |
Add check for if system time changes
4 files changed, 103 insertions, 19 deletions
diff --git a/desktop/packages/mullvad-vpn/src/main/account.ts b/desktop/packages/mullvad-vpn/src/main/account.ts index 7fc6b48d47..7ee415c0b4 100644 --- a/desktop/packages/mullvad-vpn/src/main/account.ts +++ b/desktop/packages/mullvad-vpn/src/main/account.ts @@ -1,4 +1,4 @@ -import { closeToExpiry } from '../shared/account-expiry'; +import { closeToExpiry, hasExpired } from '../shared/account-expiry'; import { AccountDataError, AccountNumber, @@ -19,6 +19,7 @@ import AccountDataCache from './account-data-cache'; import { DaemonRpc } from './daemon-rpc'; import { IpcMainEventChannel } from './ipc-event-channel'; import { NotificationSender } from './notification-controller'; +import { systemTimeMonitor } from './system-time-monitor'; import { TunnelStateProvider } from './tunnel-state'; export interface LocaleProvider { @@ -35,16 +36,14 @@ export default class Account { private expiryNotificationFrequencyScheduler = new Scheduler(); private firstExpiryNotificationScheduler = new Scheduler(); + private hasExpired = false; + private accountDataCache = new AccountDataCache( (accountNumber) => { return this.daemonRpc.getAccountData(accountNumber); }, (accountData) => { - this.accountDataValue = accountData; - - IpcMainEventChannel.account.notify?.(this.accountData); - - this.handleAccountExpiry(); + this.handleAccountData(accountData); }, ); @@ -53,7 +52,9 @@ export default class Account { public constructor( private delegate: AccountDelegate & TunnelStateProvider & LocaleProvider & NotificationSender, private daemonRpc: DaemonRpc, - ) {} + ) { + this.monitorExpiredChange(); + } public get accountData() { return this.accountDataValue; @@ -110,10 +111,10 @@ export default class Account { }; public detectStaleAccountExpiry(tunnelState: TunnelState) { - const hasExpired = !this.accountData || new Date() >= new Date(this.accountData.expiry); + const expired = !this.accountData || hasExpired(this.accountData.expiry); // It's likely that the account expiry is stale if the daemon managed to establish the tunnel. - if (tunnelState.state === 'connected' && hasExpired) { + if (tunnelState.state === 'connected' && expired) { log.info('Detected the stale account expiry.'); this.accountDataCache.invalidate(); } @@ -146,6 +147,16 @@ export default class Account { IpcMainEventChannel.accountHistory.notify?.(accountHistory); } + // This function monitors if the account is expired due to system clock changes. + private monitorExpiredChange() { + systemTimeMonitor(() => { + const expired = this.accountData && hasExpired(this.accountData.expiry); + if (expired !== this.hasExpired) { + this.handleAccountData(this.accountData); + } + }); + } + private async createNewAccount(): Promise<string> { try { return await this.daemonRpc.createNewAccount(); @@ -180,7 +191,14 @@ export default class Account { } } - private handleAccountExpiry() { + private handleAccountData(accountData?: IAccountData) { + this.accountDataValue = accountData; + this.hasExpired = this.accountData !== undefined && hasExpired(this.accountData?.expiry); + IpcMainEventChannel.account.notify?.(this.accountData); + this.showNotifications(); + } + + private showNotifications() { if (this.accountData) { const expiredNotification = new AccountExpiredNotificationProvider({ accountExpiry: this.accountData.expiry, @@ -205,7 +223,7 @@ export default class Account { const twelveHours = 12 * 60 * 60 * 1000; const remainingMilliseconds = new Date(this.accountData.expiry).getTime() - Date.now(); const delay = Math.min(twelveHours, remainingMilliseconds); - this.expiryNotificationFrequencyScheduler.schedule(() => this.handleAccountExpiry(), delay); + this.expiryNotificationFrequencyScheduler.schedule(() => this.showNotifications(), delay); } else if (!closeToExpiry(this.accountData.expiry)) { this.expiryNotificationFrequencyScheduler.cancel(); // If no longer close to expiry, all previous notifications should be closed @@ -217,7 +235,7 @@ export default class Account { // Add 10 seconds to be on the safe side. Never make it longer than a 24 days since // the timeout needs to fit into a signed 32-bit integer. const timeout = Math.min(expiry - now - threeDays + 10_000, 24 * 24 * 60 * 60 * 1000); - this.firstExpiryNotificationScheduler.schedule(() => this.handleAccountExpiry(), timeout); + this.firstExpiryNotificationScheduler.schedule(() => this.showNotifications(), timeout); } } } diff --git a/desktop/packages/mullvad-vpn/src/main/system-time-monitor.ts b/desktop/packages/mullvad-vpn/src/main/system-time-monitor.ts new file mode 100644 index 0000000000..308c38e527 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/main/system-time-monitor.ts @@ -0,0 +1,15 @@ +const INTERVAL = 1000; + +// This functions monitors the system clock for changes, such as NTP corrections or user manually +// changing the time. It probably has a lot of false positives, e.g. after suspend. And it only +// checks once a second so the event will be a bit delayed. +export function systemTimeMonitor(listener: () => void) { + let prevDate = Date.now(); + setInterval(() => { + const now = Date.now(); + if (Math.abs(now - prevDate - INTERVAL) > 500) { + listener(); + } + prevDate = now; + }, INTERVAL); +} diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/account-expiry.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/account-expiry.spec.ts index 6e259752e7..bd8f7b0317 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/mocked/account-expiry.spec.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/account-expiry.spec.ts @@ -48,15 +48,37 @@ test.describe('Account expiry', () => { await routes.expired.waitForRoute(); }); - test('Should move clock back', async () => { - await page.clock.setSystemTime('2025-04-03T14:00:00'); - await util.ipc.account[''].notify({ - expiry: new Date('2025-04-03T13:00:00').toISOString(), + // These tests verify that the renderer process will handle receiving the same expiry as + // previously but at different system times, where for one system time the expiry is passed but + // not for the other. This can happen if the system clock is changed. + test.describe('Handle system clock changes', () => { + test('Should move clock back', async () => { + const expiry = { + expiry: new Date('2025-04-03T13:00:00').toISOString(), + }; + + await page.clock.setSystemTime('2025-04-03T14:00:00'); + await util.ipc.account[''].notify(expiry); + + await routes.expired.waitForRoute(); + await page.clock.setSystemTime('2025-01-01T12:00'); + await util.ipc.account[''].notify(expiry); + await routes.main.waitForRoute(); }); - await routes.expired.waitForRoute(); - await page.clock.setSystemTime('2025-01-01T12:00'); - await routes.main.waitForRoute(); + test('Should move clock forward', async () => { + const expiry = { + expiry: new Date('2025-04-03T13:00:00').toISOString(), + }; + + await page.clock.setSystemTime('2025-04-03T12:00:00'); + await util.ipc.account[''].notify(expiry); + + await routes.main.waitForRoute(); + await page.clock.setSystemTime('2025-04-04T12:00'); + await util.ipc.account[''].notify(expiry); + await routes.expired.waitForRoute(); + }); }); function addTimeTests(newAccount: boolean) { diff --git a/desktop/packages/mullvad-vpn/test/unit/system-time-monitor.spec.ts b/desktop/packages/mullvad-vpn/test/unit/system-time-monitor.spec.ts new file mode 100644 index 0000000000..f207447288 --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/unit/system-time-monitor.spec.ts @@ -0,0 +1,29 @@ +import { expect, spy } from 'chai'; +import sinon from 'sinon'; + +import { systemTimeMonitor } from '../../src/main/system-time-monitor'; + +describe('IAccountData cache', () => { + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + clock.restore(); + }); + + it('should notify when system clock changes', () => { + const systemTimeListener = spy(); + + clock.setSystemTime(new Date('2025-01-01')); + systemTimeMonitor(systemTimeListener); + clock.setSystemTime(new Date('2025-01-02')); + clock.tick(1001); + clock.setSystemTime(new Date('2025-01-01')); + clock.tick(1900); + + expect(systemTimeListener).to.have.been.called.twice; + }); +}); |
