import z from 'zod'; import { StreamProxyConfig } from '../db/schemas.js'; import { Cache, createLogger, maskSensitiveInfo, Env, constants, } from '../utils/index.js'; import { config as appConfig } from '../config/index.js'; const logger = createLogger('proxy'); const cache = Cache.getInstance('publicIp'); export interface ProxyStream { url: string; filename?: string; type?: 'nzb' | 'stream'; headers?: { request?: Record; response?: Record; }; } type ValidatedStreamProxyConfig = StreamProxyConfig & { id: 'mediaflow' | 'stremthru' | 'builtin'; url: string; credentials: string; }; export abstract class BaseProxy { protected readonly config: ValidatedStreamProxyConfig; private readonly PRIVATE_CIDR = /^(10\.|127\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/; constructor(config: StreamProxyConfig) { if (config.id === constants.BUILTIN_SERVICE) { config.url = appConfig.bootstrap.baseUrl; config.publicUrl = undefined; } if (!config.id || !config.credentials || !config.url) { throw new Error('Proxy configuration is missing'); } this.config = { enabled: config.enabled ?? false, id: config.id, url: config.url, publicUrl: config.publicUrl, credentials: config.credentials, publicIp: config.publicIp, proxiedAddons: config.proxiedAddons, proxiedServices: config.proxiedServices, }; } public getConfig(): StreamProxyConfig { return this.config; } protected abstract generateProxyUrl(endpoint: string): URL; protected abstract getPublicIpEndpoint(): string; protected abstract getPublicIpFromResponse(data: any): string | null; protected abstract generateStreamUrls( streams: ProxyStream[], encrypt?: boolean ): Promise; public async getPublicIp(): Promise { if (!this.config.url) { logger.error('proxy url is missing'); throw new Error('Proxy URL is missing'); } if (this.config.publicIp) { return this.config.publicIp; } const proxyUrl = new URL(this.config.url.replace(/\/$/, '')); if (this.PRIVATE_CIDR.test(proxyUrl.hostname)) { logger.warn('proxy url is a private ip, skipping public ip lookup'); return null; } const cacheKey = `${this.config.id}:${this.config.url}:${this.config.credentials}`; const cachedPublicIp = cache ? await cache.get(cacheKey) : null; if (cachedPublicIp) { logger.debug( { proxy: this.config.id }, 'returning cached proxy public ip' ); return cachedPublicIp; } const ipUrl = this.generateProxyUrl(this.getPublicIpEndpoint()); logger.debug( { proxy: this.config.id, endpoint: `${ipUrl.protocol}//${maskSensitiveInfo(ipUrl.hostname)}${ipUrl.pathname}`, }, 'fetching proxy public ip' ); const response = await fetch(ipUrl.toString(), { method: 'GET', headers: this.getHeaders(), signal: AbortSignal.timeout(30000), // 30 second timeout }); if (!response.ok) { throw new Error(`${response.status}: ${response.statusText}`); } const data = await response.json(); const publicIp = this.getPublicIpFromResponse(data); const { error, success } = z .union([z.ipv4(), z.ipv6()]) .safeParse(publicIp); if (error || !success) { logger.error( { proxy: this.config.id, ip: publicIp }, 'proxy returned invalid ip' ); throw new Error(`Proxy did not respond with a valid public IP`); } if (publicIp && cache) { await cache.set(cacheKey, publicIp, appConfig.proxy.ip.cacheTtl); } else { logger.error( { proxy: this.config.id }, 'proxy did not return a public ip' ); throw new Error('Proxy did not respond with a public IP'); } return publicIp; } protected abstract getHeaders(): Record; public async generateUrls( streams: ProxyStream[], encrypt?: boolean ): Promise { if (!streams.length) { return []; } if (!this.config) { throw new Error('Proxy configuration is missing'); } try { let urls = await this.generateStreamUrls(streams, encrypt); const publicUrl = this.config.publicUrl; if (publicUrl && urls) { const publicUrlObj = new URL(publicUrl); const publicBasePath = publicUrlObj.pathname.replace(/\/+$/, ''); // remove trailing slash urls = urls.map((url) => { const urlObj = new URL(url); // Set protocol, hostname, and port from publicUrl urlObj.protocol = publicUrlObj.protocol; urlObj.hostname = publicUrlObj.hostname; urlObj.port = publicUrlObj.port; // Adjust pathname: join publicUrl's base path with the original path, avoiding duplicate slashes const origPath = urlObj.pathname.replace(/^\/+/, ''); // remove leading slash urlObj.pathname = publicBasePath ? `${publicBasePath}/${origPath}`.replace(/\/{2,}/g, '/') : `/${origPath}`; return urlObj.toString(); }); } return urls; } catch (error) { logger.error( { proxy: this.config.id, err: error instanceof Error ? error.message : String(error), }, 'failed to generate proxy urls' ); return { error: error instanceof Error ? error.message : String(error) }; } } }