From 9799558ad778af3ea958ef1e4aa2497e8995448e Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 3 Aug 2026 00:23:46 +0200 Subject: [PATCH] fix(stalker): apply the discovered cadence when a repair adopts its session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lazy repair cached `outcome.token` and refreshed the watchdog, but dropped the cadence its own confirmation profile had just reported. The retried request then found the cached token, so no authentication path ever applied the profile outcome and a freshly repaired playlist kept pinging on the default until the token failed or the app restarted. `adoptDiscoveredSession()` is the entry point for "another layer already negotiated this session": it caches the token, applies the cadence and persists both, keyed to the REPAIRED configuration since the session belongs to the endpoint it was negotiated against. Also removes the contradictory watchdog contract from the same document — the portal-mode summary still said pings run every 25 s with 120 s deferred, while the lifecycle section below it documents the implemented profile-driven cadence. Applying the outcome moved into the session store (it already owns the persisted half), which keeps the facade under the 400-line limit. Co-Authored-By: Claude Fable 5 --- docs/architecture/stalker-portal.md | 6 +- .../lib/stalker-portal-repair.service.spec.ts | 45 +++++++++-- .../src/lib/stalker-portal-repair.service.ts | 22 +++-- .../src/lib/stalker-session-store.ts | 39 +++++++++ .../src/lib/stalker-session.service.ts | 81 ++++++++++++------- 5 files changed, 149 insertions(+), 44 deletions(-) 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 + ); } /**