Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,17 @@ creating the **first admin account**. After that:
Chromium signs you in; only tokens are stored). The server then **keeps the session alive on its own**,
rotating the token ahead of each expiry — and it **auto-discovers dulo's current Supabase config at
runtime**, so when dulo migrates its Supabase project (rotating the public URL + anon key) the session
self-heals on its next refresh with no re-capture and nothing to configure. If you captured the session
self-heals on its next refresh with no re-capture and no key to bump. If you captured the session
from your own browser (pair/paste), just **close that dulo tab — don't sign out**: signing out of
dulo.tv revokes the very session you handed over.
dulo revokes the very session you handed over.

dulo also **rebrands onto new domains** periodically, and that one *is* operator-configurable: the
**Domain** field on the same panel (Settings → Advanced → Dulo.tv Authentication) drives every
dulo-facing hop — catalog fetch, playback-session mint, Supabase bundle scrape, the pairing bookmarklet,
the streamed login, and the SSRF apex. **Auto-detect** follows a redirect from the old domain (it finds a
rebrand that left a 301 behind; a hard cut-over has to be typed in), and **Test** probes a candidate
without saving it. Saving a *changed* domain **signs the dulo session out** — a captured session belongs
to the site it came from — so re-pair afterwards.
3. **Sync now** to populate channels, then optionally add **EPG Sources** and link guide data on the
**Channel Mapping** screen.
4. Create **Users** with per-user access lists — each gets a personal **tokenized `.m3u` + XMLTV guide
Expand Down Expand Up @@ -475,7 +483,7 @@ All adapters implement the `SourceAdapter` contract (`server/src/sources/types.t
| `direct` | Imported | — | Identity (passthrough) | — | — |
| `hdhomerun` | HDHomeRun | — | Catalog import (playback dormant — needs remux) | — | — |
| `local` | Local Now | — | Sentinel → rotating CDN | — | — |
| `dulo` | dulo.tv | session | `dulo://` sentinel → playbackUrl | — | yes |
| `dulo` | dulo.tv (default; operator-set) | session | `dulo://` sentinel → playbackUrl | — | yes |
| `dlhd` | DaddyLive | — | `watch.php` → 3-hop scrape, 6 providers | yes | yes |
| `tubi` | Tubi.TV | — | `tubi://` → Tubi API | yes (inline) | — |
| `xumo` | Xumo Play | — | broadcast.json → 3-hop API | yes | — |
Expand Down
9 changes: 9 additions & 0 deletions server/src/backup/restoreBackup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { bootInitSources } from '../sources/seed.js';
import { startScheduler, removeAllCronjobs } from '../scheduler/index.js';
import { duloAuth } from '../sources/adapters/dulo/auth.js';
import { applyDnsFromSettings } from '../settings/applyDns.js';
import { applyDuloDomainFromSettings } from '../settings/applyDuloDomain.js';
import { logger } from '../sources/core/logger.js';

// Thrown when an uploaded/stored buffer is not a recognizable backup — the routes map it to 400 bad_backup.
Expand Down Expand Up @@ -118,6 +119,14 @@ export async function applyPostRestore(): Promise<void> {
} catch (err) {
logger.warn('settings', `post-restore: dns re-apply failed (continuing): ${(err as Error).message}`);
}
// A restored backup can carry a different Settings.duloDomain — re-hydrate the adapter cache from it.
// Phase 'mongo' deliberately, NOT 'update': the restore already replaced the playlistauths row wholesale
// (and invalidates the auth cache below), so signing the restored session out would be wrong.
try {
await applyDuloDomainFromSettings('mongo');
} catch (err) {
logger.warn('settings', `post-restore: dulo domain re-apply failed (continuing): ${(err as Error).message}`);
}
try {
await startScheduler();
} catch (err) {
Expand Down
11 changes: 11 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { logsRouter } from './routes/logs.js';
import { startLogStore, stopLogStore, attachLogs, closeAllLogs } from './logs/logStore.js';
import { applyDnsFromSettings } from './settings/applyDns.js';
import { applyDlhdPlayerFromSettings } from './settings/applyDlhdPlayer.js';
import { applyDuloDomainFromSettings } from './settings/applyDuloDomain.js';
import { logger } from './sources/core/logger.js';
import { startProxySidecar, stopProxySidecar, EDGE } from './proxy/sidecar.js';
import { internalRouter } from './routes/internal.js';
Expand Down Expand Up @@ -104,6 +105,16 @@ async function main() {
logger.error('startup', `dlhd player default apply error (continuing): ${(err as Error).message}`);
}

// Hydrate the dulo adapter's domain cache from the persisted settings. MUST run before
// startDuloKeepalive() below, so the keepalive's first token refresh already targets the configured
// domain. Boot never signs the session out — it only mirrors what is already stored. Non-fatal
// (falls back to the committed default domain).
try {
await applyDuloDomainFromSettings('mongo');
} catch (err) {
logger.error('startup', `dulo domain apply error (continuing): ${(err as Error).message}`);
}

// Register persisted cron jobs (cronjobs collection) with the scheduler. Non-fatal: a scheduler
// failure must not prevent the API from serving.
try {
Expand Down
8 changes: 8 additions & 0 deletions server/src/models/Settings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Schema, model } from 'mongoose';
import { DULO_DEFAULT_DOMAIN } from '../sources/adapters/dulo/config.js';

// Settings — a single application-settings document. Deterministic _id ('app') makes every read/write
// a singleton upsert (same idempotency rule as the synced collections). It holds operator-facing values
Expand Down Expand Up @@ -54,6 +55,12 @@ export interface SettingsDoc {
darkMode: boolean;
videoPlayer: 'inapp' | 'ultimate' | 'debug'; // which player the slide-out renders ('inapp' default; 'ultimate' = popup player window; 'debug' = diagnostic HUD)
dlhdPlayer: number; // source-wide default DaddyLive player (0 = Auto/first; 1..N) for dlhd channels without a per-channel override
// The domain dulo is currently on, as a bare host (no scheme/path) — e.g. 'dulo.tv'. dulo rebrands
// periodically, so every dulo-facing hop (catalog, playback-session, Supabase bundle scrape, pairing,
// streamed login, SSRF apex) derives from this instead of a compile-time const. Edited on
// Settings -> Advanced -> Dulo.tv Authentication; pushed into the adapter cache by
// settings/applyDuloDomain.ts. NOT to be confused with `domain` above, which is masqueradarr's OWN base URL.
duloDomain: string;
nameservers: string | null; // comma-separated outbound-fetch resolver IP(s); null/blank = OS resolver (DEFAULT_NAMESERVERS 8.8.8.8,8.8.4.4 seeds first boot)
logLevel: number; // GLOBAL 1|2|3 log verbosity — app + Rust proxy engine (default 2; formerly dnsLogLevel)
maxmindAccountId: string | null; // MaxMind GeoLite2 web-service account id (null = geo disabled)
Expand All @@ -78,6 +85,7 @@ const SettingsSchema = new Schema<SettingsDoc>(
darkMode: { type: Boolean, required: true, default: true },
videoPlayer: { type: String, required: true, default: 'inapp' },
dlhdPlayer: { type: Number, required: true, default: 0 }, // 0 = Auto; 1..N = source-wide default DaddyLive player
duloDomain: { type: String, required: true, default: DULO_DEFAULT_DOMAIN },
nameservers: { type: String, default: null },
logLevel: { type: Number, required: true, default: 2 },
maxmindAccountId: { type: String, default: null },
Expand Down
14 changes: 14 additions & 0 deletions server/src/routes/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Settings, SETTINGS_ID, type SettingsDoc } from '../models/Settings.js';
import { envDefaults, toRuntimeSettings, toExternalPatch } from '../settings/translate.js';
import { applyDnsFromSettings } from '../settings/applyDns.js';
import { applyDlhdPlayerFromSettings } from '../settings/applyDlhdPlayer.js';
import { applyDuloDomainFromSettings } from '../settings/applyDuloDomain.js';
import { cascadePlaylistUrls } from './playlists.js';
import { logger } from '../sources/core/logger.js';

Expand Down Expand Up @@ -89,6 +90,19 @@ settingsRouter.put('/', async (req, res, next) => {
}
}

// Push the dulo domain into the adapter's cache so every dulo hop (catalog, playback-session, Supabase
// discovery, pairing, streamed login) retargets live. A CHANGED domain also resets Supabase discovery
// and signs the dulo session out — a session belongs to the domain it was captured on, so the operator
// is sent back through pairing rather than hitting an opaque playback failure later. Best-effort: a
// cascade hiccup must not fail the write (same contract as the domain cascade above).
if ('duloDomain' in $set) {
try {
await applyDuloDomainFromSettings('update');
} catch (err) {
logger.error('settings', `dulo domain re-apply failed (continuing): ${(err as Error).message}`);
}
}

res.json(toRuntimeSettings(doc));
} catch (err) {
next(err);
Expand Down
105 changes: 104 additions & 1 deletion server/src/routes/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ import { createMetrics, snapshotOne, type Metrics } from '../sources/core/metric
import { syncLive, resetSource, ensureShellRow } from '../sources/seed.js';
import { duloAuth } from '../sources/adapters/dulo/auth.js';
import { duloPairing, buildBookmarklet, buildSnippet } from '../sources/adapters/dulo/pairing.js';
import {
DULO_DEFAULT_DOMAIN,
browserHeadersFor,
catalogUrlFor,
getDomain,
getOrigin,
normalizeDomain,
originFor,
} from '../sources/adapters/dulo/config.js';
import { scrapeSupabaseConfig } from '../sources/adapters/dulo/supabaseConfig.js';
import type { Request, Response } from 'express';
import { Playlist } from '../models/Playlist.js';
import { grantPlaylistToAdmins } from '../security/adminAccess.js';
Expand Down Expand Up @@ -121,6 +131,99 @@ sourcesRouter.post('/api/sources/:id/provision', async (req, res, next) => {
}
});

// ── dulo domain (Settings → Advanced → Dulo.tv Authentication) ────────────────
// dulo REBRANDS periodically, so the domain it lives on is an operator setting (Settings.duloDomain) rather
// than a compile-time const. These two endpoints only HELP the operator find and verify a candidate —
// NEITHER PERSISTS ANYTHING. The save goes through PUT /api/settings, which is where the cascade lives
// (reset Supabase discovery + sign the dulo session out, since a session belongs to the domain it was
// captured on). Admin-only via the /api/sources prefix (index.ts adminOnlyRoutes).
//
// SSRF: both fetch an OPERATOR-SUPPLIED host server-side, so every candidate goes through normalizeDomain()
// first — it strips scheme/path/port/userinfo and rejects IP literals plus private/loopback targets. This
// is the gate that actually runs on user input (the adapter's isAllowedUpstream is not wired up today).
const DOMAIN_PROBE_TIMEOUT_MS = 10_000;

// Probe a candidate domain: does it serve dulo's Live TV catalog, and is it a dulo frontend build? The
// bundle scrape is the stronger signal — any site can 404, but only dulo's build carries an
// `sb_publishable_` key next to a supabase.co project URL.
sourcesRouter.post('/api/sources/dulo/domain/test', async (req, res, next) => {
try {
const body = (req.body ?? {}) as Record<string, unknown>;
const parsed = normalizeDomain(typeof body.domain === 'string' ? body.domain : '');
if (!parsed.ok) return res.status(400).json({ error: parsed.error });

const domain = parsed.domain;
const endpoint = catalogUrlFor(domain);
let httpStatus: number | null = null;
let channelCount: number | null = null;
let error: string | null = null;
try {
const r = await fetch(endpoint, {
headers: browserHeadersFor(originFor(domain)),
signal: AbortSignal.timeout(DOMAIN_PROBE_TIMEOUT_MS),
});
httpStatus = r.status;
if (r.ok) {
const parsedBody = (await r.json()) as { channels?: unknown[] };
channelCount = Array.isArray(parsedBody.channels) ? parsedBody.channels.length : 0;
} else {
error = `catalog returned HTTP ${r.status}`;
}
} catch (err) {
error = (err as Error).message;
}

// Cache-free scrape (scrapeSupabaseConfig, not discoverSupabaseConfig) so probing a candidate can never
// poison the ACTIVE session's Supabase config.
const supabase = await scrapeSupabaseConfig(originFor(domain));

res.json({
domain,
endpoint,
ok: channelCount !== null && channelCount > 0,
httpStatus,
channelCount,
supabaseFound: !!supabase,
supabaseUrl: supabase?.supabaseUrl ?? null,
error,
});
} catch (err) {
next(err);
}
});

// Where did dulo move to? Follow redirects from the currently configured domain and, if different, from the
// committed default. A rebrand that leaves a 301/302 on the old host is discoverable this way; a hard
// cut-over (old host simply dead) is not — `detected: null` then, and the SPA says so.
sourcesRouter.post('/api/sources/dulo/domain/detect', async (_req, res, next) => {
try {
const current = getDomain();
const candidates = [...new Set([current, DULO_DEFAULT_DOMAIN])];
const tried: Array<{ from: string; landed: string | null; httpStatus: number | null; error: string | null }> = [];

for (const from of candidates) {
try {
const r = await fetch(originFor(from), {
redirect: 'follow',
headers: browserHeadersFor(originFor(from)),
signal: AbortSignal.timeout(DOMAIN_PROBE_TIMEOUT_MS),
});
const landedParsed = normalizeDomain(new URL(r.url).hostname);
const landed = landedParsed.ok ? landedParsed.domain : null;
tried.push({ from, landed, httpStatus: r.status, error: null });
if (landed && landed !== from) {
return res.json({ detected: landed, from, sameAsCurrent: landed === current, tried });
}
} catch (err) {
tried.push({ from, landed: null, httpStatus: null, error: (err as Error).message });
}
}
res.json({ detected: null, from: current, sameAsCurrent: true, tried });
} catch (err) {
next(err);
}
});

// ── dulo Live TV authentication ───────────────────────────────────────────────
// dulo gates Live TV streams behind a Supabase session (no static stream URLs). The SPA captures the
// already signed-in session from dulo.tv and POSTs the tokens here — only tokens are stored, never a
Expand Down Expand Up @@ -180,7 +283,7 @@ sourcesRouter.post('/api/sources/dulo/auth/pair', (req, res) => {
code,
expiresAt,
callbackUrl,
duloUrl: 'https://dulo.tv',
duloUrl: getOrigin(), // follows Settings.duloDomain — the SPA links the user to the right site
bookmarklet: buildBookmarklet(code, callbackUrl),
snippet: buildSnippet(code, callbackUrl),
});
Expand Down
42 changes: 42 additions & 0 deletions server/src/settings/applyDuloDomain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Bridge between the persisted `settings` singleton and the dulo adapter's module-level domain cache.
// dulo rebrands periodically, so the domain it lives on is an operator setting (Settings.duloDomain) rather
// than a compile-time const; this reads it into config.setDomain() so the synchronous hot paths
// (upstreamHeaders / isAllowedUpstream) and every dulo fetch resolve it with NO DB hit. Mirrors
// applyDlhdPlayer: called after connect (boot, source 'mongo') and on every Settings PUT that touches
// duloDomain (source 'update'). Kept out of dulo/config.ts (a Mongo-free leaf) so config never imports the
// models layer.
//
// A CHANGED domain on 'update' cascades twice:
// · resetSupabaseDiscovery() — the cached Supabase project pair was scraped from the OLD site and its
// cooldown would otherwise suppress a re-scrape for minutes.
// · duloAuth.signOut() — a captured session belongs to the domain it was captured on; rather than
// letting playback fail opaquely later, drop it and send the operator back through the pairing flow.
// Boot ('mongo') NEVER signs out — it is just hydrating the cache from what is already stored.

import { Settings, SETTINGS_ID, type SettingsDoc } from '../models/Settings.js';
import { DULO_DEFAULT_DOMAIN, getDomain, setDomain } from '../sources/adapters/dulo/config.js';
import { resetSupabaseDiscovery } from '../sources/adapters/dulo/supabaseConfig.js';
import { duloAuth } from '../sources/adapters/dulo/auth.js';
import { logger } from '../sources/core/logger.js';

const tag = 'dulo:auth';

export async function applyDuloDomainFromSettings(
source: 'mongo' | 'update',
): Promise<{ domain: string; changed: boolean }> {
const doc = (await Settings.findOne({ _id: SETTINGS_ID }, { duloDomain: 1 }).lean()) as Pick<
SettingsDoc,
'duloDomain'
> | null;
const changed = setDomain(doc?.duloDomain || DULO_DEFAULT_DOMAIN);
const domain = getDomain();

if (changed && source === 'update') {
resetSupabaseDiscovery();
await duloAuth.signOut();
logger.warn(tag, `dulo domain changed to ${domain} — session signed out, re-pair required`);
} else if (changed) {
logger.info(tag, `dulo domain set to ${domain}`);
}
return { domain, changed };
}
15 changes: 15 additions & 0 deletions server/src/settings/translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import { isIP } from 'node:net';
import type { SettingsDoc } from '../models/Settings.js';
import { DULO_DEFAULT_DOMAIN, normalizeDomain } from '../sources/adapters/dulo/config.js';
import { zoneOffsetString } from './zoneOffset.js';

// First-provision default for the outbound-fetch DNS resolver(s). Hardcoded (the NAMESERVER env was
Expand Down Expand Up @@ -62,6 +63,10 @@ export function envDefaults(): SettingsData {
videoPlayer: asVideoPlayerMode(process.env.VIDEO_PLAYER),
// Source-wide default DaddyLive player (0 = Auto). Seedable from DLHD_PLAYER; clamped to a non-negative int.
dlhdPlayer: Math.max(0, Math.trunc(Number(process.env.DLHD_PLAYER)) || 0),
// The domain dulo is currently on. Deliberately NOT env-derived: dulo identity is kept out of infra
// config (the old DULO_API / DULO_API_BASE overrides were retired with this field), so the committed
// default seeds first boot and the operator edits it on the Settings screen thereafter.
duloDomain: DULO_DEFAULT_DOMAIN,
// nameservers: hardcoded first-provision default (no longer env-derived — the NAMESERVER env was
// dropped). 8.8.8.8,8.8.4.4 (Google public DNS) is written into the singleton on first insert so a
// working outbound-fetch resolver is ALWAYS present out of the box; the operator edits it on the
Expand Down Expand Up @@ -92,6 +97,7 @@ export function toRuntimeSettings(doc: SettingsDoc): RuntimeSettings {
darkMode: doc.darkMode,
videoPlayer: asVideoPlayerMode(doc.videoPlayer),
dlhdPlayer: typeof doc.dlhdPlayer === 'number' ? doc.dlhdPlayer : 0, // source-wide default DaddyLive player (0 = Auto)
duloDomain: doc.duloDomain || DULO_DEFAULT_DOMAIN, // bare host; not secret — returned for the Settings UI
nameservers: doc.nameservers ?? null, // not secret — returned verbatim for the Settings UI
logLevel: typeof doc.logLevel === 'number' ? doc.logLevel : 2,
maxmindAccountId: doc.maxmindAccountId ?? null,
Expand Down Expand Up @@ -154,6 +160,15 @@ export function toExternalPatch(body: unknown): PatchResult {
}
$set.dlhdPlayer = v;
}
// duloDomain: the host dulo is currently on. Normalized (scheme/path/port/userinfo stripped, lowercased)
// and gated by the SAME validator the Test/Auto-detect endpoints use — it rejects IP literals and
// private/loopback targets, which matters because those endpoints server-side-fetch this value.
if (b.duloDomain !== undefined) {
if (typeof b.duloDomain !== 'string') return { ok: false, error: 'duloDomain (string) required' };
const parsed = normalizeDomain(b.duloDomain);
if (!parsed.ok) return { ok: false, error: `duloDomain: ${parsed.error}` };
$set.duloDomain = parsed.domain;
}
// nameservers: optional comma-separated resolver IP(s). null or '' clears it (stored null → OS resolver);
// a non-empty string must be a comma list of valid IPs (isIP), else 400 — a bad value never reaches dns.ts.
if (b.nameservers !== undefined) {
Expand Down
Loading
Loading