Add ReverseProxy

* Restructure imports/exports
    * Add a default export
    * Jump to 2.0 per semver
* Add a ReverseProxy handler
* Change the behavior of registerPath around root hostnames
This commit is contained in:
MatthiasSaihttam 2021-09-15 17:16:01 -04:00
parent e53fb7a8fe
commit 3d6fc7b211
7 changed files with 143 additions and 9 deletions

View File

@ -29,6 +29,7 @@ export default class GeminiServer {
this.DefaultHandler = options.DefaultHandler || DefaultHandler;
this.certificates = {};
//TODO: change the host option to "virtualHosts" or something
if (options.hasOwnProperty("host")) {
this.usingSNI = true;
for (const certificate of options.host) {
@ -64,9 +65,14 @@ export default class GeminiServer {
registerPath(basePath, handler) {
let hostname;
//TODO: What happens if we get an empty basepath?
if (!basePath.startsWith("/")) {
//Then we're dealing with a hostname
hostname = basePath.slice(0, basePath.indexOf("/"));
//TODO: This means that "localhost" and "localhost/" are both accepted
//It's wierd to force one over the other, but I might in the future
if (hostname.includes("/")) {
hostname = basePath.slice(0, basePath.indexOf("/"));
}
// Make sure it's a configured hostname
if (!Object.keys(this.certificates).includes(hostname)) {
@ -103,10 +109,28 @@ export default class GeminiServer {
}
handleRequest(url, socket) {
let urlPath;
//In the case of "gemini://example.com" url.pathname is "", which is a problem when we later .resolve
const urlPath = url.pathname || "/";
if (url.pathname === "") {
console.log("Got empty pathname, redirecting to \"/\"");
socket.write(`30 ${url.protocol}//${url.hostname}/${url.search}${url.hash}\r\n`);
socket.end();
return;
}else {
urlPath = path.posix.resolve(url.pathname);
//Unslash
if (urlPath !== url.pathname) {
console.log(`Requested path ${url.pathname} doesn't match normalized path ${urlPath}, redirecting`);
//Return a redirect to add a slash
//Ignore URL segments: /abc/xyz/;test isn't redirected; /abc/xyz;test/ => /abc/xyz;test
socket.write(`30 ${url.protocol}//${url.hostname}${urlPath}${url.search}${url.hash}\r\n`);
socket.end();
return;
}
}
for (const p of this.pathRegistry) {
//TODO: Wildcard hostnames
if (wildHostMatches(p.hostname, socket.servername)) {
let matches;
if (p.handler.matchesSubpaths) {
@ -119,10 +143,10 @@ export default class GeminiServer {
}
if (matches) {
Promise.resolve(p.handler.handle(url, p.basePath)).then(res => {
Promise.resolve(p.handler.handle(url, p.basePath, socket)).then(res => {
socket.write(res);
socket.end();
});
}).catch(console.error);
return;
}
}
@ -169,13 +193,13 @@ export default class GeminiServer {
console.log("Connection, looking for " + socket.servername);
socket.setEncoding("utf8");
let requestLine = ""
let requestLine = "";
socket.on("data", d => {
requestLine += d;
//If we have the end of the request
if (requestLine.includes("\r\n")) {
console.log(`< ${requestLine.trim()}\\r\\n`);
//If this is true we have data after the first \n\r, be mean and error
//If this is true, we have data after the first \r\n, be mean and error
if (requestLine.indexOf("\r\n") !== requestLine.length - 2) {
socket.write("59 Bad Request\r\n");
socket.end();

View File

@ -38,3 +38,10 @@ openssl req -new -sha256 -key private-key.pem -out csr.pem
# Self-sign, generating cert
openssl x509 -req -in csr.pem -signkey private-key.pem -out public-cert.pem
```
# TODO
It's possible to imagine a situation where astronomical theater is behind a proxy and the proxy doesn't do address translation, passing the raw gemini request to us.
In this case, our behavior is undefined.
Similarly, SNI is not supported with IP addresses.

View File

@ -1,6 +1,7 @@
import GeminiServer from "./GeminiServer.js";
import StaticHandler from "./handlers/static.js";
import DefaultHandler from "./handlers/default.js";
import ReverseProxyHandler from "./handlers/revproxy.js";
// import {, staticFileHandler} from "./main.js";
@ -70,6 +71,8 @@ server.registerPath("localhost/static", new StaticHandler("/tmp/content/" /*, {o
//Kinda cursed but whatever
// server.registerPath("localhost/thoughts", proxyHandler("gemini://localhost:8888"));
server.registerPath("/blaseball", new ReverseProxyHandler("localhost:1973"));
server.registerPath("/thoughts", new ReverseProxyHandler("thoughts.learnerpages.com/about"));
server.run();

93
handlers/revproxy.js Normal file
View File

@ -0,0 +1,93 @@
import DefaultHandler from "./default.js"
import path from "path";
import * as tls from "tls";
import { pipeline, Readable } from "stream";
/**
* async wrapper around tls.connect
*/
async function tlsConnect(...args) {
return new Promise(function (resolve, reject) {
try {
const connection = tls.connect(...args, function () {
console.log("Got connection");
resolve(connection);
});
connection.on("error", function (err) {
console.error(err);
reject(err);
});
}catch (err) {
console.error(err);
reject(err);
}
});
}
// Make Gemini request to URL
// Return the content
async function geminiReq(toServe, socket) {
const url = new URL(`gemini://${toServe}`);
const options = {
port: url.port || 1965,
host: url.hostname,
servername: url.hostname, //The servername used in SNI
};
console.log(options);
const connection = await tlsConnect(options);
// connection.write("gemini://example.com:1973/testdata\r\n");
await new Promise(function (resolve, reject) {
pipeline(
Readable.from((function* () {
const req = `${url.toString()}\r\n`;
console.log(`Sending proxy request ${req}`);
yield req;
})()),
connection,
socket,
function (err) {
if (err) {
console.log("Pipeline failed");
reject(err);
}else {
resolve();
}
}
);
});
}
export default class ReverseProxyHandler extends DefaultHandler {
constructor (proxyRoot) {
super();
this.proxyRoot = proxyRoot;
this.subpathMatches = true;
}
async handle (url, p, socket) {
console.log(`${url} matched, attempting to proxy to ${this.proxyRoot}`);
const relativePath = path.relative(p, url.pathname);
//Concat and normalize the passed URL as being relative to the base path
const toServe = path.join(this.proxyRoot, relativePath);
//If the resulting path is a parent, relative to proxyRoot, disallow that
if (path.relative(this.proxyRoot, toServe).startsWith("..")) {
return "50";
}
console.log(`Attempting to proxy ${toServe}.`);
try {
await geminiReq(toServe, socket)
return "";
}catch (err) {
console.log("Something went wrong with the proxy.");
console.error(err);
// You could debate whether this is a 43 or a 42 or a 41
return "42 Internal Proxy Error\r\n"
}
}
}

View File

@ -17,6 +17,7 @@ export default class StaticHandler extends DefaultHandler {
}
// This is the handler
// url is the URL object, with url.pathname being the path. p is the 'client-side' basepath
handle (url, p) {
const relativePath = path.relative(p, url.pathname);
//Concat and normalize the passed URL as being relative to the base path
@ -38,6 +39,7 @@ export default class StaticHandler extends DefaultHandler {
}
}catch (e) {
//If the file doesn't exist
console.log(`File doesn't exist or can't be opened.`)
return "51 Not Found\r\n";
}

View File

@ -1,4 +1,9 @@
import GeminiServer from "./GeminiServer.js"
import StaticHandler from "./handlers/static.js"
import DefaultHandler from "./handlers/default.js"
import ReverseProxyHandler from "./handlers/revproxy.js"
export { GeminiServer, StaticHandler };
export default GeminiServer;
export {
StaticHandler, DefaultHandler, ReverseProxyHandler
};

View File

@ -1,6 +1,6 @@
{
"name": "astronomical-theater",
"version": "v1.0.0",
"version": "v2.0.1",
"author": "Matthias",
"license": "CC0-1.0",
"type": "module",