diff --git a/server/client.ts b/server/client.ts new file mode 100644 index 0000000..ae4372c --- /dev/null +++ b/server/client.ts @@ -0,0 +1,187 @@ + +import { + ClientRequest, + Agent as HTTPAgent, + IncomingMessage, + RequestOptions, + STATUS_CODES, + request as httpRequest, +} from "http"; + +import EventEmitter from "events"; + +import { + C2SRequestTypes, + HTTPRequestPayload, + HTTPResponsePayload, + ProtoBareHeaders, + S2CRequestTypes, +} from "../protocol/index.js"; +import { BareError, bareFetch, options } from "./http.js"; +export class Client { + send: (msg: Buffer) => void; + events: EventEmitter; + + constructor(send) { + this.send = send; + this.events = new EventEmitter(); + } + + static parseMsgInit( + msg: Buffer + ): { cursor: number; seq: number; op: number } | undefined { + try { + let cursor = 0; + const seq = msg.readUint16BE(cursor); + cursor += 2; + const op = msg.readUint8(cursor); + cursor += 1; + return { cursor, seq, op }; + } catch (e) { + if (e instanceof RangeError) { + // malformed message + return; + } + throw e; + } + } + + static parseHttpReqPayload( + payloadRaw: Buffer + ): HTTPRequestPayload | undefined { + let payload; + try { + payload = JSON.parse(payloadRaw.toString()); + } catch (e) { + if (e instanceof SyntaxError) { + return; + } + throw e; + } + console.log({ payload }); + return payload; + } + + static bareErrorToResponse(e: BareError): { + payload: HTTPResponsePayload; + body: Buffer; + } { + return { + payload: { + status: e.status, + statusText: STATUS_CODES[e.status] || "", + headers: {}, + }, + body: Buffer.from(JSON.stringify(e.body)), + }; + } + + async handleHTTPRequest(payload: HTTPRequestPayload): Promise<{ + payload: HTTPResponsePayload; + body: Buffer; + }> { + const abort = new AbortController(); + const onClose = () => { + abort.abort(); + this.events.off("close", onClose); + }; + this.events.on("close", onClose); + + let resp: IncomingMessage; + try { + resp = await bareFetch( + payload, + abort.signal, + new URL(payload.remote), + options + ); + } catch (e) { + if (e instanceof BareError) { + return Client.bareErrorToResponse(e); + } + this.events.off("close", onClose); + throw e; + } + + this.events.off("close", onClose); + const buffers: any[] = []; + + // node.js readable streams implement the async iterator protocol + for await (const data of resp) { + buffers.push(data); + } + const body = Buffer.concat(buffers); + + return { + payload: { + status: resp.statusCode || 500, + statusText: resp.statusMessage || "", + headers: Object.fromEntries( + Object.entries(resp.headersDistinct).filter(([k, v]) => Boolean(v)) + ) as ProtoBareHeaders, + }, + body, + }; + } + + sendHTTPResponse(seq: number, payload: HTTPResponsePayload, body: Buffer) { + const payloadBuffer = Buffer.from(JSON.stringify(payload)); + const buf = Buffer.alloc(2 + 1 + 4 + payloadBuffer.length + body.length); + let cursor = 0; + cursor = buf.writeUInt16BE(seq, cursor); + cursor = buf.writeUInt8(S2CRequestTypes.HTTPResponse, cursor); + cursor = buf.writeUInt32BE(payloadBuffer.length, cursor); + cursor += payloadBuffer.copy(buf, cursor); + body.copy(buf, cursor); + this.send(buf); + } + + async onMsg(msg: Buffer) { + const init = Client.parseMsgInit(msg); + if (!init) return; + const { cursor, seq, op } = init; + switch (op) { + case C2SRequestTypes.HTTPRequest: + let resp; + const reqPayload = Client.parseHttpReqPayload(msg.subarray(cursor)); + if (!reqPayload) return; + try { + resp = await this.handleHTTPRequest(reqPayload); + } catch (e) { + if (options.logErrors) console.error(e); + + let bareError; + if (e instanceof BareError) { + bareError = e; + } else if (e instanceof Error) { + bareError = new BareError(500, { + code: "UNKNOWN", + id: `error.${e.name}`, + message: e.message, + stack: e.stack, + }); + } else { + bareError = new BareError(500, { + code: "UNKNOWN", + id: "error.Exception", + message: "Error: " + e, + stack: new Error(e).stack, + }); + } + + resp = Client.bareErrorToResponse(bareError); + } + + const { payload, body } = resp; + this.sendHTTPResponse(seq, payload, body); + break; + default: + // not implemented + break; + } + } + + onClose() { + this.events.emit("close"); + } +} \ No newline at end of file diff --git a/server/http.ts b/server/http.ts new file mode 100644 index 0000000..3f6d5bc --- /dev/null +++ b/server/http.ts @@ -0,0 +1,195 @@ +import { HTTPRequestPayload } from "../protocol/index.js"; +import { LookupAddress, LookupAllOptions, lookup } from "dns"; +import { + ClientRequest, + Agent as HTTPAgent, + IncomingMessage, + RequestOptions, + STATUS_CODES, + request as httpRequest, +} from "http"; +import { Agent as HTTPSAgent, request as httpsRequest } from "https"; +import fuck from "ipaddr.js"; +const { isValid, parse } = fuck; +import { Readable } from "stream"; + + +export interface BareErrorBody { + code: string; + id: string; + message?: string; + stack?: string; +} + +export class BareError extends Error { + status: number; + body: BareErrorBody; + constructor(status: number, body: BareErrorBody) { + super(body.message || body.code); + this.status = status; + this.body = body; + } +} +export const options: BareServerOptions = { + logErrors: true, + filterRemote: (url) => { + // if the remote is an IP then it didn't go through the init.lookup hook + // isValid determines if this is so + if (isValid(url.hostname) && parse(url.hostname).range() !== "unicast") + throw new RangeError("Forbidden IP"); + }, + lookup: (hostname, options, callback) => + lookup(hostname, options, (err, address, family) => { + if ( + address && + toAddressArray(address, family).some( + ({ address }) => parse(address).range() !== "unicast" + ) + ) + callback(new RangeError("Forbidden IP"), [], -1); + else callback(err, address, family); + }), + httpAgent: new HTTPAgent({ + keepAlive: true, + }), + httpsAgent: new HTTPSAgent({ + keepAlive: true, + }), + database: new Map(), +}; + +export interface BareServerOptions { + logErrors: boolean; + /** + * Callback for filtering the remote URL. + * @returns Nothing + * @throws An error if the remote is bad. + */ + filterRemote?: (remote: Readonly) => Promise | void; + /** + * DNS lookup + * May not get called when remote.host is an IP + * Use in combination with filterRemote to block IPs + */ + lookup: ( + hostname: string, + options: LookupAllOptions, + callback: ( + err: NodeJS.ErrnoException | null, + addresses: LookupAddress[], + family: number + ) => void + ) => void; + localAddress?: string; + family?: number; + httpAgent: HTTPAgent; + httpsAgent: HTTPSAgent; + database: Map; +} + +export interface Address { + address: string; + family: number; +} + +/** + * Converts the address and family of a DNS lookup callback into an array if it wasn't already + */ +export function toAddressArray(address: string | Address[], family?: number) { + if (typeof address === "string") + return [ + { + address, + family, + }, + ] as Address[]; + else return address; +} + + +function outgoingError(error: T): T | BareError { + if (error instanceof Error) { + switch ((error).code) { + case "ENOTFOUND": + return new BareError(500, { + code: "HOST_NOT_FOUND", + id: "request", + message: "The specified host could not be resolved.", + }); + case "ECONNREFUSED": + return new BareError(500, { + code: "CONNECTION_REFUSED", + id: "response", + message: "The remote rejected the request.", + }); + case "ECONNRESET": + return new BareError(500, { + code: "CONNECTION_RESET", + id: "response", + message: "The request was forcibly closed.", + }); + case "ETIMEOUT": + return new BareError(500, { + code: "CONNECTION_TIMEOUT", + id: "response", + message: "The response timed out.", + }); + } + } + + return error; +} + +export async function bareFetch( + request: HTTPRequestPayload, + signal: AbortSignal, + remote: URL, + options: BareServerOptions +): Promise { + if (options.filterRemote) await options.filterRemote(remote); + + const req: RequestOptions = { + method: request.method, + headers: request.requestHeaders, + setHost: false, + signal, + localAddress: options.localAddress, + family: options.family, + lookup: options.lookup, + }; + + let outgoing: ClientRequest; + + // NodeJS will convert the URL into HTTP options automatically + // see https://github.com/nodejs/node/blob/e30e71665cab94118833cc536a43750703b19633/lib/internal/url.js#L1277 + + if (remote.protocol === "https:") + outgoing = httpsRequest(remote, { + ...req, + agent: options.httpsAgent, + }); + else if (remote.protocol === "http:") + outgoing = httpRequest(remote, { + ...req, + agent: options.httpAgent, + }); + else throw new RangeError(`Unsupported protocol: '${remote.protocol}'`); + + if (request.body) Readable.from([request.body]).pipe(outgoing); + else outgoing.end(); + + return await new Promise((resolve, reject) => { + outgoing.on("response", (response: IncomingMessage) => { + resolve(response); + }); + + outgoing.on("upgrade", (req, socket) => { + reject("Remote did not send a response"); + socket.destroy(); + }); + + outgoing.on("error", (error: Error) => { + reject(outgoingError(error)); + }); + }); +} \ No newline at end of file diff --git a/server/main.ts b/server/main.ts index 686dca5..27d9ab1 100644 --- a/server/main.ts +++ b/server/main.ts @@ -1,28 +1,9 @@ -import { LookupAddress, LookupAllOptions, lookup } from "dns"; import dotenv from "dotenv"; -import EventEmitter from "events"; import express from "express"; import expressWs from "express-ws"; -import { - ClientRequest, - Agent as HTTPAgent, - IncomingMessage, - RequestOptions, - STATUS_CODES, - request as httpRequest, -} from "http"; -import { Agent as HTTPSAgent, request as httpsRequest } from "https"; -import fuck from "ipaddr.js"; -const { isValid, parse } = fuck; -import { Readable } from "stream"; + import * as wrtc from "wrtc"; -import { - C2SRequestTypes, - HTTPRequestPayload, - HTTPResponsePayload, - ProtoBareHeaders, - S2CRequestTypes, -} from "../protocol/index.js"; +import { Client } from "./client.js"; const configuration = { iceServers: [ @@ -115,353 +96,8 @@ app.post("/connect", (req, res) => { } }); -export interface BareErrorBody { - code: string; - id: string; - message?: string; - stack?: string; -} -export class BareError extends Error { - status: number; - body: BareErrorBody; - constructor(status: number, body: BareErrorBody) { - super(body.message || body.code); - this.status = status; - this.body = body; - } -} -interface BareServerOptions { - logErrors: boolean; - /** - * Callback for filtering the remote URL. - * @returns Nothing - * @throws An error if the remote is bad. - */ - filterRemote?: (remote: Readonly) => Promise | void; - /** - * DNS lookup - * May not get called when remote.host is an IP - * Use in combination with filterRemote to block IPs - */ - lookup: ( - hostname: string, - options: LookupAllOptions, - callback: ( - err: NodeJS.ErrnoException | null, - addresses: LookupAddress[], - family: number - ) => void - ) => void; - localAddress?: string; - family?: number; - httpAgent: HTTPAgent; - httpsAgent: HTTPSAgent; - database: Map; -} - -export interface Address { - address: string; - family: number; -} - -/** - * Converts the address and family of a DNS lookup callback into an array if it wasn't already - */ -export function toAddressArray(address: string | Address[], family?: number) { - if (typeof address === "string") - return [ - { - address, - family, - }, - ] as Address[]; - else return address; -} - -const options: BareServerOptions = { - logErrors: true, - filterRemote: (url) => { - // if the remote is an IP then it didn't go through the init.lookup hook - // isValid determines if this is so - if (isValid(url.hostname) && parse(url.hostname).range() !== "unicast") - throw new RangeError("Forbidden IP"); - }, - lookup: (hostname, options, callback) => - lookup(hostname, options, (err, address, family) => { - if ( - address && - toAddressArray(address, family).some( - ({ address }) => parse(address).range() !== "unicast" - ) - ) - callback(new RangeError("Forbidden IP"), [], -1); - else callback(err, address, family); - }), - httpAgent: new HTTPAgent({ - keepAlive: true, - }), - httpsAgent: new HTTPSAgent({ - keepAlive: true, - }), - database: new Map(), -}; - -function outgoingError(error: T): T | BareError { - if (error instanceof Error) { - switch ((error).code) { - case "ENOTFOUND": - return new BareError(500, { - code: "HOST_NOT_FOUND", - id: "request", - message: "The specified host could not be resolved.", - }); - case "ECONNREFUSED": - return new BareError(500, { - code: "CONNECTION_REFUSED", - id: "response", - message: "The remote rejected the request.", - }); - case "ECONNRESET": - return new BareError(500, { - code: "CONNECTION_RESET", - id: "response", - message: "The request was forcibly closed.", - }); - case "ETIMEOUT": - return new BareError(500, { - code: "CONNECTION_TIMEOUT", - id: "response", - message: "The response timed out.", - }); - } - } - - return error; -} - -export async function bareFetch( - request: HTTPRequestPayload, - signal: AbortSignal, - remote: URL, - options: BareServerOptions -): Promise { - if (options.filterRemote) await options.filterRemote(remote); - - const req: RequestOptions = { - method: request.method, - headers: request.requestHeaders, - setHost: false, - signal, - localAddress: options.localAddress, - family: options.family, - lookup: options.lookup, - }; - - let outgoing: ClientRequest; - - // NodeJS will convert the URL into HTTP options automatically - // see https://github.com/nodejs/node/blob/e30e71665cab94118833cc536a43750703b19633/lib/internal/url.js#L1277 - - if (remote.protocol === "https:") - outgoing = httpsRequest(remote, { - ...req, - agent: options.httpsAgent, - }); - else if (remote.protocol === "http:") - outgoing = httpRequest(remote, { - ...req, - agent: options.httpAgent, - }); - else throw new RangeError(`Unsupported protocol: '${remote.protocol}'`); - - if (request.body) Readable.from([request.body]).pipe(outgoing); - else outgoing.end(); - - return await new Promise((resolve, reject) => { - outgoing.on("response", (response: IncomingMessage) => { - resolve(response); - }); - - outgoing.on("upgrade", (req, socket) => { - reject("Remote did not send a response"); - socket.destroy(); - }); - - outgoing.on("error", (error: Error) => { - reject(outgoingError(error)); - }); - }); -} - -class Client { - send: (msg: Buffer) => void; - events: EventEmitter; - - constructor(send) { - this.send = send; - this.events = new EventEmitter(); - } - - static parseMsgInit( - msg: Buffer - ): { cursor: number; seq: number; op: number } | undefined { - try { - let cursor = 0; - const seq = msg.readUint16BE(cursor); - cursor += 2; - const op = msg.readUint8(cursor); - cursor += 1; - return { cursor, seq, op }; - } catch (e) { - if (e instanceof RangeError) { - // malformed message - return; - } - throw e; - } - } - - static parseHttpReqPayload( - payloadRaw: Buffer - ): HTTPRequestPayload | undefined { - let payload; - try { - payload = JSON.parse(payloadRaw.toString()); - } catch (e) { - if (e instanceof SyntaxError) { - return; - } - throw e; - } - console.log({ payload }); - return payload; - } - - static bareErrorToResponse(e: BareError): { - payload: HTTPResponsePayload; - body: Buffer; - } { - return { - payload: { - status: e.status, - statusText: STATUS_CODES[e.status] || "", - headers: {}, - }, - body: Buffer.from(JSON.stringify(e.body)), - }; - } - - async handleHTTPRequest(payload: HTTPRequestPayload): Promise<{ - payload: HTTPResponsePayload; - body: Buffer; - }> { - const abort = new AbortController(); - const onClose = () => { - abort.abort(); - this.events.off("close", onClose); - }; - this.events.on("close", onClose); - - let resp: IncomingMessage; - try { - resp = await bareFetch( - payload, - abort.signal, - new URL(payload.remote), - options - ); - } catch (e) { - if (e instanceof BareError) { - return Client.bareErrorToResponse(e); - } - this.events.off("close", onClose); - throw e; - } - - this.events.off("close", onClose); - const buffers: any[] = []; - - // node.js readable streams implement the async iterator protocol - for await (const data of resp) { - buffers.push(data); - } - const body = Buffer.concat(buffers); - - return { - payload: { - status: resp.statusCode || 500, - statusText: resp.statusMessage || "", - headers: Object.fromEntries( - Object.entries(resp.headersDistinct).filter(([k, v]) => Boolean(v)) - ) as ProtoBareHeaders, - }, - body, - }; - } - - sendHTTPResponse(seq: number, payload: HTTPResponsePayload, body: Buffer) { - const payloadBuffer = Buffer.from(JSON.stringify(payload)); - const buf = Buffer.alloc(2 + 1 + 4 + payloadBuffer.length + body.length); - let cursor = 0; - cursor = buf.writeUInt16BE(seq, cursor); - cursor = buf.writeUInt8(S2CRequestTypes.HTTPResponse, cursor); - cursor = buf.writeUInt32BE(payloadBuffer.length, cursor); - cursor += payloadBuffer.copy(buf, cursor); - body.copy(buf, cursor); - this.send(buf); - } - - async onMsg(msg: Buffer) { - const init = Client.parseMsgInit(msg); - if (!init) return; - const { cursor, seq, op } = init; - switch (op) { - case C2SRequestTypes.HTTPRequest: - let resp; - const reqPayload = Client.parseHttpReqPayload(msg.subarray(cursor)); - if (!reqPayload) return; - try { - resp = await this.handleHTTPRequest(reqPayload); - } catch (e) { - if (options.logErrors) console.error(e); - - let bareError; - if (e instanceof BareError) { - bareError = e; - } else if (e instanceof Error) { - bareError = new BareError(500, { - code: "UNKNOWN", - id: `error.${e.name}`, - message: e.message, - stack: e.stack, - }); - } else { - bareError = new BareError(500, { - code: "UNKNOWN", - id: "error.Exception", - message: "Error: " + e, - stack: new Error(e).stack, - }); - } - - resp = Client.bareErrorToResponse(bareError); - } - - const { payload, body } = resp; - this.sendHTTPResponse(seq, payload, body); - break; - default: - // not implemented - break; - } - } - - onClose() { - this.events.emit("close"); - } -} app.ws("/dev-ws", (ws, req) => { console.log("ws connect");