feat: TLS session resumption

ci: require 100% test coverage
docs: notes about GoT, middleware options
parent b7ba0ced
Pipeline #122063874 passed with stage
in 1 minute and 6 seconds
......@@ -4,6 +4,6 @@ node.js:
NPM_CONFIG_LOGLEVEL: warn
cache:
paths:
- node_modules
- node_modules
script:
- npm it
- npm it
check-coverage: true
lines: 100
functions: 100
branches: 100
statements: 100
......@@ -2,6 +2,8 @@
Gopher-over-HTTPS (GoH) middleware for Node.js web servers.
Supports plaintext Gopher(-over-TCP) as well as encrypted, vhost-capable Gopher-over-TLS (GoT).
## Usage
```js
......@@ -62,6 +64,10 @@ https://gopher.commons.host
**timeout** is the number of milliseconds to keep idle Gopher sessions active. Defaults to `10000` (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.
**handshakeTimeout** is the number of milliseconds to wait for TCP/IP and TLS connection establishment. The default is `10000` (10 seconds).
Any other options are passed to the `net.Socket` and `tls.TLSSocket` constructors. This can be used, for example, to disable `rejectUnauthorized` in a unit test.
## Gopher over HTTPS (GoH) protocol
GoH maps the Gopher protocol onto an HTTP exchange. Both Gopher and HTTP use concepts of client-server and request-response. The GoH mapping is therefore appropriately simple and straightforward.
......@@ -91,3 +97,7 @@ A GoH server must include an HTTP Content-Type response header field indicating
Content-Type: application/gopher
A GoH server response has the Gopher response as its body.
## See Also
- [Gopher over TLS (GoT) protocol & server](https://gitlab.com/commonshost/goth)
......@@ -90,11 +90,12 @@ async function netConnect (port, host, options) {
return netSocket
}
async function tlsConnect (port, host, options) {
async function tlsConnect (port, host, options, session) {
const tlsSocket = tls.connect({
port,
host,
servername: host,
session,
ALPNProtocols: ['gopher'],
...options
})
......@@ -107,7 +108,7 @@ async function tlsConnect (port, host, options) {
return tlsSocket
}
async function connectGopher (origins, port, host, options) {
async function connectGopher (origins, tlsSessions, port, host, options) {
const origin = host + ':' + port
const protocol = origins.get(origin)
......@@ -118,9 +119,17 @@ async function connectGopher (origins, port, host, options) {
break
default:
case GOPHER_TLS:
const session = tlsSessions.get(origin)
try {
socket = await tlsConnect(port, host, options)
socket = await tlsConnect(port, host, options, session)
origins.set(origin, GOPHER_TLS)
if (socket.getProtocol() === 'TLSv1.2') {
const tlsSession = socket.getSession()
tlsSessions.set(origin, tlsSession)
}
socket.on('session', (tlsSession) => {
tlsSessions.set(origin, tlsSession)
})
} catch (error) {
if ('statusCode' in error) {
throw error
......@@ -149,6 +158,10 @@ function goh ({
maxAge: 60 * 1000
})
const tlsSessions = new LRU({
max: 1000
})
return async function goh (request, response, next) {
if (request.headers[HTTP2_HEADER_ACCEPT] !== gohMediaType) {
return next()
......@@ -184,7 +197,7 @@ function goh ({
let socket
try {
socket = await connectGopher(origins, port, host, {
socket = await connectGopher(origins, tlsSessions, port, host, {
unsafeAllowPrivateAddress,
handshakeTimeout,
...options
......
......@@ -17,8 +17,8 @@ function middlewareEchoError (error, request, response, next) {
async function sendGopherResponse (client) {
client.on('data', (chunk) => {
client.write('iHello, World!\t\terror.host\t1\r\n')
client.write(`iSelector: ${chunk}\t\terror.host\t1\r\n`)
client.write('iHello, World!\t\thost\t1\r\n')
client.write(`iSelector: ${chunk}\t\thost\t1\r\n`)
client.end()
})
}
......@@ -37,7 +37,6 @@ const tlsServerOptions = {
function createGopherTlsServer (connectionListener) {
const server = tls.createServer({
ALPNProtocols: ['gopher'],
rejectUnauthorized: false,
...tlsServerOptions
}, connectionListener)
return server
......@@ -579,7 +578,6 @@ test('Reject invalid ALPN', async (t) => {
}
const gopherServer = tls.createServer({
ALPNProtocols: ['definitely-not-gopher'],
rejectUnauthorized: false,
...tlsServerOptions
}, ignoreConnections)
gopherServer.listen(0)
......@@ -624,3 +622,69 @@ test('Reject invalid ALPN', async (t) => {
gopherServer.close()
await once(gopherServer, 'close')
})
for (const tlsVersion of ['TLSv1.2', 'TLSv1.3']) {
test(`Resume ${tlsVersion} session`, async (t) => {
const echoTls = (client) => {
client.on('data', (chunk) => {
client.write(`iTLS: ${client.getProtocol()}\t\thost\t1\r\n`)
client.write(`iResume: ${client.isSessionReused()}\t\thost\t1\r\n`)
client.end()
})
}
const gopherServer = tls.createServer({
minVersion: tlsVersion,
maxVersion: tlsVersion,
ALPNProtocols: ['gopher'],
...tlsServerOptions
}, echoTls)
gopherServer.listen(0)
await once(gopherServer, 'listening')
const { port: gopherPort } = gopherServer.address()
console.log(
`Unresponsive ${tlsVersion} server at gopher://localhost:${gopherPort}`
)
const tlsSocketOptions = {
rejectUnauthorized: false
}
const middleware = goh({
allowHTTP1: true,
unsafeAllowNonStandardPort: true,
unsafeAllowPrivateAddress: true,
...tlsSocketOptions
})
const app = connect()
app.use(middleware)
app.use(middlewareEchoError)
const httpServer = http1.createServer(app)
httpServer.listen(0)
await once(httpServer, 'listening')
const { port: httpPort } = httpServer.address()
console.log(`HTTP server at http://localhost:${httpPort}`)
const gopherUrl = encodeURIComponent(`gopher://localhost:${gopherPort}`)
const url = `http://localhost:${httpPort}/?url=${gopherUrl}`
for (const isSessionResumed of [false, true]) {
{
const client = context({ httpProtocol: 'http1' })
const response = await client.fetch(url, {
headers: { accept: 'application/gopher' }
})
t.is(response.ok, true)
const body = await response.text()
t.ok(body.includes(`TLS: ${tlsVersion}`))
t.ok(body.includes(`Resume: ${isSessionResumed}`))
await client.disconnectAll()
}
}
httpServer.close()
await once(httpServer, 'close')
gopherServer.close()
await once(gopherServer, '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