adrift/server/http.ts
2023-08-12 11:55:37 -04:00

195 lines
No EOL
5.6 KiB
TypeScript

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<string, string>(),
};
export interface BareServerOptions {
logErrors: boolean;
/**
* Callback for filtering the remote URL.
* @returns Nothing
* @throws An error if the remote is bad.
*/
filterRemote?: (remote: Readonly<URL>) => Promise<void> | 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<string, string>;
}
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<T>(error: T): T | BareError {
if (error instanceof Error) {
switch ((<Error & { code?: string }>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<IncomingMessage> {
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));
});
});
}