mirror of
https://github.com/koush/scrypted.git
synced 2026-03-07 19:42:05 +00:00
1107 lines
37 KiB
TypeScript
1107 lines
37 KiB
TypeScript
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<string>;
|
|
}
|
|
|
|
export async function readMessage(client: Readable): Promise<string[]> {
|
|
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<number>();
|
|
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<number>();
|
|
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<string>(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<string[]> {
|
|
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<number, RtspClientTcpSetupOptions>();
|
|
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<void>();
|
|
|
|
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<string[]> {
|
|
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<RtspServerResponse> {
|
|
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<boolean>) {
|
|
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<T extends RtspServer>(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,
|
|
}
|
|
}
|