Commit f66b5cdc authored by Sebastiaan Deckers's avatar Sebastiaan Deckers 馃悜

feat: Gopher over TLS server

parent 0acd0312
......@@ -199,6 +199,28 @@ Default: `10000`
The number of milliseconds to keep idle Gopher sessions active. Defaults to 10 seconds. The HTTP connection (aka session) is not closed, only the TCP/IP socket to the Gopher server and its corresponding HTTP/2 streams with the HTTP user agent.
## `gopher`
Default: `false`
If set to an object, enables serving of static files via the Gopher-over-TLS protocol. No processing of the files is done. It is up to the publisher to ensure the file are Gopher-compliant. Notably, text documents and menus must be "terminated by a period on a line by itself." And specifically for text files: "Lines beginning with periods must be prepended with an extra period to ensure that the transmission is not terminated early."
### `gopher.port`
Default: `70`
Port to listen for Gopher connections. Negotiates between clients using either plaintext Gopher protocol or Gopher-over-TLS (GoT).
### `gopher.lookup`
Default: `'$root/sites/$domain/public/_gopher/$selector'`
Expression defining the location of files to serve. If the selector resolves to a directory, the `gophermap` index file is served, if it exists. If no file matches the selector an error message is returned.
### `gopher.root`
Default: `'./'` (current working directory)
## `log`
Settings for the [Pino](https://getpino.io)-based logger.
......
......@@ -13,6 +13,7 @@ module.exports.defaultOptions = {
},
doh: false,
goh: false,
gopher: false,
placeholder: {
hostNotFound: ''
},
......
const { GopherServer } = require('goth')
const { resolve } = require('path')
const { resolve, join } = require('path')
const { createReadStream } = require('fs')
const { stringTemplate } = require('./helpers/stringTemplate')
const eventToPromise = require('event-to-promise')
const GOPHER_PORT = 70
function once (emitter, events) {
const listeners = {}
for (const [name, listener] of Object.entries(events)) {
const wrapper = function () {
for (const [name, wrapper] of Object.entries(listeners)) {
emitter.off(name, wrapper)
}
listener.apply(this, arguments)
}
emitter.once(name, wrapper)
}
}
class Gopher {
constructor ({
lookup = '$root/sites/$domain/public/_gopher/$selector',
port = GOPHER_PORT,
root = process.cwd(),
lookup = '$root/$domain/public/_gopher/$selector'
SNICallback
}) {
this.root = root
this.lookup = lookup
this.server = new GopherServer()
this.server.on('connection', this.onConnection)
this.port = port
this.root = root
this.server = new GopherServer({ SNICallback })
this.server.on('gopherConnection', this.onConnection.bind(this))
this.server.on('connection', (socket) => {
socket.setTimeout(10000, () => socket.end())
})
}
async start () {
return new Promise((resolve, reject) => {
once(this.server, { listening: resolve, error: reject })
this.server.listen(GOPHER_PORT)
})
this.server.listen(this.port)
await eventToPromise(this.server, 'listening')
}
async stop () {
return new Promise((resolve, reject) => {
this.server.close((error) => error ? reject(error) : resolve())
})
this.server.close()
await eventToPromise(this.server, 'close')
}
async onConnection (socket, type) {
console.log(`Connection: ${socket.remoteAddress} @ ${socket.servername}`)
socket.on('error', (error) => console.error(error.message))
if (type !== 'tls') {
socket.end('3Secure connection required\t\terror.host\t1\r\n.\r\n')
socket.destroy()
return
}
if (!socket.servername) {
socket.end('3Server name indicator required\t\terror.host\t1\r\n.\r\n')
return
}
const selectorTimeout = setTimeout(() => socket.end(), 10000)
socket.on('error', (error) => console.error(error.message))
socket.on('close', () => socket.removeAllListeners())
socket.once('data', async (chunk) => {
clearTimeout(selectorTimeout)
const selector = resolve('/', chunk.toString().split(/[\t\r\n]/, 1)[0])
const parameters = { domain: socket.servername, root: this.root }
let file
for (const suffix of ['', '/gophermap']) {
parameters.selector = selector + suffix
const filepath = stringTemplate(this.lookup, parameters)
file = createReadStream(filepath)
try {
await eventToPromise(file, 'ready')
} catch (error) { continue }
file.on('error', (error) => console.error(error.messsage))
file.pipe(socket)
break
}
if (!file) {
socket.end('4File not found\t\terror.host\t1\r\n.\r\n')
const domain = socket.servername
const parameters = { selector, domain, root: this.root }
const filepath = stringTemplate(this.lookup, parameters)
try {
await serveFile(socket, filepath)
} catch (error) {
if (error.code === 'EISDIR') {
try {
await serveFile(socket, join(filepath, 'gophermap'))
} catch (error) {
socket.end('3File not found\t\terror.host\t1\r\n.\r\n')
}
} else if (error.code === 'ENOENT') {
socket.end('3File not found\t\terror.host\t1\r\n.\r\n')
} else {
socket.end()
}
}
})
}
}
async function serveFile (socket, filepath) {
const file = createReadStream(filepath)
await eventToPromise(file, 'open')
file.pipe(socket)
await eventToPromise(file, 'close')
socket.end()
}
module.exports.Gopher = Gopher
const { resolve } = require('path')
const { promisify } = require('util')
const { readFile } = require('fs')
const ocsp = require('ocsp')
const { createSecureContext } = require('tls')
const isDomainName = require('is-domain-name')
const isIp = require('is-ip')
const { stringTemplate } = require('./stringTemplate')
const ecdhCurve = 'P-384:P-256'
async function read (filepath) {
const resolved = resolve(process.cwd(), filepath)
return promisify(readFile)(resolved)
}
let fallbackContext
async function fallbackSecureContext (options) {
if (!fallbackContext) {
const [key, cert, ...ca] = await Promise.all([
read(options.https.key),
read(options.https.cert),
...options.https.ca.filter(String).map(read)
])
fallbackContext = createSecureContext({ ecdhCurve, key, cert, ca })
}
return fallbackContext
}
const acmeCache = new Map()
module.exports.SNICallback =
function SNICallback (options) {
return async function SNICallback (servername, callback) {
if (acmeCache.has(servername)) {
const { key, cert } = acmeCache.get(servername)
const contextOptions = { ecdhCurve, key, cert }
return callback(null, createSecureContext(contextOptions))
}
if (!(isDomainName(servername) || isIp(servername))) {
return callback(null, await fallbackSecureContext(options))
}
try {
const parameters = { store: options.acme.store, domain: servername }
const keyPath = stringTemplate(options.acme.key, parameters)
const certPath = stringTemplate(options.acme.cert, parameters)
const [key, cert] = await Promise.all([
read(resolve(options.acme.store, keyPath)),
read(resolve(options.acme.store, certPath))
])
const contextOptions = { ecdhCurve, key, cert }
const context = createSecureContext(contextOptions)
acmeCache.set(servername, { key, cert })
return callback(null, context)
} catch (error) {
if (error.code !== 'ENOENT') {
console.error(error)
}
return callback(null, await fallbackSecureContext(options))
}
}
}
process.on('message', ({ type, domain }) => {
if (type === 'certificate-issue' ||
type === 'certificate-revoke' ||
type === 'site-delete'
) {
if (acmeCache.has(domain)) {
acmeCache.delete(domain)
}
}
})
const ocspCache = new ocsp.Cache()
module.exports.OCSPRequestHandler =
function OCSPRequestHandler (certificate, issuer, callback) {
if (!issuer) return callback()
ocsp.getOCSPURI(certificate, (error, uri) => {
if (error) return callback(error)
if (uri === null) return callback()
const request = ocsp.request.generate(certificate, issuer)
ocspCache.probe(request.id, (error, { response }) => {
if (error) return callback(error)
if (response) return callback(null, response)
const options = {
url: uri,
ocsp: request.data
}
ocspCache.request(request.id, options, callback)
})
})
}
......@@ -10,6 +10,8 @@ const { Parser } = require('expr-eval')
const { promisify } = require('util')
const pino = require('pino')
const { normaliseHost } = require('@commonshost/configuration')
const { Gopher } = require('./gopher')
const { SNICallback, OCSPRequestHandler } = require('./helpers/tls')
const timeout = (time) => new Promise((resolve) => setTimeout(resolve, time))
......@@ -33,6 +35,9 @@ module.exports.Master = class Master {
if (this.redirector) {
await promisify(this.redirector.close).call(this.redirector)
}
if (this.gopher) {
await this.gopher.stop()
}
for (const worker of this.workers) {
worker.on('exit', (code, signal) => {
if (code === null) {
......@@ -58,6 +63,7 @@ module.exports.Master = class Master {
await this._setup()
await this._startWorkers()
await this._startRedirector()
await this._startGopher()
}
async reload () {
......@@ -68,6 +74,7 @@ module.exports.Master = class Master {
retiree.kill()
}
await this._startRedirector()
await this._startGopher()
}
async message (message) {
......@@ -153,6 +160,21 @@ module.exports.Master = class Master {
}
}
async _startGopher () {
if (this.gopher) {
await this.gopher.stop()
delete this.gopher
}
if (typeof this.options.gopher === 'object') {
this.gopher = new Gopher({
...this.options.gopher,
SNICallback: SNICallback(this.options),
OCSPRequestHandler
})
await this.gopher.start()
}
}
async _startWorkers () {
const files = {}
for (const { root, domain } of this.options.hosts) {
......
const { resolve } = require('path')
const { promisify } = require('util')
const { readFile, readFileSync } = require('fs')
const { app } = require('./app')
const ocsp = require('ocsp')
const { createSecureServer } = require('http2')
const { createSecureContext } = require('tls')
const isDomainName = require('is-domain-name')
const isIp = require('is-ip')
const { stringTemplate } = require('./helpers/stringTemplate')
async function read (filepath) {
const resolved = resolve(process.cwd(), filepath)
return promisify(readFile)(resolved)
}
function readSync (filepath) {
const resolved = resolve(process.cwd(), filepath)
return readFileSync(resolved)
}
const { SNICallback, OCSPRequestHandler } = require('./helpers/tls')
module.exports.server = (options, files) => {
const requestListener = app(options, files)
const ecdhCurve = 'P-384:P-256'
const fallbackKey = readSync(options.https.key)
const fallbackCert = readSync(options.https.cert)
const fallbackCa = options.https.ca.filter(String).map(readSync)
function fallbackSecureContext () {
return createSecureContext({
ecdhCurve,
key: fallbackKey,
cert: fallbackCert,
ca: fallbackCa
})
}
const acmeCache = new Map()
const serverOptions = {
allowHTTP1: true,
ecdhCurve,
// key: fallbackKey,
// cert: fallbackCert,
// ca: fallbackCa,
SNICallback: async (servername, callback) => {
if (acmeCache.has(servername)) {
const { key, cert } = acmeCache.get(servername)
const contextOptions = { ecdhCurve, key, cert }
return callback(null, createSecureContext(contextOptions))
}
if (!(isDomainName(servername) || isIp(servername))) {
return callback(null, fallbackSecureContext())
}
try {
const parameters = { store: options.acme.store, domain: servername }
const keyPath = stringTemplate(options.acme.key, parameters)
const certPath = stringTemplate(options.acme.cert, parameters)
const [key, cert] = await Promise.all([
read(resolve(options.acme.store, keyPath)),
read(resolve(options.acme.store, certPath))
])
const contextOptions = { ecdhCurve, key, cert }
const context = createSecureContext(contextOptions)
acmeCache.set(servername, { key, cert })
return callback(null, context)
} catch (error) {
if (error.code !== 'ENOENT') {
console.error(error)
}
return callback(null, fallbackSecureContext())
}
}
}
const serverOptions = { allowHTTP1: true, SNICallback: SNICallback(options) }
const server = createSecureServer(serverOptions, requestListener)
server.on('OCSPRequest', OCSPRequestHandler)
if (options.http2.timeout !== null) {
server.setTimeout(options.http2.timeout)
}
const ocspCache = new ocsp.Cache()
server.on('OCSPRequest', (certificate, issuer, callback) => {
if (!issuer) return callback()
ocsp.getOCSPURI(certificate, (error, uri) => {
if (error) return callback(error)
if (uri === null) return callback()
const request = ocsp.request.generate(certificate, issuer)
ocspCache.probe(request.id, (error, { response }) => {
if (error) return callback(error)
if (response) return callback(null, response)
const options = {
url: uri,
ocsp: request.data
}
ocspCache.request(request.id, options, callback)
})
})
})
process.on('message', ({ type, domain }) => {
if (type === 'certificate-issue' ||
type === 'certificate-revoke' ||
type === 'site-delete'
) {
if (acmeCache.has(domain)) {
acmeCache.delete(domain)
}
}
})
return server
}
File in root dir
\ No newline at end of file
Index of root dir
\ No newline at end of file
This file is a placeholder for the maliciously named directory.
\ No newline at end of file
......@@ -49,4 +49,4 @@ test('Make a Gopher over HTTPS request', async (t) => {
})
test('stop server', async (t) => master.close())
test('stop resolver', (t) => gopherServer.close(t.end))
test('stop mock Gopher server', (t) => gopherServer.close(t.end))
const test = require('blue-tape')
const { Master } = require('..')
const { join } = require('path')
const eventToPromise = require('event-to-promise')
const tls = require('tls')
const net = require('net')
async function readAll (socket) {
const chunks = []
for await (const chunk of socket) {
chunks.push(chunk)
}
return Buffer.concat(chunks)
}
let master
test('start server', async (t) => {
const cwd = join(__dirname, 'fixtures/gopher')
const options = {
gopher: {
port: 7000,
root: cwd
}
}
master = new Master({ cwd, options })
await master.listen()
})
const fixtures = [
{
selector: '',
expected: 'Index of root dir'
},
{
selector: '/',
expected: 'Index of root dir'
},
{
selector: '/gophermap',
expected: 'Index of root dir'
},
{
selector: '/bar.txt',
expected: 'File in root dir'
},
{
selector: '/does-not-exist',
expected: '3File not found\t\terror.host\t1\r\n.\r\n'
},
{
selector: '/subdir1',
expected: 'Index of subdir1'
},
{
selector: '/subdir1/',
expected: 'Index of subdir1'
},
{
selector: '/subdir1/gophermap',
expected: 'Index of subdir1'
},
{
selector: '/subdir2/foo.txt',
expected: 'File in subdir2'
},
{
selector: '/subdir2',
expected: '3File not found\t\terror.host\t1\r\n.\r\n'
},
{
selector: '/subdir3',
expected: '3File not found\t\terror.host\t1\r\n.\r\n'
},
{
selector: '/../../../../../../../package.json',
expected: '3File not found\t\terror.host\t1\r\n.\r\n'
}
]
for (const { selector, expected } of fixtures) {
test(`Make a Gopher request: ${selector}`, async (t) => {
const client = tls.connect({
servername: 'example.com',
port: 7000,
ALPNProtocols: ['gopher'],
rejectUnauthorized: false
})
await eventToPromise(client, 'secureConnect')
client.write(`${selector}\r\n`)
const response = await readAll(client)
t.is(response.toString(), expected)
})
}
test('Reject non-SNI clients', async (t) => {
const client = tls.connect({
// servername: undefined,
port: 7000,
ALPNProtocols: ['gopher'],
rejectUnauthorized: false
})
const error = await eventToPromise(client, 'error')
t.is(error.code, 'ECONNRESET')
})
test('Reject plaintext Gopher clients', async (t) => {
const client = net.connect({ port: 7000 })
await eventToPromise(client, 'connect')
client.write(`\r\n`)
const response = await readAll(client)
t.ok(response.toString().startsWith('3Secure connection required\t'))
})
test('stop server', async (t) => master.close())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment