import * as path from "path"; import tls from "tls"; import fs from "fs"; import DefaultHandler from "./handlers/default.js"; /** * wildHostMatches("*.example.com", "example.com") => false * wildHostMatches("*.example.com", "hello.example.com") => true * wildHostMatches("*.example.com", "test.hello.example.com") => false * wildHostMatches("*.*.example.com", "test.hello.example.com") => false * * This follows from what wildcard TLS certs you're able to get * That is *.example.com, or *.www.example.com or *.test.www.example.com * But the * must be the left most element and there must be only one */ function wildHostMatches(wildHost, hostInstance) { if (wildHost[0] === "*") { //Return if everything after the first . matches //TODO: what happens if I'm dumb and enter *ww.example.com return wildHost.toLowerCase().slice(wildHost.indexOf(".")) === hostInstance.toLowerCase().slice(hostInstance.indexOf(".")) }else { //If there's no wildcard, just return if they match return wildHost.toLowerCase() === hostInstance.toLowerCase(); } } export default class GeminiServer { constructor(options) { 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) { for (const hostname of certificate.hostnames) { console.log(`For hostname ${hostname}:`); console.log(` Using cert from ${certificate.cert}`); console.log(` Using key from ${certificate.key}`); this.certificates[hostname] = { hostname, key: fs.readFileSync(certificate.key), cert: fs.readFileSync(certificate.cert) } } } }else { console.log(`Listening on hostname ${options.hostname}`); this.usingSNI = false; this.certificates[options.hostname] = { hostname: options.hostname, key: fs.readFileSync(options.key), cert: fs.readFileSync(options.cert) } } //A list of registered paths and their handlers. The first one that matches is used this.pathRegistry = [ //{ // hostname: a hostname that we serve // path: absolute path, no trailing slash // handler: the handler function // } ]; } registerPath(basePath, handler) { let hostname; //TODO: What happens if we get an empty basepath? if (!basePath.startsWith("/")) { //Then we're dealing with a hostname //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 (basePath.includes("/")) { hostname = basePath.slice(0, basePath.indexOf("/")); } // Make sure it's a configured hostname if (!Object.keys(this.certificates).includes(hostname)) { throw new Error(`Passed non-absolute path or unrecognized hostname ${hostname} (in path ${basePath}) to registerPath.`); } basePath = basePath.slice(basePath.indexOf("/")) }else { // Hostname not specified // Check that we're not doing SNI if (this.usingSNI) { throw new Error("Virtual hosting is being used but registerPath was called without a hostname."); } hostname = Object.keys(this.certificates)[0]; } //Make sure to use POSIX paths for URLs. On Windows, path.resolve uses \ if (basePath !== path.posix.resolve(basePath)) { throw new Error(`Path ${basePath} passed to registerPath not normalized. (Remove trailing /'s and ensure there's a single root "/")`); } if (typeof handler === "string") { handler = new this.DefaultHandler(handler); } if (!(handler instanceof DefaultHandler)) { throw new Error("Handler must be an instance of DefaultHandler or a subclass."); } this.pathRegistry.push({ hostname: hostname, basePath: basePath, handler: handler }); } handleRequest(url, socket) { let urlPath; //In the case of "gemini://example.com" url.pathname is "", which is a problem when we later .resolve 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) { if (wildHostMatches(p.hostname, socket.servername)) { let matches; if (p.handler.matchesSubpaths) { console.log(`Attempting to sub-path match "${path.posix.resolve(url.pathname)}" with "${p.basePath}"`); //If the requested path is a sub path (e.g. equal to or more specific than the path in the current registry entry) matches = !path.posix.relative(p.basePath, urlPath).startsWith(".."); }else { console.log(`Attempting to match "${path.posix.resolve(url.pathname)}" with "${p.basePath}"`); matches = path.posix.resolve(url.pathname) === p.basePath; } if (matches) { Promise.resolve(p.handler.handle(url, p.basePath, socket)).then(res => { if (res) { socket.write(res); } socket.end(); }).catch(console.error); return; } } } // If we haven't returned yet, 404 console.log(`No matches found for host ${socket.servername} and path ${urlPath}`) socket.write("51 Not Found\r\n"); socket.end(); } /** * Start running the server */ run() { const options = { //SNI is mandatory for Gemini clients, so I'm not going to bother providing a fallback key/cert SNICallback: (servername, callback) => { console.log(); console.log(`Negotiating SNI for ${servername}`); //Find the cert that matches this servername //Support wildcard hostnames const hostnamePattern = Object.keys(this.certificates).find(cert => wildHostMatches(cert, servername)); console.log(`Found hostname pattern ${hostnamePattern}`); const cert = this.certificates[hostnamePattern]; if (!cert) { /* * The first param to callback is supposed to be an error * So I could do callback("No hostname found") * Empirically, that closes the socket leaving most Gemini browsers confused (showing the previous page) * Passing null means node falls back to the default (which also isn't provided, see above) * And so tries to connect with no certificate * This obviously fails. But the browser I tested in then shows >TLS/SSL handshake failed */ console.log(`No matching hostname found. Aborting handshake.`); callback(null, null); }else { callback(null, tls.createSecureContext(cert)); } } }; const server = tls.createServer(options); server.on("secureConnection", socket => { console.log("Connection, looking for " + socket.servername); socket.setEncoding("utf8"); let requestLine = ""; socket.on("data", d => { // console.log("got data\n" + d.split("").map(c => c.charCodeAt().toString(16)).join(" ")); 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 \r\n, be mean and error if (requestLine.indexOf("\r\n") !== requestLine.length - 2) { socket.write("59 Bad Request\r\n"); socket.end(); }else { //TODO: What if the URL doesn't have a protocol or a domain at all? //Make sure that the SNI negotiated name matches the hostname in the request const url = new URL(requestLine.slice(0, -2)); //url.hostname is the servername/domain name, url.host could also have a port //TODO: check that the port matches the port that we're running on if (url.hostname !== socket.servername) { socket.write("40 Negotiated SNI host doesn't match requested URL\r\n"); socket.end(); }else { //Responding this.handleRequest(url, socket); } } } }); // This can be just ECONNRESET socket.on("error", console.error); }); server.listen(1965); } }