fix(stalker): preserve explicit portal identity (#941)

This commit is contained in:
4gray authored and GitHub committed 2026-05-15 18:34:49 +02:00
1 parent 2d5c4fa4f9
commit 272e0e772e
17 files changed
+912 -215

No files matched your search

@@ -0,0 +1,95 @@
import {
LEGACY_DEFAULT_STALKER_SERIAL,
buildStalkerIdentityRequestContext,
} from './stalker-identity';
describe('buildStalkerIdentityRequestContext', () => {
const macAddress = '00:1A:79:AA:BB:CC';
it('does not add SN header or force get_profile SN fields when serial is absent', () => {
const context = buildStalkerIdentityRequestContext({
macAddress,
params: {
type: 'stb',
action: 'get_profile',
sn: 'STALE-SN',
metrics: JSON.stringify({
mac: macAddress,
sn: 'STALE-SN',
}),
},
});
expect(context.effectiveSerialNumber).toBeUndefined();
expect(context.headers).not.toHaveProperty('SN');
expect(context.cookieString).not.toContain('__cfduid=');
expect(context.requestParams).not.toHaveProperty('sn');
expect(
JSON.parse(String(context.requestParams.metrics))
).not.toHaveProperty('sn');
});
it('preserves a provided serial exactly in headers, params, and metrics', () => {
const context = buildStalkerIdentityRequestContext({
macAddress,
serialNumber: 'CustomSn123',
params: {
type: 'stb',
action: 'get_profile',
metrics: JSON.stringify({
mac: macAddress,
}),
},
});
expect(context.effectiveSerialNumber).toBe('CustomSn123');
expect(context.headers.SN).toBe('CustomSn123');
expect(context.cookieString).toContain('__cfduid=');
expect(
context.cookieString.match(/__cfduid=([^;]+)/)?.[1]
).toHaveLength(32);
expect(context.requestParams.sn).toBe('CustomSn123');
expect(JSON.parse(String(context.requestParams.metrics))).toEqual(
expect.objectContaining({
sn: 'CustomSn123',
})
);
});
it('treats the legacy default serial as absent', () => {
const context = buildStalkerIdentityRequestContext({
macAddress,
serialNumber: LEGACY_DEFAULT_STALKER_SERIAL,
params: {
type: 'stb',
action: 'get_profile',
sn: LEGACY_DEFAULT_STALKER_SERIAL,
metrics: JSON.stringify({
mac: macAddress,
sn: LEGACY_DEFAULT_STALKER_SERIAL,
}),
},
});
expect(context.effectiveSerialNumber).toBeUndefined();
expect(context.headers).not.toHaveProperty('SN');
expect(context.cookieString).not.toContain('__cfduid=');
expect(context.requestParams).not.toHaveProperty('sn');
expect(
JSON.parse(String(context.requestParams.metrics))
).not.toHaveProperty('sn');
});
it('removes stale SN params from non-profile requests', () => {
const context = buildStalkerIdentityRequestContext({
macAddress,
params: {
type: 'itv',
action: 'get_ordered_list',
sn: 'STALE-SN',
},
});
expect(context.requestParams).not.toHaveProperty('sn');
});
});
@@ -0,0 +1,117 @@
import {
buildStalkerSerialCfduid,
LEGACY_DEFAULT_STALKER_SERIAL,
normalizeStalkerSerialNumber,
} from '@iptvnator/shared/interfaces';
export { LEGACY_DEFAULT_STALKER_SERIAL };
interface StalkerIdentityRequestInput {
macAddress: string;
params: Record<string, string | number>;
token?: string;
serialNumber?: string;
}
interface StalkerIdentityRequestContext {
requestParams: Record<string, string | number>;
headers: Record<string, string>;
cookieString: string;
effectiveSerialNumber?: string;
}
export function buildStalkerIdentityRequestContext({
macAddress,
params,
token,
serialNumber,
}: StalkerIdentityRequestInput): StalkerIdentityRequestContext {
const effectiveSerialNumber = normalizeStalkerSerialNumber(serialNumber);
const requestParams = buildRequestParams(params, effectiveSerialNumber);
const cookieString = buildCookieString(macAddress, effectiveSerialNumber);
const headers: Record<string, string> = {
Cookie: cookieString,
// Use MAG250 User-Agent matching stalker-to-m3u implementation
'User-Agent':
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250',
'X-User-Agent':
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250',
Accept: '*/*',
Connection: 'keep-alive',
'Accept-Language': 'en-US,en;q=0.9',
};
if (effectiveSerialNumber) {
headers['SN'] = effectiveSerialNumber;
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return {
requestParams,
headers,
cookieString,
...(effectiveSerialNumber ? { effectiveSerialNumber } : {}),
};
}
function buildRequestParams(
params: Record<string, string | number>,
effectiveSerialNumber: string | undefined
): Record<string, string | number> {
const requestParams = { ...params };
const isProfileRequest =
requestParams.type === 'stb' &&
requestParams.action === 'get_profile';
if (!isProfileRequest) {
delete requestParams.sn;
return requestParams;
}
if (effectiveSerialNumber) {
requestParams.sn = effectiveSerialNumber;
} else {
delete requestParams.sn;
}
if (typeof requestParams.metrics === 'string') {
try {
const parsedMetrics = JSON.parse(requestParams.metrics);
const nextMetrics = { ...(parsedMetrics ?? {}) };
if (effectiveSerialNumber) {
nextMetrics.sn = effectiveSerialNumber;
} else {
delete nextMetrics.sn;
}
requestParams.metrics = JSON.stringify(nextMetrics);
} catch {
// Keep original metrics payload when malformed.
}
}
return requestParams;
}
function buildCookieString(
macAddress: string,
effectiveSerialNumber: string | undefined
): string {
const cookieParts = [
`mac=${macAddress}`,
'stb_lang=en_US@rg=dezzzz',
'timezone=Europe/Berlin',
];
if (effectiveSerialNumber) {
cookieParts.push(
`__cfduid=${buildStalkerSerialCfduid(effectiveSerialNumber)}`
);
}
return cookieParts.join('; ');
}
@@ -4,40 +4,11 @@
*/
import axios, { AxiosRequestConfig } from 'axios';
import { createHash } from 'crypto';
import { ipcMain } from 'electron';
import { PortalDebugEvent, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
import { rememberStalkerPlaybackContext } from '../services/stalker-playback-context.service';
import { emitPortalDebugEvent } from './portal-debug.events';
const LEGACY_DEFAULT_SERIAL = 'BEDACD4569BAF';
function deriveStalkerIdentity(
macAddress: string,
providedSerial?: string
): { serialNumber: string; cfduid: string } {
const normalizedMac = String(macAddress ?? '').trim().toUpperCase();
const md5 = createHash('md5').update(normalizedMac).digest('hex');
const derivedSerial = md5.slice(0, 13).toUpperCase();
const normalizedProvided = String(providedSerial ?? '')
.trim()
.toUpperCase();
const useProvidedSerial =
normalizedProvided.length > 0 &&
normalizedProvided !== LEGACY_DEFAULT_SERIAL;
const serialNumber = useProvidedSerial ? normalizedProvided : derivedSerial;
// Keep serial and __cfduid coherent: serial as prefix + stable MAC hash tail.
const serialPrefix = serialNumber.toLowerCase().replace(/[^a-f0-9]/g, '');
const cfduid = `${serialPrefix}${md5.slice(serialPrefix.length)}`.slice(
0,
32
);
return { serialNumber, cfduid };
}
import { buildStalkerIdentityRequestContext } from './stalker-identity';
export default class StalkerEvents {
static bootstrapStalkerEvents(): Electron.IpcMain {
@@ -55,7 +26,7 @@ ipcMain.handle(
payload: {
url: string;
macAddress: string;
params: Record<string, string>;
params: Record<string, string | number>;
token?: string;
serialNumber?: string;
requestId?: string;
@@ -66,29 +37,13 @@ ipcMain.handle(
try {
const { url, macAddress, params, token, serialNumber, requestId } =
payload;
const identity = deriveStalkerIdentity(macAddress, serialNumber);
const effectiveSerialNumber = identity.serialNumber;
const requestParams = { ...params };
// Some providers validate sn/metrics strongly in get_profile.
if (
requestParams.type === 'stb' &&
requestParams.action === 'get_profile'
) {
requestParams.sn = effectiveSerialNumber;
if (typeof requestParams.metrics === 'string') {
try {
const parsedMetrics = JSON.parse(requestParams.metrics);
requestParams.metrics = JSON.stringify({
...(parsedMetrics ?? {}),
sn: effectiveSerialNumber,
});
} catch {
// Keep original metrics payload when malformed.
}
}
}
const { requestParams, headers, effectiveSerialNumber } =
buildStalkerIdentityRequestContext({
macAddress,
params,
token,
serialNumber,
});
// Build URL with query parameters
// Note: For 'cmd' parameter, we need to use encodeURI (not encodeURIComponent)
@@ -117,34 +72,6 @@ ipcMain.handle(
// Build final URL with manually constructed query string
const fullUrl = `${urlObject.origin}${urlObject.pathname}?${queryParts.join('&')}`;
// Build cookie string matching the working curl example format
// Format: mac=XX:XX:XX:XX:XX:XX; stb_lang=de_DE; timezone=Europe/Berlin; __cfduid=...
// The __cfduid cookie uses the serial number lowercase + random suffix
const cookieString = `mac=${macAddress}; stb_lang=en_US@rg=dezzzz; timezone=Europe/Berlin; __cfduid=${identity.cfduid}`;
// Build headers - using MAG250 User-Agent that stalker-to-m3u uses
const headers: Record<string, string> = {
Cookie: cookieString,
// Use MAG250 User-Agent matching stalker-to-m3u implementation
'User-Agent':
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250',
'X-User-Agent':
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250',
Accept: '*/*',
Connection: 'keep-alive',
'Accept-Language': 'en-US,en;q=0.9',
};
// Add SN (serial number) header if provided - required by some portals
if (effectiveSerialNumber) {
headers['SN'] = effectiveSerialNumber;
}
// Add Authorization header if token is provided
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// Determine timeout based on action type
// create_link requests can take longer as server generates stream URL
const isCreateLink = requestParams.action === 'create_link';
@@ -200,7 +127,7 @@ ipcMain.handle(
const debugEvent: PortalDebugEvent = {
requestId,
provider: 'stalker',
operation: params.action ?? 'unknown',
operation: String(params.action ?? 'unknown'),
transport: 'electron-main',
startedAt: new Date(startedAt).toISOString(),
durationMs: Date.now() - startedAt,
@@ -217,7 +144,7 @@ ipcMain.handle(
const debugEvent: PortalDebugEvent = {
requestId: payload.requestId,
provider: 'stalker',
operation: payload.params?.action ?? 'unknown',
operation: String(payload.params?.action ?? 'unknown'),
transport: 'electron-main',
startedAt: new Date(startedAt).toISOString(),
durationMs: Date.now() - startedAt,
@@ -0,0 +1,55 @@
import { LEGACY_DEFAULT_STALKER_SERIAL } from '@iptvnator/shared/interfaces';
import {
getStalkerPlaybackContextHeaders,
rememberStalkerPlaybackContext,
} from './stalker-playback-context.service';
describe('stalker playback context', () => {
const macAddress = '00:1A:79:AA:BB:CC';
function rememberSameOriginContext(
name: string,
serialNumber?: string
): Record<string, string> {
const origin = `http://${name}.example.test`;
const streamUrl = `${origin}/stream/1.ts`;
rememberStalkerPlaybackContext({
streamUrl,
portalUrl: `${origin}/stalker_portal/server/load.php`,
macAddress,
serialNumber,
token: 'token-1',
});
return getStalkerPlaybackContextHeaders(streamUrl) ?? {};
}
it('does not add SN or a serial-derived __cfduid when serial is absent', () => {
const headers = rememberSameOriginContext('stalker-no-serial');
expect(headers).not.toHaveProperty('SN');
expect(headers['Cookie']).not.toContain('__cfduid=');
});
it('preserves a provided serial and creates a canonical __cfduid', () => {
const headers = rememberSameOriginContext(
'stalker-with-serial',
' CustomSn123 '
);
const cfduid = headers['Cookie']?.match(/__cfduid=([^;]+)/)?.[1];
expect(headers['SN']).toBe('CustomSn123');
expect(cfduid).toHaveLength(32);
});
it('treats the legacy default serial as absent', () => {
const headers = rememberSameOriginContext(
'stalker-legacy-serial',
LEGACY_DEFAULT_STALKER_SERIAL
);
expect(headers).not.toHaveProperty('SN');
expect(headers['Cookie']).not.toContain('__cfduid=');
});
});
@@ -1,3 +1,8 @@
import {
buildStalkerSerialCfduid,
normalizeStalkerSerialNumber,
} from '@iptvnator/shared/interfaces';
const STALKER_MAG_USER_AGENT =
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250';
const STALKER_STREAM_USER_AGENT = 'KSPlayer';
@@ -66,16 +71,15 @@ export function rememberStalkerPlaybackContext(input: {
const streamKey = normalizeStreamUrl(input.streamUrl);
if (!streamKey || !input.macAddress) return;
const serialNumber = normalizeStalkerSerialNumber(input.serialNumber);
const cookieParts = [
`mac=${input.macAddress}`,
'stb_lang=en_US@rg=dezzzz',
'timezone=Europe/Berlin',
];
if (input.serialNumber) {
cookieParts.push(
`__cfduid=${input.serialNumber.toLowerCase()}e030245495acd6ebfc1`
);
if (serialNumber) {
cookieParts.push(`__cfduid=${buildStalkerSerialCfduid(serialNumber)}`);
}
const streamOrigin = getOrigin(input.streamUrl) || undefined;
@@ -112,8 +116,8 @@ export function rememberStalkerPlaybackContext(input: {
headers['Referer'] = portalOrigin;
}
if (!crossOriginStream && input.serialNumber) {
headers['SN'] = input.serialNumber;
if (!crossOriginStream && serialNumber) {
headers['SN'] = serialNumber;
}
if (!crossOriginStream && input.token) {
+23
View File
@@ -103,6 +103,29 @@ Failure-handling rule:
not `undefined` collections or renderer exceptions. The workspace Stalker
context panel and live layout rely on this guarantee.
## Stalker Identity Policy
Full Stalker/Ministra portal authentication defaults to MAC-only identity. The
import UI can capture optional serial number, device IDs, and signatures, but
blank fields are not generated or forwarded to `get_profile`.
- User-provided `sn`, `device_id`, `device_id2`, `signature`, and `signature2`
values are trimmed, persisted under the canonical `stalker*` playlist fields,
and reused for initial auth, token refresh, retry auth, normal API requests,
and same-origin playback headers.
- Empty optional identity fields remain absent. IPTVnator must not generate a
device ID from the MAC address or duplicate `device_id2` from `device_id1`.
- The legacy default serial value `BEDACD4569BAF` is treated as absent at
runtime so older blank imports do not keep sending a synthetic serial number.
- Playback headers use the same serial normalization, so the legacy default is
not sent as `SN` or as a serial-derived `__cfduid`. MAC-only API and
playback requests do not synthesize `__cfduid`; when a real serial is
present, same-origin playback uses a canonical 32-character `__cfduid`
protocol cookie.
- Generated MAG-like identity remains a future explicit setting. It must not be
the default because strict portals can bind accounts to the first device
fingerprint they receive.
## Live TV and Radio
The Stalker live route and radio route intentionally share
@@ -0,0 +1,120 @@
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import { StalkerPortalImportComponent } from './stalker-portal-import.component';
describe('StalkerPortalImportComponent identity handling', () => {
let component: StalkerPortalImportComponent;
let stalkerSession: { authenticate: jest.Mock };
let store: { dispatch: jest.Mock };
beforeEach(() => {
stalkerSession = {
authenticate: jest.fn().mockResolvedValue({ token: 'token-1' }),
};
store = {
dispatch: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
{ provide: StalkerSessionService, useValue: stalkerSession },
{ provide: Store, useValue: store },
{
provide: MatSnackBar,
useValue: { open: jest.fn() },
},
{
provide: TranslateService,
useValue: { instant: jest.fn((value: string) => value) },
},
],
});
component = TestBed.runInInjectionContext(
() => new StalkerPortalImportComponent()
);
});
it('passes trimmed SN, device IDs, and signatures into initial authentication and persisted playlist metadata', async () => {
component.form.patchValue({
_id: 'playlist-1',
title: 'Strict Portal',
macAddress: '00:1A:79:AA:BB:CC',
portalUrl: 'https://portal.example.com/stalker_portal/c',
serialNumber: ' CUSTOMSN123 ',
deviceId1: ' DEVICE-ID-1 ',
deviceId2: ' DEVICE-ID-2 ',
signature1: ' SIGNATURE-1 ',
signature2: ' SIGNATURE-2 ',
importDate: '2026-05-15T00:00:00.000Z',
});
await component.addPlaylist();
expect(stalkerSession.authenticate).toHaveBeenCalledWith(
'https://portal.example.com/stalker_portal/server/load.php',
'00:1A:79:AA:BB:CC',
{
serialNumber: 'CUSTOMSN123',
deviceId1: 'DEVICE-ID-1',
deviceId2: 'DEVICE-ID-2',
signature1: 'SIGNATURE-1',
signature2: 'SIGNATURE-2',
}
);
const playlist = store.dispatch.mock.calls[0][0].playlist;
expect(playlist).toEqual(
expect.objectContaining({
stalkerSerialNumber: 'CUSTOMSN123',
stalkerDeviceId1: 'DEVICE-ID-1',
stalkerDeviceId2: 'DEVICE-ID-2',
stalkerSignature1: 'SIGNATURE-1',
stalkerSignature2: 'SIGNATURE-2',
})
);
expect(playlist.serialNumber).toBeUndefined();
expect(playlist.deviceId1).toBeUndefined();
expect(playlist.deviceId2).toBeUndefined();
expect(playlist.signature1).toBeUndefined();
expect(playlist.signature2).toBeUndefined();
});
it('keeps blank Stalker identity fields absent instead of generating defaults', async () => {
component.form.patchValue({
_id: 'playlist-1',
title: 'MAC Only Portal',
macAddress: '00:1A:79:AA:BB:CC',
portalUrl: 'https://portal.example.com/stalker_portal/c',
serialNumber: ' ',
deviceId1: ' ',
deviceId2: '',
signature1: ' ',
signature2: '',
importDate: '2026-05-15T00:00:00.000Z',
});
await component.addPlaylist();
expect(stalkerSession.authenticate).toHaveBeenCalledWith(
'https://portal.example.com/stalker_portal/server/load.php',
'00:1A:79:AA:BB:CC',
{}
);
const playlist = store.dispatch.mock.calls[0][0].playlist;
expect(playlist.stalkerSerialNumber).toBeUndefined();
expect(playlist.stalkerDeviceId1).toBeUndefined();
expect(playlist.stalkerDeviceId2).toBeUndefined();
expect(playlist.stalkerSignature1).toBeUndefined();
expect(playlist.stalkerSignature2).toBeUndefined();
expect(playlist.serialNumber).toBeUndefined();
expect(playlist.deviceId1).toBeUndefined();
expect(playlist.deviceId2).toBeUndefined();
expect(playlist.signature1).toBeUndefined();
expect(playlist.signature2).toBeUndefined();
});
});
@@ -13,8 +13,9 @@ import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
STALKER_SERIAL_NUMBER,
StalkerPortalIdentity,
StalkerSessionService,
normalizeStalkerPortalIdentity,
} from '@iptvnator/portal/stalker/data-access';
import { Playlist } from '@iptvnator/shared/interfaces';
import { v4 as uuid } from 'uuid';
@@ -105,44 +106,30 @@ export class StalkerPortalImportComponent {
this.isLoading.set(true);
try {
const originalUrl = this.form.value.portalUrl;
const formValue = this.form.getRawValue();
const originalUrl = formValue.portalUrl ?? '';
const transformedUrl = this.transformPortalUrl(originalUrl);
const isFullStalkerPortal =
this.isFullStalkerPortalUrl(originalUrl);
const stalkerIdentity = normalizeStalkerPortalIdentity({
serialNumber: formValue.serialNumber,
deviceId1: formValue.deviceId1,
deviceId2: formValue.deviceId2,
signature1: formValue.signature1,
signature2: formValue.signature2,
});
let stalkerToken: string | undefined;
let stalkerAccountInfo: Playlist['stalkerAccountInfo'] | undefined;
let stalkerSerialNumber: string | undefined;
let stalkerDeviceId1: string | undefined;
let stalkerDeviceId2: string | undefined;
let stalkerSignature1: string | undefined;
let stalkerSignature2: string | undefined;
// For full stalker portal URLs, perform handshake and get profile
if (isFullStalkerPortal) {
// Use provided serial number or generate a new one
stalkerSerialNumber =
this.form.value.serialNumber?.trim() ||
STALKER_SERIAL_NUMBER;
// Use provided device IDs if available (64 hex chars each)
stalkerDeviceId1 =
this.form.value.deviceId1?.trim() || undefined;
stalkerDeviceId2 =
this.form.value.deviceId2?.trim() || undefined;
stalkerSignature1 =
this.form.value.signature1?.trim() || undefined;
stalkerSignature2 =
this.form.value.signature2?.trim() || undefined;
try {
const authResult =
await this.stalkerSessionService.authenticate(
transformedUrl,
this.form.value.macAddress,
stalkerSerialNumber,
stalkerDeviceId1,
stalkerDeviceId2
formValue.macAddress ?? '',
stalkerIdentity
);
stalkerToken = authResult.token;
@@ -183,17 +170,22 @@ export class StalkerPortalImportComponent {
}
}
const {
serialNumber: _serialNumber,
deviceId1: _deviceId1,
deviceId2: _deviceId2,
signature1: _signature1,
signature2: _signature2,
...playlistFormValue
} = formValue;
const playlist: Playlist = {
...this.form.value,
...playlistFormValue,
portalUrl: transformedUrl,
isFullStalkerPortal,
stalkerToken,
stalkerAccountInfo,
stalkerSerialNumber,
stalkerDeviceId1,
stalkerDeviceId2,
stalkerSignature1,
stalkerSignature2,
...this.toPlaylistIdentityFields(stalkerIdentity),
} as Playlist;
this.store.dispatch(PlaylistActions.addPlaylist({ playlist }));
@@ -211,6 +203,32 @@ export class StalkerPortalImportComponent {
return url.includes('/stalker_portal');
}
private toPlaylistIdentityFields(identity: StalkerPortalIdentity): {
stalkerSerialNumber?: string;
stalkerDeviceId1?: string;
stalkerDeviceId2?: string;
stalkerSignature1?: string;
stalkerSignature2?: string;
} {
return {
...(identity.serialNumber
? { stalkerSerialNumber: identity.serialNumber }
: {}),
...(identity.deviceId1
? { stalkerDeviceId1: identity.deviceId1 }
: {}),
...(identity.deviceId2
? { stalkerDeviceId2: identity.deviceId2 }
: {}),
...(identity.signature1
? { stalkerSignature1: identity.signature1 }
: {}),
...(identity.signature2
? { stalkerSignature2: identity.signature2 }
: {}),
};
}
/**
* Transforms the portal URL to the correct API endpoint
* - Simple URL (example.com/c) -> example.com/portal.php
@@ -0,0 +1,33 @@
import {
normalizeStalkerPortalIdentity as normalizeSharedStalkerPortalIdentity,
type Playlist,
type StalkerPortalIdentity,
} from '@iptvnator/shared/interfaces';
export {
buildStalkerSerialCfduid,
LEGACY_DEFAULT_STALKER_SERIAL,
normalizeStalkerIdentityValue,
normalizeStalkerPortalIdentity,
normalizeStalkerSerialNumber,
type StalkerPortalIdentity,
} from '@iptvnator/shared/interfaces';
export function getStalkerPortalIdentityFromPlaylist(
playlist: Pick<
Playlist,
| 'stalkerSerialNumber'
| 'stalkerDeviceId1'
| 'stalkerDeviceId2'
| 'stalkerSignature1'
| 'stalkerSignature2'
>
): StalkerPortalIdentity {
return normalizeSharedStalkerPortalIdentity({
serialNumber: playlist.stalkerSerialNumber,
deviceId1: playlist.stalkerDeviceId1,
deviceId2: playlist.stalkerDeviceId2,
signature1: playlist.stalkerSignature1,
signature2: playlist.stalkerSignature2,
});
}
@@ -0,0 +1,66 @@
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { buildStalkerExternalPlaybackHeaders } from './stalker-live-playback.utils';
import { STALKER_SERIAL_NUMBER } from './stalker-session.service';
function createPlaylist(
overrides: Partial<PlaylistMeta> = {}
): PlaylistMeta {
return {
_id: 'playlist-1',
title: 'Playlist',
filename: '',
count: 0,
url: '',
importDate: '',
filePath: '',
updateDate: '',
updateState: '',
position: 0,
autoRefresh: false,
favorites: [],
serverUrl: '',
username: '',
password: '',
macAddress: '00:1A:79:AA:BB:CC',
hiddenGroupTitles: [],
portalUrl: 'http://portal.test/stalker_portal/c/index.html',
recentlyViewed: [],
isFullStalkerPortal: true,
...overrides,
};
}
describe('buildStalkerExternalPlaybackHeaders', () => {
it('treats the legacy default serial as absent', () => {
const headers = buildStalkerExternalPlaybackHeaders(
createPlaylist({ stalkerSerialNumber: STALKER_SERIAL_NUMBER }),
'TOKEN',
'http://portal.test/stalker_portal/server/load.php'
);
expect(headers).not.toHaveProperty('SN');
expect(headers['Cookie']).not.toContain('__cfduid=');
});
it('trims a provided serial before adding same-origin playback headers', () => {
const headers = buildStalkerExternalPlaybackHeaders(
createPlaylist({ stalkerSerialNumber: ' CustomSn123 ' }),
'TOKEN',
'http://portal.test/stalker_portal/server/load.php'
);
expect(headers['SN']).toBe('CustomSn123');
expect(headers['Cookie']).toContain('__cfduid=');
});
it('uses a canonical 32-character __cfduid when serial is provided', () => {
const headers = buildStalkerExternalPlaybackHeaders(
createPlaylist({ stalkerSerialNumber: ' CustomSn123 ' }),
'TOKEN',
'http://portal.test/stalker_portal/server/load.php'
);
const cfduid = headers['Cookie']?.match(/__cfduid=([^;]+)/)?.[1];
expect(cfduid).toHaveLength(32);
});
});
@@ -1,4 +1,8 @@
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
buildStalkerSerialCfduid,
normalizeStalkerSerialNumber,
} from './stalker-identity.utils';
export const STALKER_MAG_USER_AGENT =
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250';
@@ -59,19 +63,23 @@ export function buildStalkerExternalPlaybackHeaders(
'stb_lang=en_US@rg=dezzzz',
'timezone=Europe/Berlin',
];
const serialNumber = normalizeStalkerSerialNumber(
playlist.stalkerSerialNumber
);
if (playlist.stalkerSerialNumber) {
cookieParts.push(
`__cfduid=${playlist.stalkerSerialNumber.toLowerCase()}e030245495acd6ebfc1`
);
if (serialNumber) {
cookieParts.push(`__cfduid=${buildStalkerSerialCfduid(serialNumber)}`);
}
const headers: Record<string, string> = {
Cookie: cookieParts.join('; '),
'X-User-Agent': STALKER_MAG_USER_AGENT,
SN: playlist.stalkerSerialNumber || '',
};
if (serialNumber) {
headers['SN'] = serialNumber;
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
@@ -0,0 +1,189 @@
import { TestBed } from '@angular/core/testing';
import { DataService } from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import {
STALKER_SERIAL_NUMBER,
StalkerProfileResponse,
StalkerSessionService,
} from './stalker-session.service';
type ExpectedStalkerPortalIdentity = {
serialNumber?: string;
deviceId1?: string;
deviceId2?: string;
signature1?: string;
signature2?: string;
};
type GetProfileWithIdentity = (
portalUrl: string,
macAddress: string,
token: string,
identity: ExpectedStalkerPortalIdentity,
handshakeRandom: string
) => Promise<StalkerProfileResponse>;
describe('StalkerSessionService identity payloads', () => {
const portalUrl = 'https://portal.example.com/stalker_portal/server/load.php';
const macAddress = '00:1A:79:AA:BB:CC';
let service: StalkerSessionService;
let dataService: { sendIpcEvent: jest.Mock };
beforeEach(() => {
Object.defineProperty(globalThis, 'crypto', {
configurable: true,
value: {
subtle: {
digest: jest.fn(
async () => new Uint8Array(20).fill(1).buffer
),
},
},
});
dataService = {
sendIpcEvent: jest.fn().mockResolvedValue({ js: {} }),
};
TestBed.configureTestingModule({
providers: [
StalkerSessionService,
{ provide: DataService, useValue: dataService },
],
});
service = TestBed.inject(StalkerSessionService);
});
it('omits SN, device IDs, and signatures from get_profile when identity is blank', async () => {
const getProfile =
service.getProfile as unknown as GetProfileWithIdentity;
await getProfile.call(
service,
portalUrl,
macAddress,
'token-1',
{},
'random-1'
);
const payload = lastStalkerPayload();
expect(payload.serialNumber).toBeUndefined();
expect(payload.params).not.toHaveProperty('sn');
expect(payload.params).not.toHaveProperty('device_id');
expect(payload.params).not.toHaveProperty('device_id2');
expect(payload.params).not.toHaveProperty('signature');
expect(payload.params).not.toHaveProperty('signature2');
expect(JSON.parse(String(payload.params.metrics))).not.toHaveProperty(
'sn'
);
});
it('sends provided SN, device IDs, and signatures exactly in get_profile', async () => {
const getProfile =
service.getProfile as unknown as GetProfileWithIdentity;
await getProfile.call(
service,
portalUrl,
macAddress,
'token-1',
{
serialNumber: 'CUSTOMSN123',
deviceId1: 'DEVICE-ID-1',
deviceId2: 'DEVICE-ID-2',
signature1: 'SIGNATURE-1',
signature2: 'SIGNATURE-2',
},
'random-1'
);
const payload = lastStalkerPayload();
expect(payload.serialNumber).toBe('CUSTOMSN123');
expect(payload.params).toEqual(
expect.objectContaining({
sn: 'CUSTOMSN123',
device_id: 'DEVICE-ID-1',
device_id2: 'DEVICE-ID-2',
signature: 'SIGNATURE-1',
signature2: 'SIGNATURE-2',
})
);
expect(JSON.parse(String(payload.params.metrics))).toEqual(
expect.objectContaining({
sn: 'CUSTOMSN123',
})
);
});
it('passes stored playlist identity into ensureToken re-authentication', async () => {
const authenticate = jest
.spyOn(service, 'authenticate')
.mockResolvedValue({ token: 'fresh-token' });
await service.ensureToken({
_id: 'playlist-1',
portalUrl,
macAddress,
isFullStalkerPortal: true,
stalkerSerialNumber: 'CUSTOMSN123',
stalkerDeviceId1: 'DEVICE-ID-1',
stalkerDeviceId2: 'DEVICE-ID-2',
stalkerSignature1: 'SIGNATURE-1',
stalkerSignature2: 'SIGNATURE-2',
} as Playlist);
expect(authenticate).toHaveBeenCalledWith(portalUrl, macAddress, {
serialNumber: 'CUSTOMSN123',
deviceId1: 'DEVICE-ID-1',
deviceId2: 'DEVICE-ID-2',
signature1: 'SIGNATURE-1',
signature2: 'SIGNATURE-2',
});
});
it('treats the legacy default serial number as absent during ensureToken', async () => {
const authenticate = jest
.spyOn(service, 'authenticate')
.mockResolvedValue({ token: 'fresh-token' });
const result = await service.ensureToken({
_id: 'playlist-1',
portalUrl,
macAddress,
isFullStalkerPortal: true,
stalkerSerialNumber: STALKER_SERIAL_NUMBER,
} as Playlist);
expect(result.serialNumber).toBeUndefined();
expect(authenticate).toHaveBeenCalledWith(portalUrl, macAddress, {});
});
it('passes an explicit serial into the initial handshake request', async () => {
dataService.sendIpcEvent
.mockResolvedValueOnce({
js: {
token: 'token-1',
random: 'random-1',
},
})
.mockResolvedValueOnce({ js: {} });
await service.authenticate(portalUrl, macAddress, {
serialNumber: 'CUSTOMSN123',
});
const handshakePayload = dataService.sendIpcEvent.mock.calls[0][1];
expect(handshakePayload.params.action).toBe('handshake');
expect(handshakePayload.serialNumber).toBe('CUSTOMSN123');
});
function lastStalkerPayload(): {
params: Record<string, unknown>;
serialNumber?: string;
} {
return dataService.sendIpcEvent.mock.calls.at(-1)?.[1];
}
});
@@ -1,6 +1,18 @@
import { Injectable, inject } from '@angular/core';
import { Playlist, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
import { DataService } from '@iptvnator/services';
import {
getStalkerPortalIdentityFromPlaylist,
LEGACY_DEFAULT_STALKER_SERIAL,
normalizeStalkerPortalIdentity,
type StalkerPortalIdentity,
} from './stalker-identity.utils';
export {
getStalkerPortalIdentityFromPlaylist,
normalizeStalkerPortalIdentity,
};
export type { StalkerPortalIdentity };
/**
* SHA1 hash using native Web Crypto API
@@ -36,11 +48,7 @@ function generateRandom(): string {
return result;
}
/**
* Deterministic serial number - 13 hex characters
* Constant value for consistency across all sessions
*/
export const STALKER_SERIAL_NUMBER = 'BEDACD4569BAF';
export const STALKER_SERIAL_NUMBER = LEGACY_DEFAULT_STALKER_SERIAL;
const STALKER_WATCHDOG_INTERVAL_MS = 25_000;
export interface StalkerHandshakeResponse {
@@ -97,25 +105,6 @@ export class StalkerSessionService {
private watchdogInFlight = new Set<string>();
private activeWatchdogPlaylistId: string | null = null;
/**
* Generates a consistent 64-character device ID from MAC address
*/
private generateDeviceId(macAddress: string): string {
// Generate consistent device ID from MAC
const str = macAddress.toUpperCase().replace(/:/g, '');
const chars = '0123456789ABCDEF';
let result = '';
let seed = 0;
for (let i = 0; i < str.length; i++) {
seed = (seed * 31 + str.charCodeAt(i)) >>> 0;
}
for (let i = 0; i < 64; i++) {
seed = (seed * 1103515245 + 12345) >>> 0;
result += chars[seed % 16];
}
return result;
}
/**
* Checks if a URL is a full stalker portal URL (requires handshake)
* Full stalker portal URLs contain /stalker_portal/ in the path
@@ -252,8 +241,10 @@ export class StalkerSessionService {
*/
async performHandshake(
portalUrl: string,
macAddress: string
macAddress: string,
identity: StalkerPortalIdentity = {}
): Promise<{ token: string; random: string }> {
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
const prehash = await generatePrehash(macAddress);
const params: Record<string, string> = {
@@ -269,9 +260,12 @@ export class StalkerSessionService {
await this.dataService.sendIpcEvent<StalkerHandshakeResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
url: portalUrl,
macAddress,
params,
...(normalizedIdentity.serialNumber
? { serialNumber: normalizedIdentity.serialNumber }
: {}),
}
);
@@ -298,29 +292,20 @@ export class StalkerSessionService {
portalUrl: string,
macAddress: string,
token: string,
serialNumber: string,
handshakeRandom: string,
providedDeviceId1?: string,
providedDeviceId2?: string,
providedSignature1?: string,
providedSignature2?: string
identity: StalkerPortalIdentity,
handshakeRandom: string
): Promise<StalkerProfileResponse> {
// Use provided device IDs or generate from MAC (64 hex chars each)
const deviceId1 =
providedDeviceId1?.trim() || this.generateDeviceId(macAddress);
const deviceId2 = providedDeviceId2?.trim() || deviceId1;
// Use provided signatures or empty string (some portals don't require them)
const signature1 = providedSignature1?.trim() || '';
const signature2 = providedSignature2?.trim() || '';
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
// Build metrics JSON matching working app
const metrics = {
const metrics: Record<string, string> = {
mac: macAddress,
model: 'MAG250',
type: 'STB',
random: handshakeRandom,
sn: serialNumber,
...(normalizedIdentity.serialNumber
? { sn: normalizedIdentity.serialNumber }
: {}),
};
// Generate prehash for get_profile (same as handshake)
@@ -337,11 +322,21 @@ export class StalkerSessionService {
auth_second_step: '1',
num_banks: '2',
metrics: JSON.stringify(metrics),
sn: serialNumber,
device_id: deviceId1,
device_id2: deviceId2,
signature: signature1,
...(signature2 ? { signature2: signature2 } : {}),
...(normalizedIdentity.serialNumber
? { sn: normalizedIdentity.serialNumber }
: {}),
...(normalizedIdentity.deviceId1
? { device_id: normalizedIdentity.deviceId1 }
: {}),
...(normalizedIdentity.deviceId2
? { device_id2: normalizedIdentity.deviceId2 }
: {}),
...(normalizedIdentity.signature1
? { signature: normalizedIdentity.signature1 }
: {}),
...(normalizedIdentity.signature2
? { signature2: normalizedIdentity.signature2 }
: {}),
prehash: prehash,
stb_type: '',
JsHttpRequest: '1-xml',
@@ -352,11 +347,13 @@ export class StalkerSessionService {
await this.dataService.sendIpcEvent<StalkerProfileResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
token,
serialNumber,
url: portalUrl,
macAddress,
params,
token,
...(normalizedIdentity.serialNumber
? { serialNumber: normalizedIdentity.serialNumber }
: {}),
}
);
@@ -380,8 +377,6 @@ export class StalkerSessionService {
action: 'do_auth',
login: '',
password: '',
device_id: '',
device_id2: '',
JsHttpRequest: '1-xml',
};
@@ -390,10 +385,10 @@ export class StalkerSessionService {
await this.dataService.sendIpcEvent<StalkerAuthConfirmationResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
token,
url: portalUrl,
macAddress,
params,
token,
}
);
@@ -416,19 +411,18 @@ export class StalkerSessionService {
async authenticate(
portalUrl: string,
macAddress: string,
serialNumber: string,
deviceId1?: string,
deviceId2?: string,
signature1?: string,
signature2?: string
identity: StalkerPortalIdentity = {}
): Promise<{
token: string;
accountInfo?: StalkerProfileResponse['js']['account_info'];
}> {
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
// Step 1: Handshake to get token and random
const { token, random } = await this.performHandshake(
portalUrl,
macAddress
macAddress,
normalizedIdentity
);
// Step 2: Get profile to activate token and get account info
@@ -438,12 +432,8 @@ export class StalkerSessionService {
portalUrl,
macAddress,
token,
serialNumber,
random,
deviceId1,
deviceId2,
signature1,
signature2
normalizedIdentity,
random
);
// Check for profile-level errors
@@ -480,10 +470,12 @@ export class StalkerSessionService {
return { token: null };
}
const identity = getStalkerPortalIdentityFromPlaylist(playlist);
// Check in-memory cache first (valid for current session only)
const cachedToken = this.getCachedToken(playlist._id);
if (cachedToken) {
return { token: cachedToken };
return { token: cachedToken, serialNumber: identity.serialNumber };
}
// Check if there's already a pending authentication for this playlist
@@ -503,12 +495,6 @@ export class StalkerSessionService {
throw new Error('Portal URL and MAC address are required');
}
// Get or generate serial number - must be consistent for the MAC
let serialNumber = playlist.stalkerSerialNumber;
if (!serialNumber) {
serialNumber = STALKER_SERIAL_NUMBER;
}
// Create the authentication promise and store it to prevent concurrent auth attempts
// Use async/await wrapper to properly clean up on both success and failure
const authPromise = (async () => {
@@ -516,10 +502,10 @@ export class StalkerSessionService {
const { token } = await this.authenticate(
playlist.portalUrl,
playlist.macAddress,
serialNumber
identity
);
this.setCachedToken(playlist._id, token);
return { token, serialNumber };
return { token, serialNumber: identity.serialNumber };
} finally {
// Clean up pending promise regardless of success/failure
this.pendingAuth.delete(playlist._id);
@@ -532,8 +518,6 @@ export class StalkerSessionService {
return authPromise;
}
/**
* Checks if a response or error indicates an authorization failure
*/
@@ -578,8 +562,7 @@ export class StalkerSessionService {
retryOnAuthFailure = true
): Promise<T> {
// Get token (will wait if auth is in progress)
const { token } = await this.ensureToken(playlist);
const serialNumber = playlist.stalkerSerialNumber;
const { token, serialNumber } = await this.ensureToken(playlist);
try {
const response = await this.dataService.sendIpcEvent<T>(
@@ -589,7 +572,7 @@ export class StalkerSessionService {
macAddress: playlist.macAddress,
params,
token,
serialNumber,
...(serialNumber ? { serialNumber } : {}),
}
);
@@ -62,7 +62,7 @@ export function withStalkerPortal() {
toStalkerSessionPlaylist(playlist)
);
token = result.token ?? undefined;
serialNumber = playlist.stalkerSerialNumber;
serialNumber = result.serialNumber;
} catch (error) {
logger.error('Failed to get stalker token', error);
}
@@ -176,7 +176,7 @@ export class StalkerSearchComponent {
playlist as Playlist
);
token = result.token ?? undefined;
serialNumber = (playlist as Playlist).stalkerSerialNumber;
serialNumber = result.serialNumber;
} catch (error) {
this.logger.error('Failed to get stalker token', error);
}
+1
View File
@@ -43,6 +43,7 @@ export * from './lib/xtream-vod-stream.interface';
// Stalker interfaces
export * from './lib/stalker-item.normalizer';
export * from './lib/stalker-identity.utils';
export * from './lib/stalker-portal-item.interface';
export * from './lib/stalker-serial-details.interface';
export * from './lib/stalker-vod-details.interface';
@@ -0,0 +1,58 @@
export const LEGACY_DEFAULT_STALKER_SERIAL = 'BEDACD4569BAF';
const STALKER_CFDUID_LENGTH = 32;
const STALKER_SERIAL_CFDUID_SUFFIX = 'e030245495acd6ebfc1';
export interface StalkerPortalIdentity {
serialNumber?: string;
deviceId1?: string;
deviceId2?: string;
signature1?: string;
signature2?: string;
}
export function normalizeStalkerIdentityValue(
value: string | undefined | null
): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
export function normalizeStalkerSerialNumber(
serialNumber: string | undefined | null
): string | undefined {
const trimmed = normalizeStalkerIdentityValue(serialNumber);
if (
!trimmed ||
trimmed.toUpperCase() === LEGACY_DEFAULT_STALKER_SERIAL
) {
return undefined;
}
return trimmed;
}
export function normalizeStalkerPortalIdentity(
identity: StalkerPortalIdentity | undefined | null
): StalkerPortalIdentity {
const serialNumber = normalizeStalkerSerialNumber(identity?.serialNumber);
const deviceId1 = normalizeStalkerIdentityValue(identity?.deviceId1);
const deviceId2 = normalizeStalkerIdentityValue(identity?.deviceId2);
const signature1 = normalizeStalkerIdentityValue(identity?.signature1);
const signature2 = normalizeStalkerIdentityValue(identity?.signature2);
return {
...(serialNumber ? { serialNumber } : {}),
...(deviceId1 ? { deviceId1 } : {}),
...(deviceId2 ? { deviceId2 } : {}),
...(signature1 ? { signature1 } : {}),
...(signature2 ? { signature2 } : {}),
};
}
export function buildStalkerSerialCfduid(serialNumber: string): string {
const serialPrefix = serialNumber.toLowerCase().replace(/[^a-f0-9]/g, '');
return `${serialPrefix}${STALKER_SERIAL_CFDUID_SUFFIX}`
.slice(0, STALKER_CFDUID_LENGTH)
.padEnd(STALKER_CFDUID_LENGTH, '0');
}