@derhuerst/gemini
Version:
Experimental Gemini server & client.
321 lines (284 loc) • 8.9 kB
JavaScript
import createDebug from 'debug'
import {parse as parseUrl} from 'node:url'
import pem from 'pem'
import {pipeline as pipe} from 'node:stream'
import {connectToGeminiServer as connect} from './connect.js'
import {createResponseParser as createParser} from './lib/response-parser.js'
import {
DEFAULT_PORT,
ALPN_ID,
} from './lib/util.js'
import {CODES, MESSAGES} from './lib/statuses.js'
const debug = createDebug('gemini:client')
const debugRequest = createDebug('gemini:client:request')
const HOUR = 60 * 60 * 1000
const _request = (pathOrUrl, opt, ctx, cb) => {
debugRequest('_request', pathOrUrl, ctx, opt)
const {
verifyAlpnId,
headersTimeout,
bodyTimeout,
} = {
verifyAlpnId: alpnId => alpnId ? (alpnId === ALPN_ID) : true,
...opt,
}
connect(opt, (err, socket) => {
if (err) return cb(err)
debugRequest('connection', socket)
if (verifyAlpnId(socket.alpnProtocol) !== true) {
socket.destroy()
return cb(new Error('invalid or missing ALPN protocol'))
}
const res = createParser()
let resPassedOn = false
pipe(
socket,
res,
(err) => {
if (err) debugRequest('error receiving response', err)
// Control over the socket has been given to the caller
// already, so we swallow the error here.
if (resPassedOn) return;
// If control over the socket has been given to the caller already, we swallow the error here.
if (err) {
cb(err)
} else {
cb(new Error('socket closed while waiting for header'))
}
},
)
let headersTimeoutTimer = null
const reportHeadersTimeout = () => {
clearTimeout(headersTimeoutTimer)
const err = new Error('timeout waiting for response headers')
err.timeout = headersTimeout
// todo: is it okay to mimic syscall errors? does ETIMEDOUT apply to protocol-level timeouts?
err.code = 'ETIMEDOUT'
err.errno = -60
socket.destroy(err)
}
if (headersTimeout !== null) {
headersTimeoutTimer = setTimeout(reportHeadersTimeout, headersTimeout)
headersTimeoutTimer.unref()
}
let bodyTimeoutTimer = null
const reportBodyTimeout = () => {
clearTimeout(bodyTimeoutTimer)
bodyTimeoutTimer = null
const err = new Error('timeout waiting for first byte of the response')
err.timeout = bodyTimeout
// todo: is it okay to mimic syscall errors? does ETIMEDOUT apply to protocol-level timeouts?
err.code = 'ETIMEDOUT'
err.errno = -60
socket.destroy(err)
}
if (bodyTimeout !== null) {
bodyTimeoutTimer = setTimeout(reportBodyTimeout, bodyTimeout)
bodyTimeoutTimer.unref()
}
res.once('body-first-byte', () => {
clearTimeout(bodyTimeoutTimer)
bodyTimeoutTimer = null
})
res.once('header', (header) => {
clearTimeout(headersTimeoutTimer)
headersTimeoutTimer = null
debugRequest('received header', header)
// prepare res
res.socket = socket
res.statusCode = header.statusCode
res.statusMessage = header.statusMsg
res.meta = header.meta // todo: change name
// todo: res.abort(), res.destroy()
cb(null, res)
socket.emit('response', res)
resPassedOn = true
socket.once('end', () => socket.end())
})
// send request, but don't close the socket
socket.write(pathOrUrl + '\r\n')
})
}
// https://gemini.circumlunar.space/docs/spec-spec.txt, 1.4.3
// > Transient certificates are limited in scope to a particular domain.
// > Transient certificates MUST NOT be reused across different domains.
// >
// > Transient certificates MUST be permanently deleted when the matching
// > server issues a response with a status code of 21 (see Appendix 1
// > below).
// >
// > Transient certificates MUST be permanently deleted when the client
// > process terminates.
// >
// > Transient certificates SHOULD be permanently deleted after not having
// > been used for more than 24 hours.
const certs = new Map()
const defaultClientCertStore = {
get: (host, cb) => {
// reuse?
if (certs.has(host)) {
const {tCreated, cert, key} = certs.get(host)
if ((Date.now() - tCreated) <= 24 * HOUR) {
return cb(null, {tCreated, cert, key})
}
certs.delete(host) // expired
}
// generate new
const tCreated = Date.now()
pem.createCertificate({
days: 1, selfSigned: true
}, (err, {certificate: cert, clientKey: key}) => {
if (err) return cb(err)
certs.set(host, {tCreated, cert, key})
return cb(null, {tCreated, cert, key})
})
},
delete: (host, cb) => {
const has = certs.has(host)
if (has) certs.delete(host)
cb(null, has)
},
}
const errFromStatusCode = (res, msg = null) => {
const err = new Error(msg || MESSAGES[res.statusCode] || 'unknown error')
err.statusCode = res.statusCode
err.res = res
return err
}
const sendGeminiRequest = (pathOrUrl, opt, done) => {
if (typeof pathOrUrl !== 'string' || !pathOrUrl) {
throw new Error('pathOrUrl must be a string & not empty')
}
if (typeof opt === 'function') {
done = opt
opt = {}
}
const {
followRedirects,
useClientCerts,
letUserConfirmClientCertUsage,
clientCertStore,
connectTimeout,
headersTimeout,
timeout: bodyTimeout,
tlsOpt,
verifyAlpnId,
} = {
followRedirects: false,
// https://gemini.circumlunar.space/docs/spec-spec.txt, 1.4.3
// > Interactive clients for human users MUST inform users that such a
// > session has been requested and require the user to approve
// > generation of such a certificate. Transient certificates MUST NOT
// > be generated automatically.
// >
// > Transient certificates are limited in scope to a particular domain.
// > Transient certificates MUST NOT be reused across different domains.
useClientCerts: false,
letUserConfirmClientCertUsage: null,
clientCertStore: defaultClientCertStore,
connectTimeout: 60 * 1000, // 60s
// time to wait for response headers *after* the socket is connected
headersTimeout: 30 * 1000, // 30s
// time to wait for the first byte of the response body *after* the socket is connected
timeout: 40 * 1000, // 40s
tlsOpt: {},
...opt,
}
const shouldFollowRedirect = 'function' === typeof followRedirects
? followRedirects
: () => followRedirects
if (useClientCerts) {
if (typeof letUserConfirmClientCertUsage !== 'function') {
throw new Error('letUserConfirmClientCertUsage must be a function')
}
if (!clientCertStore) throw new Error('invalid clientCertStore')
if (typeof clientCertStore.get !== 'function') {
throw new Error('clientCertStore.get must be a function')
}
if (typeof clientCertStore.delete !== 'function') {
throw new Error('clientCertStore.delete must be a function')
}
}
const target = parseUrl(pathOrUrl)
let reqOpt = {
hostname: target.hostname || 'localhost',
port: target.port || DEFAULT_PORT,
connectTimeout,
headersTimeout,
bodyTimeout,
tlsOpt,
}
if (verifyAlpnId) reqOpt.verifyAlpnId = verifyAlpnId
let ctx = {
redirectsFollowed: 0,
}
let cb = (err, res) => {
if (err) return done(err)
// handle redirect
if ((
res.statusCode === CODES.REDIRECT_TEMPORARY ||
res.statusCode === CODES.REDIRECT_PERMANENT
) && shouldFollowRedirect(ctx.redirectsFollowed + 1, res)) {
ctx = {
...ctx,
redirectsFollowed: ctx.redirectsFollowed + 1
}
debug('following redirect nr', ctx.redirectsFollowed)
// todo: handle empty res.meta
const newTarget = parseUrl(res.meta)
reqOpt = {
...reqOpt,
hostname: newTarget.hostname || reqOpt.hostname,
port: newTarget.port || reqOpt.port,
}
pathOrUrl = res.meta
_request(res.meta, reqOpt, ctx, cb)
return;
}
// report server-sent errors
// > The contents of <META> may provide additional information
// > on certificate requirements or the reason a certificate
// > was rejected.
if (
res.statusCode === CODES.CERTIFICATE_NOT_ACCEPTED ||
res.statusCode === CODES.FUTURE_CERT_REJECTED ||
res.statusCode === CODES.EXPIRED_CERT_REJECTED
) return done(errFromStatusCode(res, res.meta))
// handle server-sent client cert prompt
if (
res.statusCode === CODES.CLIENT_CERT_REQUIRED ||
res.statusCode === CODES.TRANSIENT_CERT_REQUESTED ||
res.statusCode === CODES.AUTHORISED_CERT_REQUIRED
) {
if (!useClientCerts) {
const err = new Error('server request client cert, but client is configured not to send one')
err.res = res
return done(err)
}
const origin = reqOpt.hostname + ':' + reqOpt.port
letUserConfirmClientCertUsage({
host: origin,
reason: res.meta,
}, (confirmed) => {
if (confirmed !== true) {
const err = new Error('server request client cert, but user rejected')
err.res = res
return done(err)
}
clientCertStore.get(origin, (err, {cert, key}) => {
if (err) return done(err)
_request(pathOrUrl, {
...reqOpt,
cert, key,
}, ctx, cb)
})
})
return;
}
done(null, res)
}
_request(pathOrUrl, reqOpt, ctx, cb)
}
export {
sendGeminiRequest,
}