diff --git a/GeminiServer.js b/GeminiServer.js index b9658da..3ae88cf 100644 --- a/GeminiServer.js +++ b/GeminiServer.js @@ -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(); diff --git a/README.md b/README.md index 3acfd71..da6e0ed 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/example.js b/example.js index 532215b..4b88371 100644 --- a/example.js +++ b/example.js @@ -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(); diff --git a/handlers/revproxy.js b/handlers/revproxy.js new file mode 100644 index 0000000..cb0e769 --- /dev/null +++ b/handlers/revproxy.js @@ -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" + } + } +} diff --git a/handlers/static.js b/handlers/static.js index 8feaeee..226f3a5 100644 --- a/handlers/static.js +++ b/handlers/static.js @@ -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"; } diff --git a/main.js b/main.js index 942640a..05f3f8b 100644 --- a/main.js +++ b/main.js @@ -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 +}; diff --git a/package.json b/package.json index 85898ac..3c0e245 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "astronomical-theater", - "version": "v1.0.0", + "version": "v2.0.1", "author": "Matthias", "license": "CC0-1.0", "type": "module",