import crypto, { randomBytes } from 'crypto'; import dgram from 'dgram'; import { once } from 'events'; import { BASIC } from 'http-auth-utils/dist/index'; import { parseHTTPHeadersQuotedKeyValueSet } from 'http-auth-utils/dist/utils'; import net from 'net'; import { Duplex, Readable, Writable } from 'stream'; import tls from 'tls'; import { Deferred } from './deferred'; import { closeQuiet, createBindUdp, createBindZero, listenZeroSingleClient } from './listen-cluster'; import { timeoutPromise } from './promise-utils'; import { readLength, readLine } from './read-stream'; import { MSection, parseSdp } from './sdp-utils'; import { sleep } from './sleep'; import { StreamChunk, StreamParser, StreamParserOptions } from './stream-parser'; import { URL } from 'url'; const REQUIRED_WWW_AUTHENTICATE_KEYS = ['realm', 'nonce']; type DigestWWWAuthenticateData = { realm: string; domain?: string; nonce: string; opaque?: string; stale?: 'true' | 'false'; algorithm?: 'MD5' | 'MD5-sess' | 'token'; qop?: 'auth' | 'auth-int' | string; }; export const RTSP_FRAME_MAGIC = 36; export interface Headers { [header: string]: string } export interface RtspStreamParser extends StreamParser { sdp: Promise; } export async function readMessage(client: Readable): Promise { let currentHeaders: string[] = []; while (true) { let line = await readLine(client); line = line.trim(); if (!line) return currentHeaders; currentHeaders.push(line); } } export async function readBody(client: Readable, response: Headers) { const cl = parseInt(response['content-length']); if (cl) return readLength(client, cl) } export function writeMessage(client: Writable, messageLine: string, body: Buffer, headers: Headers, console?: Console) { let message = messageLine !== undefined ? `${messageLine}\r\n` : ''; if (body) headers['Content-Length'] = body.length.toString(); for (const [key, value] of Object.entries(headers)) { message += `${key}: ${value}\r\n`; } message += '\r\n'; client.write(message); console?.log('rtsp outgoing message\n', message); console?.log(); if (body) client.write(body); } // https://yumichan.net/video-processing/video-compression/introduction-to-h264-nal-unit/ export const H264_NAL_TYPE_RESERVED0 = 0; export const H264_NAL_TYPE_RESERVED30 = 30; export const H264_NAL_TYPE_RESERVED31 = 31; export const H264_NAL_TYPE_IDR = 5; export const H264_NAL_TYPE_SEI = 6; export const H264_NAL_TYPE_SPS = 7; export const H264_NAL_TYPE_PPS = 8; // aggregate NAL Unit export const H264_NAL_TYPE_STAP_A = 24; export const H264_NAL_TYPE_STAP_B = 25; // fragmented NAL Unit (need to match against first) export const H264_NAL_TYPE_FU_A = 28; export const H264_NAL_TYPE_FU_B = 29; export const H264_NAL_TYPE_MTAP16 = 26; export const H264_NAL_TYPE_MTAP32 = 27; export function findH264NaluType(streamChunk: StreamChunk, naluType: number) { if (streamChunk.type !== 'h264') return; return findH264NaluTypeInNalu(streamChunk.chunks[streamChunk.chunks.length - 1].subarray(12), naluType); } export function findH264NaluTypeInNalu(nalu: Buffer, naluType: number) { const checkNaluType = nalu[0] & 0x1f; if (checkNaluType === H264_NAL_TYPE_STAP_A) { let pos = 1; while (pos < nalu.length) { const naluLength = nalu.readUInt16BE(pos); pos += 2; const stapaType = nalu[pos] & 0x1f; if (stapaType === naluType) return nalu.subarray(pos, pos + naluLength); pos += naluLength; } } else if (checkNaluType === H264_NAL_TYPE_FU_A) { const fuaType = nalu[1] & 0x1f; const isFuStart = !!(nalu[1] & 0x80); if (fuaType === naluType && isFuStart) return nalu.subarray(1); } else if (checkNaluType === naluType) { return nalu; } return; } export function getNaluTypes(streamChunk: StreamChunk) { if (streamChunk.type !== 'h264') return new Set(); return getNaluTypesInNalu(streamChunk.chunks[streamChunk.chunks.length - 1].subarray(12)) } export function getNaluFragmentInformation(nalu: Buffer) { const naluType = nalu[0] & 0x1f; const fua = naluType === H264_NAL_TYPE_FU_A; return { fua, fuaStart: fua && !!(nalu[1] & 0x80), fuaEnd: fua && !!(nalu[1] & 0x40), } } export function getNaluTypesInNalu(nalu: Buffer, fuaRequireStart = false, fuaRequireEnd = false) { const ret = new Set(); const naluType = nalu[0] & 0x1f; if (naluType === H264_NAL_TYPE_STAP_A) { ret.add(H264_NAL_TYPE_STAP_A); let pos = 1; while (pos < nalu.length) { const naluLength = nalu.readUInt16BE(pos); pos += 2; const stapaType = nalu[pos] & 0x1f; ret.add(stapaType); pos += naluLength; } } else if (naluType === H264_NAL_TYPE_FU_A) { ret.add(H264_NAL_TYPE_FU_A); const fuaType = nalu[1] & 0x1f; if (fuaRequireStart) { const isFuStart = !!(nalu[1] & 0x80); if (isFuStart) ret.add(fuaType); } else if (fuaRequireEnd) { const isFuEnd = !!(nalu[1] & 0x40); if (isFuEnd) ret.add(fuaType); } else { ret.add(fuaType); } } else { ret.add(naluType); } return ret; } export function createRtspParser(options?: StreamParserOptions): RtspStreamParser { let resolve: any; return { container: 'rtsp', tcpProtocol: 'rtsp://127.0.0.1/' + randomBytes(8).toString('hex'), inputArguments: [ '-rtsp_transport', 'tcp', ], outputArguments: [ '-rtsp_transport', 'tcp', ...(options?.vcodec || []), ...(options?.acodec || []), '-f', 'rtsp', ], findSyncFrame(streamChunks: StreamChunk[]) { let foundIndex: number; let nonVideo: { [codec: string]: StreamChunk, } = {}; const createSyncFrame = () => { const ret = streamChunks.slice(foundIndex); // for (const nv of Object.values(nonVideo)) { // ret.unshift(nv); // } return ret; } for (let prebufferIndex = 0; prebufferIndex < streamChunks.length; prebufferIndex++) { const streamChunk = streamChunks[prebufferIndex]; if (streamChunk.type !== 'h264') { nonVideo[streamChunk.type] = streamChunk; continue; } if (findH264NaluType(streamChunk, H264_NAL_TYPE_SPS)) foundIndex = prebufferIndex; } if (foundIndex !== undefined) return createSyncFrame(); nonVideo = {}; // some streams don't contain codec info, so find an idr frame instead. for (let prebufferIndex = 0; prebufferIndex < streamChunks.length; prebufferIndex++) { const streamChunk = streamChunks[prebufferIndex]; if (streamChunk.type !== 'h264') { nonVideo[streamChunk.type] = streamChunk; continue; } if (findH264NaluType(streamChunk, H264_NAL_TYPE_IDR)) foundIndex = prebufferIndex; } if (foundIndex !== undefined) return createSyncFrame(); // oh well! }, sdp: new Promise(r => resolve = r), async *parse(duplex, width, height) { const server = new RtspServer(duplex); await server.handleSetup(); resolve(server.sdp); for await (const { type, rtcp, header, packet } of server.handleRecord()) { yield { chunks: [header, packet], type: `${rtcp ? 'rtcp-' : ''}${type}`, width, height, } } } } } export function parseHeaders(headers: string[]): Headers { const ret: any = {}; for (const header of headers.slice(1)) { const index = header.indexOf(':'); let value = ''; if (index !== -1) value = header.substring(index + 1).trim(); const key = header.substring(0, index).toLowerCase(); ret[key] = value; } return ret; } export function getFirstAuthenticateHeader(headers: string[]): string { for (const header of headers.slice(1)) { const index = header.indexOf(':'); let value = ''; if (index !== -1) value = header.substring(index + 1).trim(); const key = header.substring(0, index).toLowerCase(); if (key === 'www-authenticate') return value; } } export function parseSemicolonDelimited(value: string) { const dict: { [key: string]: string } = {}; for (const part of value.split(';')) { const [key, value] = part.split('=', 2); dict[key] = value; } return dict; } export interface RtspStatus { line: string, code: number, version: string, reason: string, } export interface RtspServerResponse { headers: Headers; body: Buffer; status: RtspStatus; } export class RtspStatusError extends Error { constructor(public status: RtspStatus) { super(`RTSP Error: ${status.line}`); } } export class RtspBase { client: net.Socket; console?: Console; constructor() { } write(messageLine: string, headers: Headers, body?: Buffer) { writeMessage(this.client, messageLine, body, headers, this.console); } async readMessage(): Promise { const message = await readMessage(this.client); this.console?.log('rtsp incoming message\n', message.join('\n')); this.console?.log(); return message; } } const quote = (str: string): string => `"${str.replace(/"/g, '\\"')}"`; export interface RtspClientSetupOptions { type: 'tcp' | 'udp'; path?: string; onRtp: (rtspHeader: Buffer, rtp: Buffer) => void; } export interface RtspClientTcpSetupOptions extends RtspClientSetupOptions { type: 'tcp'; port: number; } export interface RtspClientUdpSetupOptions extends RtspClientSetupOptions { type: 'udp'; dgram?: dgram.Socket; } // probably only works with scrypted rtsp server. export class RtspClient extends RtspBase { cseq = 0; session: string; wwwAuthenticate: string; requestTimeout: number; needKeepAlive = false; setupOptions = new Map(); issuedTeardown = false; hasGetParameter = true; constructor(public url: string) { super(); const u = new URL(url); const port = parseInt(u.port) || 554; if (url.startsWith('rtsps')) { this.client = tls.connect({ rejectUnauthorized: false, port, host: u.hostname, }) } else { this.client = net.connect(port, u.hostname); } this.client.on('error', e => { this.console?.log('client error', e); }); } async safeTeardown() { // issue a teardown to upstream to close gracefully if (this.issuedTeardown) return; this.issuedTeardown = true; try { this.writeTeardown(); await sleep(500); } catch (e) { } finally { // will trigger after teardown returns this.client.destroy(); } } writeRequest(method: string, headers?: Headers, path?: string, body?: Buffer) { headers = headers || {}; let fullUrl = this.url; if (path) { // a=control may be a full or "relative" url. if (path.includes('rtsp://') || path.includes('rtsps://')) { fullUrl = path; } else { // strangely, relative RTSP urls do not behave like expected from an HTTP-ish server. // ffmpeg will happily suffix path segments after query strings: // SETUP rtsp://localhost:5554/cam/realmonitor?channel=1&subtype=0/trackID=0 RTSP/1.0 fullUrl += (fullUrl.endsWith('/') ? '' : '/') + path; } } const sanitized = new URL(fullUrl); sanitized.username = ''; sanitized.password = ''; const line = `${method} ${sanitized} RTSP/1.0`; const cseq = this.cseq++; headers['CSeq'] = cseq.toString(); headers['User-Agent'] = 'Scrypted'; if (this.wwwAuthenticate) headers['Authorization'] = this.createAuthorizationHeader(method, new URL(fullUrl)); if (this.session) headers['Session'] = this.session; this.write(line, headers, body); } async handleDataPayload(header: Buffer) { // todo: fix this, because calling teardown outside of the read loop causes this. if (header[0] !== RTSP_FRAME_MAGIC) throw new Error('RTSP Client received invalid frame magic. This may be a bug in your camera firmware. If this error persists, switch your RTSP Parser to FFmpeg or Scrypted (UDP): ' + header.toString()); const channel = header.readUInt8(1); const length = header.readUInt16BE(2); const data = await readLength(this.client, length); const options = this.setupOptions.get(channel); options?.onRtp?.(header, data); } async readDataPayload() { const header = await readLength(this.client, 4); return this.handleDataPayload(header); } createBadHeader(header: Buffer) { return new Error('RTSP Client received invalid frame magic. This may be a bug in your camera firmware. If this error persists, switch your RTSP Parser to FFmpeg or Scrypted (UDP): ' + header.toString()); } async readLoopLegacy() { try { while (true) { if (this.needKeepAlive) { this.needKeepAlive = false; if (this.hasGetParameter) await this.getParameter(); else await this.options(); } await this.readDataPayload(); } } catch (e) { this.client.destroy(e); throw e; } } async readLoop() { const deferred = new Deferred(); let header: Buffer; let channel: number; let length: number; const read = async () => { if (this.needKeepAlive) { this.needKeepAlive = false; if (this.hasGetParameter) this.writeGetParameter(); else this.writeOptions(); } try { while (true) { // get header if needed if (!header) { header = this.client.read(4); if (!header) return; // validate header once. if (header[0] !== RTSP_FRAME_MAGIC) { if (header.toString() !== 'RTSP') throw this.createBadHeader(header); this.client.unshift(header); header = undefined; // remove the listener to operate in pull mode. this.client.removeListener('readable', read); // do what with this? const message = await super.readMessage(); const body = await this.readBody(parseHeaders(message)); // readd the listener to operate in streaming mode. this.client.on('readable', read); continue; } channel = header.readUInt8(1); length = header.readUInt16BE(2); } const data = this.client.read(length); if (!data) return; const h = header; header = undefined; const options = this.setupOptions.get(channel); options?.onRtp?.(h, data); } } catch (e) { deferred.reject(e); this.client.destroy(); } }; read(); this.client.on('readable', read); await Promise.all([once(this.client, 'end')]); } // rtsp over tcp will actually interleave RTSP request/responses // within the RTSP data stream. The only way to tell if it's a request/response // is to see if the header + data starts with RTSP/1.0 message line. // Or RTSP, if looking at only the header bytes. Then grab the response out. async readMessage(): Promise { while (true) { const header = await readLength(this.client, 4); if (header[0] !== RTSP_FRAME_MAGIC) { if (header.toString() === 'RTSP') { this.client.unshift(header); const message = await super.readMessage(); return message; } throw this.createBadHeader(header); } await this.handleDataPayload(header); } } createAuthorizationHeader(method: string, url: URL) { if (!this.wwwAuthenticate) throw new Error('no WWW-Authenticate found'); if (this.wwwAuthenticate.includes('Basic')) { const hash = BASIC.computeHash(url); return `Basic ${hash}`; } // hikvision sends out of spec 'random' name and value parameter, // which causes the digest auth lib to fail. so, need to parse the header // manually with a relax set of authorized parameters. // https://github.com/koush/scrypted/issues/344#issuecomment-1223627956 // https://github.com/nfroidure/http-auth-utils/blob/7532d21a419ad098d1240c9e1b55855020df5d7f/src/mechanisms/digest.ts#L97 // const wwwAuth = DIGEST.parseWWWAuthenticateRest(this.wwwAuthenticate); const wwwAuth = parseHTTPHeadersQuotedKeyValueSet( this.wwwAuthenticate, // the parser will call indexOf to see if the key is authorized. monkey patch this call. // https://github.com/nfroidure/http-auth-utils/blob/17186d3eefb86535916d044c7f59a340bc765603/src/utils.ts#L43 { indexOf: () => 0, } as any, REQUIRED_WWW_AUTHENTICATE_KEYS, ) as DigestWWWAuthenticateData; const authedUrl = new URL(this.url); const username = decodeURIComponent(authedUrl.username); const password = decodeURIComponent(authedUrl.password); const strippedUrl = new URL(url.toString()); strippedUrl.username = ''; strippedUrl.password = ''; const ha1 = crypto.createHash('md5').update(`${username}:${wwwAuth.realm}:${password}`).digest('hex'); const ha2 = crypto.createHash('md5').update(`${method}:${strippedUrl}`).digest('hex'); const hash = crypto.createHash('md5').update(`${ha1}:${wwwAuth.nonce}:${ha2}`).digest('hex'); const params = { username, realm: wwwAuth.realm, nonce: wwwAuth.nonce, uri: strippedUrl.toString(), algorithm: 'MD5', response: hash, }; const paramsString = Object.entries(params).map(([key, value]) => `${key}=${value && quote(value)}`).join(', '); return `Digest ${paramsString}`; } async readBody(response: Headers) { return readBody(this.client, response); } async request(method: string, headers?: Headers, path?: string, body?: Buffer, authenticating?: boolean): Promise { this.writeRequest(method, headers, path, body); const message = this.requestTimeout ? await timeoutPromise(this.requestTimeout, this.readMessage()) : await this.readMessage(); const statusLine = message[0]; const [version, codeString, reason] = statusLine.split(' ', 3); const code = parseInt(codeString); const response = parseHeaders(message); const status = { line: statusLine, code, version, reason, }; if (code !== 200 && !response['www-authenticate']) throw new RtspStatusError(status); // it seems that the first www-authenticate header should be used, as latter ones that are // offered are not actually valid? weird issue seen on tp-link that offers both DIGEST and BASIC. const wwwAuthenticate = getFirstAuthenticateHeader(message) || response['www-authenticate'] if (wwwAuthenticate) { if (authenticating) throw new Error('auth failed'); this.wwwAuthenticate = wwwAuthenticate; return this.request(method, headers, path, body, true); } return { headers: response, body: await this.readBody(response), status, } } async options() { const headers: Headers = {}; const ret = await this.request('OPTIONS', headers); const publicHeader = ret.headers['public']; if (publicHeader) this.hasGetParameter = publicHeader.toLowerCase().includes('get_parameter'); return ret; } writeOptions() { return this.writeRequest('OPTIONS'); } async getParameter() { return this.request('GET_PARAMETER'); } writeGetParameter() { return this.writeRequest('GET_PARAMETER'); } async describe(headers?: Headers) { return this.request('DESCRIBE', { ...(headers || {}), Accept: 'application/sdp', }); } async setup(options: RtspClientTcpSetupOptions | RtspClientUdpSetupOptions, headers?: Headers) { const protocol = options.type === 'udp' ? '' : '/TCP'; const client = options.type === 'udp' ? 'client_port' : 'interleaved'; let port: number; if (options.type === 'tcp') { port = options.port; } else { if (!options.dgram) { const udp = await createBindZero(); options.dgram = udp.server; this.client.on('close', () => closeQuiet(udp.server)); } port = options.dgram.address().port; options.dgram.on('message', data => options.onRtp(undefined, data)); } headers = Object.assign({ Transport: `RTP/AVP${protocol};unicast;${client}=${port}-${port + 1}`, }, headers); const response = await this.request('SETUP', headers, options.path); let interleaved: { begin: number; end: number; }; if (response.headers.session) { const sessionDict = parseSemicolonDelimited(response.headers.session); let timeout = parseInt(sessionDict['timeout']); if (timeout) { // if a timeout is requested, need to keep the session alive with periodic refresh. // one suggestion is calling OPTIONS, but apparently GET_PARAMETER is more reliable. // https://stackoverflow.com/a/39818378 let interval = (timeout - 5) * 1000; let timer = setInterval(() => this.needKeepAlive = true, interval); this.client.once('close', () => clearInterval(timer)); } this.session = response.headers.session.split(';')[0]; } if (response.headers.transport) { const match = response.headers.transport.match(/.*?interleaved=([0-9]+)-([0-9]+)/); if (match) { const [_, begin, end] = match; if (begin && end) { interleaved = { begin: parseInt(begin), end: parseInt(end), }; } } } if (options.type === 'tcp') this.setupOptions.set(interleaved ? interleaved.begin : port, options); return Object.assign({ interleaved, options }, response); } async play(headers: Headers = {}, start = '0.000') { headers['Range'] = `npt=${start}-`; return this.request('PLAY', headers); } writePlay(start: string = '0.000') { const headers: any = { Range: `npt=${start}-`, }; return this.writeRequest('PLAY', headers); } writeRtpPayload(header: Buffer, rtp: Buffer) { this.client.write(header); return this.client.write(Buffer.from(rtp)); } send(rtp: Buffer, channel: number) { const header = Buffer.alloc(4); header.writeUInt8(RTSP_FRAME_MAGIC, 0); header.writeUInt8(channel, 1); header.writeUInt16BE(rtp.length, 2); return this.writeRtpPayload(header, rtp); } async pause() { return this.request('PAUSE'); } async teardown() { try { // todo: fix this, because calling teardown outside of the read loop causes this. return await this.request('TEARDOWN'); } finally { this.client.destroy(); } } writeTeardown() { this.writeRequest('TEARDOWN'); } } export interface RtspTrack { protocol: 'tcp' | 'udp'; destination: number; codec: string; control: string; rtp?: dgram.Socket; rtcp?: dgram.Socket; } export class RtspServer { session: string; console: Console; setupTracks: { [trackId: string]: RtspTrack; } = {}; constructor(public client: Duplex, public sdp?: string, public udp?: boolean, public checkRequest?: (method: string, url: string, headers: Headers, rawMessage: string[]) => Promise) { this.session = randomBytes(4).toString('hex'); if (sdp) sdp = sdp.trim(); if (client instanceof net.Socket) client.setNoDelay(true); } async handleSetup(methods = ['play', 'record', 'teardown']) { let currentHeaders: string[] = []; while (true) { let line = await readLine(this.client); line = line.trim(); if (!line) { const method = await this.headers(currentHeaders); if (methods.includes(method)) return method; currentHeaders = []; continue; } currentHeaders.push(line); } } async handlePlayback() { return this.handleSetup(); } async handleTeardown() { return this.handleSetup(); } async *handleRecord(): AsyncGenerator<{ type: string, rtcp: boolean, header: Buffer, packet: Buffer, }> { while (true) { const header = await readLength(this.client, 4); // can this even happen? since the RTSP request method isn't a fixed // value like the "RTSP" in the RTSP response, I don't think so? if (header[0] !== RTSP_FRAME_MAGIC) throw new Error('RTSP Server expected frame magic but received: ' + header.toString()); const length = header.readUInt16BE(2); const packet = await readLength(this.client, length); const id = header.readUInt8(1); const destination = id - (id % 2); const track = Object.values(this.setupTracks).find(track => track.destination === destination); if (!track) throw new Error('RSTP Server received unknown channel: ' + id); yield { type: track.codec, rtcp: id % 2 === 1, header, packet, } } } writeRtpPayload(header: Buffer, rtp: Buffer) { this.client.write(header); return this.client.write(Buffer.from(rtp)); } send(rtp: Buffer, channel: number) { const header = Buffer.alloc(4); header.writeUInt8(36, 0); header.writeUInt8(channel, 1); header.writeUInt16BE(rtp.length, 2); return this.writeRtpPayload(header, rtp); } sendUdp(udp: dgram.Socket, port: number, packet: Buffer) { // todo: support non local host? udp.send(packet, port, '127.0.0.1'); } sendTrack(trackId: string, packet: Buffer, rtcp: boolean) { const track = this.setupTracks[trackId]; if (!track) { this.console?.warn('RTSP Server track not found:', trackId); return true; } if (track.protocol === 'udp') { if (!this.udp) this.console?.warn('RTSP Server UDP socket not available.'); else this.sendUdp(rtcp ? track.rtcp : track.rtp, track.destination, packet); return true; } return this.send(packet, rtcp ? track.destination + 1 : track.destination); } availableOptions = ['DESCRIBE', 'OPTIONS', 'PAUSE', 'PLAY', 'SETUP', 'TEARDOWN', 'ANNOUNCE', 'RECORD', 'GET_PARAMETER']; options(url: string, requestHeaders: Headers) { const headers: Headers = {}; headers['Public'] = this.availableOptions.join(', '); this.respond(200, 'OK', requestHeaders, headers); } async get_parameter(url: string, requestHeaders: Headers) { const headers: Headers = {}; this.respond(200, 'OK', requestHeaders, headers); } describe(url: string, requestHeaders: Headers) { const headers: Headers = {}; headers['Content-Base'] = url; headers['Content-Type'] = 'application/sdp'; this.respond(200, 'OK', requestHeaders, headers, Buffer.from(this.sdp)) } setupInterleaved(msection: MSection, low: number, high: number) { this.setupTracks[msection.control] = { control: msection.control, protocol: 'tcp', destination: low, codec: msection.codec, } } resolveInterleaved?: (msection: MSection) => [number, number]; // todo: use the sdp itself to determine the audio/video track ids so // rewriting is not necessary. async setup(url: string, requestHeaders: Headers) { const headers: Headers = {}; let transport = requestHeaders['transport']; headers['Session'] = this.session; const parsedSdp = parseSdp(this.sdp); const msection = parsedSdp.msections.find(msection => url.endsWith(msection.control)); if (!msection) { this.respond(404, 'Not Found', requestHeaders, headers); return; } if (transport.includes('TCP')) { if (this.resolveInterleaved) { const [low, high] = this.resolveInterleaved(msection); this.setupInterleaved(msection, low, high); transport = `RTP/AVP/TCP;unicast;interleaved=${low}-${high}`; } else { const match = transport.match(/.*?interleaved=([0-9]+)-([0-9]+)/); if (match) { const low = parseInt(match[1]); const high = parseInt(match[2]); this.setupInterleaved(msection, low, high); } } } else { if (!this.udp) { this.respond(461, 'Unsupported Transport', requestHeaders, {}); return; } const match = transport.match(/.*?client_port=([0-9]+)-([0-9]+)/); const [_, rtp, rtcp] = match; const rtpServer = await createBindZero(); const rtcpServer = await createBindUdp(rtpServer.port + 1); this.client.on('close', () => closeQuiet(rtpServer.server)); this.client.on('close', () => closeQuiet(rtcpServer.server)); this.setupTracks[msection.control] = { control: msection.control, protocol: 'udp', destination: parseInt(rtp), codec: msection.codec, rtp: rtpServer.server, rtcp: rtcpServer.server, } transport = transport.replace('RTP/AVP/UDP', 'RTP/AVP').replace('RTP/AVP', 'RTP/AVP/UDP'); transport += `;server_port=${rtpServer.port}-${rtcpServer.port}`; } headers['Transport'] = transport; this.respond(200, 'OK', requestHeaders, headers) } play(url: string, requestHeaders: Headers) { const headers: Headers = {}; const rtpInfos = Object.values(this.setupTracks).map(track => `url=${url}/${track.control}`); // seq/rtptime was causing issues with gstreamer. commented out. const rtpInfo = rtpInfos.join(','); // + ';seq=0;rtptime=0'; headers['RTP-Info'] = rtpInfo; headers['Range'] = 'npt=now-'; headers['Session'] = this.session; this.respond(200, 'OK', requestHeaders, headers); } async announce(url: string, requestHeaders: Headers) { const contentLength = parseInt(requestHeaders['content-length']); const sdpBuffer = await readLength(this.client, contentLength); this.sdp = sdpBuffer.toString(); const headers: Headers = {}; headers['Session'] = this.session; this.respond(200, 'OK', requestHeaders, headers); } async record(url: string, requestHeaders: Headers) { const headers: Headers = {}; headers['Session'] = this.session; this.respond(200, 'OK', requestHeaders, headers); } async teardown(url: string, requestHeaders: Headers) { const headers: Headers = {}; headers['Session'] = this.session; this.respond(200, 'OK', requestHeaders, headers); this.client.destroy(); } async headers(headers: string[]) { this.console?.log('request headers', headers.join('\n')); let [method, url] = headers[0].split(' ', 2); method = method.toLowerCase(); const requestHeaders = parseHeaders(headers); if (this.checkRequest) { let allow: boolean; try { allow = await this.checkRequest(method, url, requestHeaders, headers) } catch (e) { this.console?.error('error checking request', e); } if (!allow) { this.respond(400, 'Bad Request', requestHeaders, {}); this.client.destroy(); throw new Error('check request failed'); } } const thisAny = this as any; if (!thisAny[method] || !this.availableOptions.includes(method.toUpperCase())) { this.respond(400, 'Bad Request', requestHeaders, {}); return; } await thisAny[method](url, requestHeaders); return method; } respond(code: number, message: string, requestHeaders: Headers, headers: Headers, buffer?: Buffer) { let response = `RTSP/1.0 ${code} ${message}\r\n`; if (requestHeaders['cseq']) headers['CSeq'] = requestHeaders['cseq']; if (buffer) headers['Content-Length'] = buffer.length.toString(); for (const [key, value] of Object.entries(headers)) { response += `${key}: ${value}\r\n`; } this.console?.log('response headers', response); response += '\r\n'; this.client.write(response); if (buffer) { this.client.write(buffer); this.console?.log('response body', buffer.toString()); } } destroy() { this.client.destroy(); for (const track of Object.values(this.setupTracks)) { closeQuiet(track.rtp); closeQuiet(track.rtcp); } } } export async function listenSingleRtspClient(options?: { hostname?: string, pathToken?: string, createServer?(duplex: Duplex): T, }) { const pathToken = options?.pathToken || crypto.randomBytes(8).toString('hex'); let { url, clientPromise, server } = await listenZeroSingleClient(options?.hostname); const rtspServerPath = '/' + pathToken; url = url.replace('tcp:', 'rtsp:') + rtspServerPath; const rtspServerPromise = clientPromise.then(client => { const createServer = options?.createServer || (duplex => new RtspServer(duplex)); const rtspServer = createServer(client); rtspServer.checkRequest = async (method, url, headers, message) => { rtspServer.checkRequest = undefined; const u = new URL(url); return u.pathname === rtspServerPath; }; return rtspServer as T; }); return { url, rtspServerPromise, server, } }