diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 7f4059768..613a75bf1 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -83,9 +83,9 @@ Two portal modes exist, persisted per playlist as the wider phrase set (`Invalid token`, `Auth failed`, bare `unauthorized`), since a panel fills those in deliberately. While a full portal is the active playlist, `StalkerSessionService` keeps a **watchdog** running — - periodic authenticated `watchdog/get_events` pings (currently every 25 s; - the protocol default expects 120 s, tracked for a later PR) whose failures - are non-fatal. + periodic authenticated `watchdog/get_events` pings at the cadence the + portal advertises (`watchdog_timeout`, default 120 s — see "Watchdog" + below) whose failures are non-fatal. - **Simple portal** (reseller-style `portal.php` panels): no auth lifecycle at all — requests carry only the `mac=` cookie. diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts index 8d49b9555..3da85e863 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.spec.ts @@ -35,6 +35,7 @@ describe('StalkerPortalRepairService', () => { /** When set, the atomic write fails AFTER the transform verified. */ let persistError: Error | null; let setCachedToken: jest.Mock; + let adoptDiscoveredSession: jest.Mock; let clearCachedToken: jest.Mock; let refreshActiveWatchdogPlaylist: jest.Mock; @@ -61,6 +62,7 @@ describe('StalkerPortalRepairService', () => { return of(next); }); setCachedToken = jest.fn(); + adoptDiscoveredSession = jest.fn(); clearCachedToken = jest.fn(); refreshActiveWatchdogPlaylist = jest.fn(); @@ -81,6 +83,7 @@ describe('StalkerPortalRepairService', () => { provide: StalkerSessionService, useValue: { setCachedToken, + adoptDiscoveredSession, clearCachedToken, refreshActiveWatchdogPlaylist, }, @@ -210,12 +213,13 @@ describe('StalkerPortalRepairService', () => { portalUrl: 'http://ministra.example/server/load.php', isFullStalkerPortal: true, }); - // The classification handshake already produced a token, - // tagged with the playlist as its identity source. - expect(setCachedToken).toHaveBeenCalledWith( + // The classification handshake already produced a session; it is + // adopted whole (token + cadence), tagged with the REPAIRED + // configuration as its identity source. + expect(adoptDiscoveredSession).toHaveBeenCalledWith( 'portal-1', - 'TOKEN1', - expect.objectContaining({ _id: 'portal-1' }) + expect.objectContaining({ _id: 'portal-1' }), + expect.objectContaining({ token: 'TOKEN1' }) ); // A repaired ACTIVE playlist must re-sync the watchdog now: a // simple→full flip has to start the keepalive mid-session. @@ -282,6 +286,35 @@ describe('StalkerPortalRepairService', () => { expect(writtenRow).toBeNull(); }); + it('adopts the cadence the repair confirmation discovered', async () => { + // Caching the token alone satisfies the retry, so NO + // authentication path would ever apply the profile outcome — the + // repaired playlist would keep the default cadence until the + // token failed or the app restarted. + discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'http://panel.example/server/load.php', + isFullStalkerPortal: true, + token: 'REPAIRED', + watchdogTimeoutSeconds: 60, + timeslotSeconds: 9, + }); + + await service.repairPortal(MISCLASSIFIED); + + expect(adoptDiscoveredSession).toHaveBeenCalledWith( + MISCLASSIFIED._id, + expect.objectContaining({ + portalUrl: 'http://panel.example/server/load.php', + }), + { + token: 'REPAIRED', + watchdogTimeoutSeconds: 60, + timeslotSeconds: 9, + } + ); + }); + it("forwards the playlist's stored credentials to discovery", async () => { // A login/password portal answers status 2 during confirmation; // without the credentials the probe reports `login-required` and @@ -458,7 +491,7 @@ describe('StalkerPortalRepairService', () => { expect(await service.repairPortal(MISCLASSIFIED)).toBeNull(); expect(writtenRow).toBeNull(); - expect(setCachedToken).not.toHaveBeenCalled(); + expect(adoptDiscoveredSession).not.toHaveBeenCalled(); expect(refreshActiveWatchdogPlaylist).not.toHaveBeenCalled(); }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts index 584b898a5..011239a87 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-repair.service.ts @@ -432,14 +432,22 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi { ?.set(this.repairSourceFingerprint(playlist), override); if (outcome.isFullStalkerPortal && outcome.token) { - // The classification handshake already authenticated; reuse its - // token so the retry does not immediately handshake again. The - // playlist itself is the identity source — a repair never - // changes WHO the session belongs to, only WHERE it talks. - this.stalkerSession.setCachedToken( + // The classification handshake already authenticated; adopt the + // whole session so the retry does not handshake again AND the + // cadence that profile advertised is applied — caching the token + // alone would satisfy the retry and leave the repaired playlist + // pinging on the default until restart. The identity source is + // the REPAIRED configuration: a repair never changes WHO the + // session belongs to, only WHERE it talks, and the session is + // bound to that endpoint. + this.stalkerSession.adoptDiscoveredSession( playlist._id, - outcome.token, - playlist + toStalkerSessionPlaylist(this.applyOverride(playlist)), + { + token: outcome.token, + watchdogTimeoutSeconds: outcome.watchdogTimeoutSeconds, + timeslotSeconds: outcome.timeslotSeconds, + } ); } else if (!outcome.isFullStalkerPortal) { this.stalkerSession.clearCachedToken(playlist._id); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts b/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts index cf808f320..591cbe1c1 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts @@ -157,6 +157,45 @@ export class StalkerSessionStore { } } + /** + * Propagates a successful authentication into session-side state: the + * watchdog cadence, and the write-back. + * + * Reusing a stored token skips the `get_profile` that carries the + * cadence, so the persisted cadence is applied instead — otherwise a + * portal advertising a non-default `watchdog_timeout` would sit on the + * 120 s fallback for the whole session. + */ + applyAuthenticationOutcome( + playlistId: string, + result: StalkerAuthenticationResult, + stored: PersistedStalkerSession, + fingerprint: string, + watchdog: { + applyProfileTiming: ( + playlistId: string, + timing: { + watchdogTimeoutSeconds?: number; + timeslotSeconds?: number; + } + ) => void; + } + ): void { + if (result.reusedStoredToken) { + watchdog.applyProfileTiming(playlistId, { + watchdogTimeoutSeconds: stored.watchdogTimeoutSeconds, + timeslotSeconds: stored.timeslotSeconds, + }); + return; + } + + watchdog.applyProfileTiming(playlistId, { + watchdogTimeoutSeconds: result.watchdogTimeoutSeconds, + timeslotSeconds: result.timeslotSeconds, + }); + this.write(playlistId, result, stored, fingerprint); + } + /** * Writes a renegotiated session back, best effort. * diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts index 65ee9def5..f31a6c613 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts @@ -32,10 +32,7 @@ import { type PersistedStalkerSession, } from './stalker-session-store'; import { StalkerTokenCache } from './stalker-token-cache'; -import { - STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS, - StalkerWatchdogController, -} from './stalker-watchdog.controller'; +import { StalkerWatchdogController } from './stalker-watchdog.controller'; export { getStalkerPortalIdentityFromPlaylist, @@ -164,6 +161,47 @@ export class StalkerSessionService { this.setActiveWatchdogPlaylist(playlist); } + /** + * Adopts a session another layer already negotiated — today the endpoint + * discovery run behind a lazy repair, whose classification handshake and + * `get_profile` produced both a token and the portal's cadence. + * + * Caching the token alone (as the repair used to) leaves the retry + * satisfied and no authentication path ever applies the profile outcome, + * so a freshly repaired playlist would keep pinging on the default + * cadence until the token failed or the app restarted. + * + * `identitySource` must describe the REPAIRED configuration: the session + * belongs to the endpoint it was negotiated against. + */ + adoptDiscoveredSession( + playlistId: string, + identitySource: Playlist, + session: { + token: string; + watchdogTimeoutSeconds?: number; + timeslotSeconds?: number; + } + ): void { + this.setCachedToken(playlistId, session.token, identitySource); + this.applySessionOutcome( + playlistId, + { + token: session.token, + reusedStoredToken: false, + watchdogTimeoutSeconds: session.watchdogTimeoutSeconds, + timeslotSeconds: session.timeslotSeconds, + }, + { + token: identitySource.stalkerToken, + identityFingerprint: identitySource.stalkerSessionIdentity, + watchdogTimeoutSeconds: identitySource.stalkerWatchdogTimeout, + timeslotSeconds: identitySource.stalkerTimeslot, + }, + stalkerSessionFingerprint(identitySource) + ); + } + /** * Performs handshake to get a session token for a full stalker portal. * An optional persisted token is re-presented: the handshake is @@ -331,7 +369,7 @@ export class StalkerSessionService { } ); this.setCachedToken(playlist._id, result.token, playlist); - this.applyProfileOutcome( + this.applySessionOutcome( playlist._id, result, stored, @@ -439,7 +477,7 @@ export class StalkerSessionService { // This path always ran a real get_profile, so the decoded cadence // is authoritative; the previous values are only the write-back // comparison baseline. - this.applyProfileOutcome( + this.applySessionOutcome( playlist._id, result, { @@ -465,33 +503,20 @@ export class StalkerSessionService { } } - /** - * Propagates a successful authentication into session-side state. - * - * Reusing a stored token skips the `get_profile` that carries the - * watchdog cadence, so the persisted cadence is applied instead — - * otherwise a portal advertising a non-default `watchdog_timeout` would - * sit on the 120 s fallback for the whole session. - */ - private applyProfileOutcome( + /** Delegates the watchdog + write-back half of an authentication. */ + private applySessionOutcome( playlistId: string, result: StalkerAuthenticationResult, stored: PersistedStalkerSession, fingerprint: string ): void { - if (result.reusedStoredToken) { - this.watchdog.applyProfileTiming(playlistId, { - watchdogTimeoutSeconds: stored.watchdogTimeoutSeconds, - timeslotSeconds: stored.timeslotSeconds, - }); - return; - } - - this.watchdog.applyProfileTiming(playlistId, { - watchdogTimeoutSeconds: result.watchdogTimeoutSeconds, - timeslotSeconds: result.timeslotSeconds, - }); - this.sessionStore.write(playlistId, result, stored, fingerprint); + this.sessionStore.applyAuthenticationOutcome( + playlistId, + result, + stored, + fingerprint, + this.watchdog + ); } /**