astronomical-theater/GeminiServer.js

232 lines
10 KiB
JavaScript

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);
}
}