├── .editorconfig ├── .github └── workflows │ └── test.yml ├── .gitignore ├── client.js ├── connect.js ├── eslint.config.mjs ├── examples ├── client.js └── server.js ├── index.js ├── lib ├── request-parser.js ├── response-parser.js ├── response.js ├── statuses.js └── util.js ├── license.md ├── package.json ├── readme.md ├── server.js └── test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [**.{js, json}] 11 | indent_style = tab 12 | indent_size = 4 13 | 14 | [**.{yml,yaml}] 15 | indent_style = spaces 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: ['18', '20'] 17 | 18 | steps: 19 | - name: checkout 20 | uses: actions/checkout@v2 21 | - name: setup Node v${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - run: npm install 26 | 27 | - run: npm run lint 28 | - run: npm test 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | Thumbs.db 3 | 4 | .nvm-version 5 | node_modules 6 | pnpm-debug.log 7 | 8 | /package-lock.json 9 | /yarn.lock 10 | /shrinkwrap.yaml 11 | -------------------------------------------------------------------------------- /client.js: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import {parse as parseUrl} from 'node:url' 3 | import pem from 'pem' 4 | import {pipeline as pipe} from 'node:stream' 5 | import {connectToGeminiServer as connect} from './connect.js' 6 | import {createResponseParser as createParser} from './lib/response-parser.js' 7 | import { 8 | DEFAULT_PORT, 9 | ALPN_ID, 10 | } from './lib/util.js' 11 | import {CODES, MESSAGES} from './lib/statuses.js' 12 | 13 | const debug = createDebug('gemini:client') 14 | const debugRequest = createDebug('gemini:client:request') 15 | 16 | const HOUR = 60 * 60 * 1000 17 | 18 | const _request = (pathOrUrl, opt, ctx, cb) => { 19 | debugRequest('_request', pathOrUrl, ctx, opt) 20 | 21 | const { 22 | verifyAlpnId, 23 | headersTimeout, 24 | bodyTimeout, 25 | } = { 26 | verifyAlpnId: alpnId => alpnId ? (alpnId === ALPN_ID) : true, 27 | ...opt, 28 | } 29 | 30 | connect(opt, (err, socket) => { 31 | if (err) return cb(err) 32 | debugRequest('connection', socket) 33 | 34 | if (verifyAlpnId(socket.alpnProtocol) !== true) { 35 | socket.destroy() 36 | return cb(new Error('invalid or missing ALPN protocol')) 37 | } 38 | 39 | const res = createParser() 40 | let resPassedOn = false 41 | pipe( 42 | socket, 43 | res, 44 | (err) => { 45 | if (err) debugRequest('error receiving response', err) 46 | // Control over the socket has been given to the caller 47 | // already, so we swallow the error here. 48 | if (resPassedOn) return; 49 | // If control over the socket has been given to the caller already, we swallow the error here. 50 | if (err) { 51 | cb(err) 52 | } else { 53 | cb(new Error('socket closed while waiting for header')) 54 | } 55 | }, 56 | ) 57 | 58 | let headersTimeoutTimer = null 59 | const reportHeadersTimeout = () => { 60 | clearTimeout(headersTimeoutTimer) 61 | const err = new Error('timeout waiting for response headers') 62 | err.timeout = headersTimeout 63 | // todo: is it okay to mimic syscall errors? does ETIMEDOUT apply to protocol-level timeouts? 64 | err.code = 'ETIMEDOUT' 65 | err.errno = -60 66 | socket.destroy(err) 67 | } 68 | if (headersTimeout !== null) { 69 | headersTimeoutTimer = setTimeout(reportHeadersTimeout, headersTimeout) 70 | headersTimeoutTimer.unref() 71 | } 72 | 73 | let bodyTimeoutTimer = null 74 | const reportBodyTimeout = () => { 75 | clearTimeout(bodyTimeoutTimer) 76 | bodyTimeoutTimer = null 77 | 78 | const err = new Error('timeout waiting for first byte of the response') 79 | err.timeout = bodyTimeout 80 | // todo: is it okay to mimic syscall errors? does ETIMEDOUT apply to protocol-level timeouts? 81 | err.code = 'ETIMEDOUT' 82 | err.errno = -60 83 | socket.destroy(err) 84 | } 85 | if (bodyTimeout !== null) { 86 | bodyTimeoutTimer = setTimeout(reportBodyTimeout, bodyTimeout) 87 | bodyTimeoutTimer.unref() 88 | } 89 | 90 | res.once('body-first-byte', () => { 91 | clearTimeout(bodyTimeoutTimer) 92 | bodyTimeoutTimer = null 93 | }) 94 | 95 | res.once('header', (header) => { 96 | clearTimeout(headersTimeoutTimer) 97 | headersTimeoutTimer = null 98 | debugRequest('received header', header) 99 | 100 | // prepare res 101 | res.socket = socket 102 | res.statusCode = header.statusCode 103 | res.statusMessage = header.statusMsg 104 | res.meta = header.meta // todo: change name 105 | // todo: res.abort(), res.destroy() 106 | 107 | cb(null, res) 108 | socket.emit('response', res) 109 | resPassedOn = true 110 | 111 | socket.once('end', () => socket.end()) 112 | }) 113 | 114 | // send request, but don't close the socket 115 | socket.write(pathOrUrl + '\r\n') 116 | }) 117 | } 118 | 119 | // https://gemini.circumlunar.space/docs/spec-spec.txt, 1.4.3 120 | // > Transient certificates are limited in scope to a particular domain. 121 | // > Transient certificates MUST NOT be reused across different domains. 122 | // > 123 | // > Transient certificates MUST be permanently deleted when the matching 124 | // > server issues a response with a status code of 21 (see Appendix 1 125 | // > below). 126 | // > 127 | // > Transient certificates MUST be permanently deleted when the client 128 | // > process terminates. 129 | // > 130 | // > Transient certificates SHOULD be permanently deleted after not having 131 | // > been used for more than 24 hours. 132 | const certs = new Map() 133 | const defaultClientCertStore = { 134 | get: (host, cb) => { 135 | // reuse? 136 | if (certs.has(host)) { 137 | const {tCreated, cert, key} = certs.get(host) 138 | if ((Date.now() - tCreated) <= 24 * HOUR) { 139 | return cb(null, {tCreated, cert, key}) 140 | } 141 | certs.delete(host) // expired 142 | } 143 | 144 | // generate new 145 | const tCreated = Date.now() 146 | pem.createCertificate({ 147 | days: 1, selfSigned: true 148 | }, (err, {certificate: cert, clientKey: key}) => { 149 | if (err) return cb(err) 150 | 151 | certs.set(host, {tCreated, cert, key}) 152 | return cb(null, {tCreated, cert, key}) 153 | }) 154 | }, 155 | delete: (host, cb) => { 156 | const has = certs.has(host) 157 | if (has) certs.delete(host) 158 | cb(null, has) 159 | }, 160 | } 161 | 162 | const errFromStatusCode = (res, msg = null) => { 163 | const err = new Error(msg || MESSAGES[res.statusCode] || 'unknown error') 164 | err.statusCode = res.statusCode 165 | err.res = res 166 | return err 167 | } 168 | 169 | const sendGeminiRequest = (pathOrUrl, opt, done) => { 170 | if (typeof pathOrUrl !== 'string' || !pathOrUrl) { 171 | throw new Error('pathOrUrl must be a string & not empty') 172 | } 173 | if (typeof opt === 'function') { 174 | done = opt 175 | opt = {} 176 | } 177 | const { 178 | followRedirects, 179 | useClientCerts, 180 | letUserConfirmClientCertUsage, 181 | clientCertStore, 182 | connectTimeout, 183 | headersTimeout, 184 | timeout: bodyTimeout, 185 | tlsOpt, 186 | verifyAlpnId, 187 | } = { 188 | followRedirects: false, 189 | // https://gemini.circumlunar.space/docs/spec-spec.txt, 1.4.3 190 | // > Interactive clients for human users MUST inform users that such a 191 | // > session has been requested and require the user to approve 192 | // > generation of such a certificate. Transient certificates MUST NOT 193 | // > be generated automatically. 194 | // > 195 | // > Transient certificates are limited in scope to a particular domain. 196 | // > Transient certificates MUST NOT be reused across different domains. 197 | useClientCerts: false, 198 | letUserConfirmClientCertUsage: null, 199 | clientCertStore: defaultClientCertStore, 200 | connectTimeout: 60 * 1000, // 60s 201 | // time to wait for response headers *after* the socket is connected 202 | headersTimeout: 30 * 1000, // 30s 203 | // time to wait for the first byte of the response body *after* the socket is connected 204 | timeout: 40 * 1000, // 40s 205 | tlsOpt: {}, 206 | ...opt, 207 | } 208 | 209 | const shouldFollowRedirect = 'function' === typeof followRedirects 210 | ? followRedirects 211 | : () => followRedirects 212 | 213 | if (useClientCerts) { 214 | if (typeof letUserConfirmClientCertUsage !== 'function') { 215 | throw new Error('letUserConfirmClientCertUsage must be a function') 216 | } 217 | if (!clientCertStore) throw new Error('invalid clientCertStore') 218 | if (typeof clientCertStore.get !== 'function') { 219 | throw new Error('clientCertStore.get must be a function') 220 | } 221 | if (typeof clientCertStore.delete !== 'function') { 222 | throw new Error('clientCertStore.delete must be a function') 223 | } 224 | } 225 | 226 | const target = parseUrl(pathOrUrl) 227 | let reqOpt = { 228 | hostname: target.hostname || 'localhost', 229 | port: target.port || DEFAULT_PORT, 230 | connectTimeout, 231 | headersTimeout, 232 | bodyTimeout, 233 | tlsOpt, 234 | } 235 | 236 | if (verifyAlpnId) reqOpt.verifyAlpnId = verifyAlpnId 237 | 238 | let ctx = { 239 | redirectsFollowed: 0, 240 | } 241 | 242 | let cb = (err, res) => { 243 | if (err) return done(err) 244 | 245 | // handle redirect 246 | if (( 247 | res.statusCode === CODES.REDIRECT_TEMPORARY || 248 | res.statusCode === CODES.REDIRECT_PERMANENT 249 | ) && shouldFollowRedirect(ctx.redirectsFollowed + 1, res)) { 250 | ctx = { 251 | ...ctx, 252 | redirectsFollowed: ctx.redirectsFollowed + 1 253 | } 254 | debug('following redirect nr', ctx.redirectsFollowed) 255 | 256 | // todo: handle empty res.meta 257 | const newTarget = parseUrl(res.meta) 258 | reqOpt = { 259 | ...reqOpt, 260 | hostname: newTarget.hostname || reqOpt.hostname, 261 | port: newTarget.port || reqOpt.port, 262 | } 263 | pathOrUrl = res.meta 264 | _request(res.meta, reqOpt, ctx, cb) 265 | return; 266 | } 267 | 268 | // report server-sent errors 269 | // > The contents of may provide additional information 270 | // > on certificate requirements or the reason a certificate 271 | // > was rejected. 272 | if ( 273 | res.statusCode === CODES.CERTIFICATE_NOT_ACCEPTED || 274 | res.statusCode === CODES.FUTURE_CERT_REJECTED || 275 | res.statusCode === CODES.EXPIRED_CERT_REJECTED 276 | ) return done(errFromStatusCode(res, res.meta)) 277 | 278 | // handle server-sent client cert prompt 279 | if ( 280 | res.statusCode === CODES.CLIENT_CERT_REQUIRED || 281 | res.statusCode === CODES.TRANSIENT_CERT_REQUESTED || 282 | res.statusCode === CODES.AUTHORISED_CERT_REQUIRED 283 | ) { 284 | if (!useClientCerts) { 285 | const err = new Error('server request client cert, but client is configured not to send one') 286 | err.res = res 287 | return done(err) 288 | } 289 | const origin = reqOpt.hostname + ':' + reqOpt.port 290 | letUserConfirmClientCertUsage({ 291 | host: origin, 292 | reason: res.meta, 293 | }, (confirmed) => { 294 | if (confirmed !== true) { 295 | const err = new Error('server request client cert, but user rejected') 296 | err.res = res 297 | return done(err) 298 | } 299 | 300 | clientCertStore.get(origin, (err, {cert, key}) => { 301 | if (err) return done(err) 302 | 303 | _request(pathOrUrl, { 304 | ...reqOpt, 305 | cert, key, 306 | }, ctx, cb) 307 | }) 308 | }) 309 | return; 310 | } 311 | 312 | done(null, res) 313 | } 314 | 315 | _request(pathOrUrl, reqOpt, ctx, cb) 316 | } 317 | 318 | export { 319 | sendGeminiRequest, 320 | } 321 | -------------------------------------------------------------------------------- /connect.js: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import {connect as connectTls} from 'node:tls' 3 | import { 4 | DEFAULT_PORT, 5 | ALPN_ID, 6 | MIN_TLS_VERSION, 7 | } from './lib/util.js' 8 | 9 | const debug = createDebug('gemini:connect') 10 | 11 | const connectToGeminiServer = (opt, cb) => { 12 | if (typeof opt === 'function') { 13 | cb = opt 14 | opt = {} 15 | } 16 | debug('connectToGeminiServer', opt) 17 | const { 18 | hostname, 19 | port, 20 | cert, key, passphrase, 21 | connectTimeout, 22 | tlsOpt, 23 | } = { 24 | hostname: '127.0.0.1', 25 | port: DEFAULT_PORT, 26 | cert: null, key: null, passphrase: null, 27 | // todo [breaking]: reduce to e.g. 20s 28 | connectTimeout: 60 * 1000, // 60s 29 | tlsOpt: {}, 30 | ...opt, 31 | } 32 | 33 | const socket = connectTls({ 34 | ALPNProtocols: [ALPN_ID], 35 | minVersion: MIN_TLS_VERSION, 36 | host: hostname, 37 | servername: hostname, 38 | port, 39 | cert, key, passphrase, 40 | ...tlsOpt, 41 | }) 42 | 43 | // Sets the socket to timeout after timeout milliseconds of inactivity on 44 | // the socket. By default net.Socket do not have a timeout. 45 | // When an idle timeout is triggered the socket will receive a 'timeout' 46 | // event but the connection will not be severed. The user must manually 47 | // call socket.end() or socket.destroy() to end the connection. 48 | // https://nodejs.org/api/net.html#net_socket_setnodelay_nodelay 49 | let timeoutTimer = null 50 | const onTimeout = () => { 51 | clearTimeout(timeoutTimer) 52 | const err = new Error('connect timeout') 53 | err.timeout = connectTimeout 54 | err.code = 'ETIMEDOUT' // is it okay to mimic syscall errors? 55 | err.errno = -60 56 | socket.destroy(err) 57 | } 58 | socket.once('timeout', onTimeout) 59 | if (connectTimeout !== null) { 60 | // This sets the timeout for inactivity on the *socket* layer. But the 61 | // TLS handshake might also stall. This is why we also set one manually. 62 | // see also https://github.com/nodejs/node/issues/5757 63 | socket.setTimeout(connectTimeout) 64 | timeoutTimer = setTimeout(onTimeout, connectTimeout) 65 | timeoutTimer.unref() 66 | } 67 | 68 | let cbCalled = false 69 | socket.once('error', (err) => { 70 | debug('socket error', err) 71 | if (cbCalled) return; 72 | cbCalled = true 73 | cb(err) 74 | }) 75 | socket.once('secureConnect', () => { 76 | if (cbCalled) return; 77 | // If timeout is 0, then the existing idle timeout is disabled. 78 | // https://nodejs.org/api/net.html#net_socket_setnodelay_nodelay 79 | socket.setTimeout(0) 80 | clearTimeout(timeoutTimer) 81 | 82 | cbCalled = true 83 | cb(null, socket) 84 | }) 85 | 86 | return socket 87 | } 88 | 89 | export { 90 | connectToGeminiServer, 91 | } 92 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js' 2 | import globals from 'globals' 3 | 4 | export default [ 5 | eslint.configs.recommended, 6 | { 7 | languageOptions: { 8 | ecmaVersion: 2022, 9 | globals: globals.node, 10 | }, 11 | rules: { 12 | 'no-unused-vars': [ 13 | 'error', 14 | { 15 | vars: 'all', 16 | args: 'none', 17 | ignoreRestSiblings: false 18 | }, 19 | ], 20 | }, 21 | }, 22 | ] 23 | -------------------------------------------------------------------------------- /examples/client.js: -------------------------------------------------------------------------------- 1 | import {createInterface} from 'readline' 2 | import {request} from '../index.js' 3 | 4 | // https://gemini.circumlunar.space/docs/spec-spec.txt, 1.4.3 5 | // > Interactive clients for human users MUST inform users that such a session 6 | // > has been requested and require the user to approve generation of such a 7 | // > certificate. Transient certificates MUST NOT be generated automatically. 8 | const letUserConfirmClientCertUsage = ({host, reason}, cb) => { 9 | const prompt = createInterface({ 10 | input: process.stdin, 11 | output: process.stdout, 12 | }) 13 | prompt.question([ 14 | `Send client cert to ${host}?`, 15 | reason ? ` Server says: "${reason}".` : '', 16 | ' y/n > ' 17 | ].join(''), (confirmed) => { 18 | prompt.close() 19 | cb(confirmed === 'y' || confirmed === 'Y') 20 | }) 21 | } 22 | 23 | request('/bar', { 24 | followRedirects: true, 25 | useClientCerts: true, letUserConfirmClientCertUsage, 26 | tlsOpt: { 27 | rejectUnauthorized: false, 28 | }, 29 | }, (err, res) => { 30 | if (err) { 31 | console.error(err) 32 | process.exit(1) 33 | } 34 | 35 | console.log(res.statusCode, res.statusMessage) 36 | if (res.meta) console.log(res.meta) 37 | res.pipe(process.stdout) 38 | }) 39 | -------------------------------------------------------------------------------- /examples/server.js: -------------------------------------------------------------------------------- 1 | import createCert from 'create-cert' 2 | import { 3 | createServer, 4 | DEFAULT_PORT, 5 | } from '../index.js' 6 | 7 | const onRequest = (req, res) => { 8 | console.log('request', req.url) 9 | if (req.clientFingerprint) console.log('client fingerprint:', req.clientFingerprint) 10 | 11 | if (req.path === '/foo') { 12 | if (!req.clientFingerprint) { 13 | return res.requestTransientClientCert('/foo is secret!') 14 | } 15 | res.write('foo') 16 | res.end('!') 17 | } else if (req.path === '/bar') { 18 | res.redirect('/foo') 19 | } else { 20 | res.gone() 21 | } 22 | } 23 | 24 | const keys = await createCert('example.org') 25 | 26 | const server = createServer({ 27 | tlsOpt: keys, 28 | // todo: SNICallback 29 | }, onRequest) 30 | server.on('error', console.error) 31 | 32 | server.listen(DEFAULT_PORT, (err) => { 33 | if (err) { 34 | console.error(err) 35 | process.exit(1) 36 | } 37 | 38 | const {address, port} = server.address() 39 | console.info(`listening on ${address}:${port}`) 40 | }) 41 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import {createGeminiServer} from './server.js' 2 | import {connectToGeminiServer} from './connect.js' 3 | import {sendGeminiRequest} from './client.js' 4 | import { 5 | ALPN_ID, 6 | DEFAULT_PORT, 7 | MIN_TLS_VERSION, 8 | } from './lib/util.js' 9 | 10 | export { 11 | createGeminiServer as createServer, 12 | 13 | connectToGeminiServer as connect, 14 | sendGeminiRequest as request, 15 | 16 | ALPN_ID, 17 | DEFAULT_PORT, 18 | MIN_TLS_VERSION, 19 | } 20 | -------------------------------------------------------------------------------- /lib/request-parser.js: -------------------------------------------------------------------------------- 1 | import {Transform} from 'node:stream' 2 | 3 | // https://gemini.circumlunar.space/docs/spec-spec.txt, 1.2 4 | // > Gemini requests are a single CRLF-terminated line with the 5 | // > following structure: 6 | // > is a UTF-8 encoded absolute URL, of maximum length 7 | // > 1024 bytes. [...] 8 | 9 | // todo: try to DRY with lib/response-parser.js? 10 | 11 | const CRLF = '\r\n' 12 | const MAX_HEADER_SIZE = 1024 + CRLF.length 13 | 14 | const createGeminiRequestParser = () => { 15 | let headerParsed = false 16 | let peek = Buffer.alloc(0) 17 | 18 | const invalid = () => { 19 | peek = null // allow garbage collection 20 | out.destroy(new Error('invalid Gemini request')) 21 | } 22 | 23 | const onData = (data) => { 24 | if (headerParsed) { 25 | out.push(data) 26 | return; 27 | } 28 | 29 | peek = Buffer.concat([peek, data], peek.length + data.length) 30 | if ( 31 | data.indexOf(CRLF) < 0 && 32 | peek.length < MAX_HEADER_SIZE 33 | ) return; // keep peeking 34 | 35 | const iCRLF = peek.indexOf(CRLF) 36 | if (iCRLF < 0) return invalid() 37 | 38 | const url = peek.slice(0, iCRLF).toString('utf8') 39 | headerParsed = true 40 | out.emit('header', {url}) 41 | 42 | const iBody = iCRLF + 2 43 | if (peek.length > (iBody + 1)) { 44 | // `data` contains the beginning of the body 45 | out.push(peek.slice(iBody)) 46 | } 47 | peek = null // allow garbage collection 48 | } 49 | 50 | // todo: emit error if readable ended without full header 51 | const out = new Transform({ 52 | write: (chunk, _, cb) => { 53 | onData(chunk) 54 | cb() 55 | }, 56 | writev: (chunks, cb) => { 57 | for (let i = 0; i < chunks.length; i++) { 58 | onData(chunks[i].chunk) 59 | } 60 | cb() 61 | }, 62 | }) 63 | return out 64 | } 65 | 66 | // const p = createGeminiRequestParser() 67 | // p.on('error', console.error) 68 | // p.on('header', h => console.log('header', h)) 69 | // p.on('data', d => console.log('data', d.toString('utf8'))) 70 | // const b = str => Buffer.from(str, 'utf8') 71 | // p.write(b('gemini://examp')) 72 | // p.write(b('le.org/foo?bar#baz\r\nhel')) 73 | // p.end(b('lo server!')) 74 | 75 | export { 76 | createGeminiRequestParser as createRequestParser, 77 | } 78 | -------------------------------------------------------------------------------- /lib/response-parser.js: -------------------------------------------------------------------------------- 1 | import {Transform} from 'node:stream' 2 | import {MESSAGES} from './statuses.js' 3 | 4 | // https://gemini.circumlunar.space/docs/spec-spec.txt, 1.3.1 5 | // > Gemini response headers look like this: 6 | // > 7 | // > is a two-digit numeric status code [...]. 8 | // > is any non-zero number of consecutive spaces or tabs. 9 | // > is a UTF-8 encoded string of maximum length 1024, whose meaning is 10 | // > dependent. 11 | 12 | // todo: try to DRY with lib/request-parser.js? 13 | 14 | const CRLF = '\r\n' 15 | const MAX_HEADER_SIZE = 2048 // cutoff 16 | 17 | const createGeminiResponseParser = () => { 18 | let headerParsed = false 19 | let peek = Buffer.alloc(0) 20 | 21 | const invalid = () => { 22 | peek = null // allow garbage collection 23 | out.destroy(new Error('invalid Gemini response')) 24 | } 25 | 26 | let firstByteEmitted = false 27 | const emitFirstByte = () => { 28 | if (firstByteEmitted) return; 29 | // A consumer that wants to know that the first byte of the body has been received (peek) *must not* modify the stream's behaviour. This is why we need a separate `body-first-byte` event. 30 | // > Readable streams effectively operate in one of two modes: flowing and paused. […] 31 | // > - In flowing mode, data is read from the underlying system automatically and provided to an application as quickly as possible using events via the EventEmitter interface. 32 | // > - In paused mode, the stream.read() method must be called explicitly to read chunks of data from the stream. 33 | // > All Readable streams begin in paused mode but can be switched to flowing mode in one of the following ways: 34 | // > - Adding a 'data' event handler. 35 | // > […] 36 | // > For backward compatibility reasons, removing 'data' event handlers will not automatically pause the stream. […] 37 | // > If a Readable is switched into flowing mode and there are no consumers available to handle the data, that data will be lost. This can occur, for instance, when the readable.resume() method is called without a listener attached to the 'data' event, or when a 'data' event handler is removed from the stream. 38 | // https://nodejs.org/docs/latest-v12.x/api/stream.html#stream_two_reading_modes 39 | out.emit('body-first-byte') 40 | firstByteEmitted = true 41 | } 42 | 43 | const onData = (data) => { 44 | if (headerParsed) { 45 | emitFirstByte() 46 | out.push(data) 47 | return; 48 | } 49 | 50 | peek = Buffer.concat([peek, data], peek.length + data.length) 51 | if ( 52 | data.indexOf(CRLF) < 0 && 53 | peek.length < MAX_HEADER_SIZE 54 | ) return; // keep peeking 55 | 56 | const statusCodeAndSpace = peek.slice(0, 3).toString('utf8') 57 | if (!/\d{2} /.test(statusCodeAndSpace)) return invalid() 58 | const iCRLF = peek.indexOf(CRLF) 59 | if (iCRLF < 0) return invalid() 60 | 61 | let statusCode = parseInt(statusCodeAndSpace) 62 | let statusMsg = MESSAGES[statusCode] 63 | if (!statusMsg) { 64 | statusCode = Math.floor(statusCode / 10) * 10 65 | statusMsg = MESSAGES[statusCode] 66 | if (!statusMsg) return invalid() 67 | } 68 | 69 | const meta = peek.slice(3, iCRLF).toString('utf8').trim() 70 | 71 | headerParsed = true 72 | out.emit('header', { 73 | statusCode, statusMsg, 74 | meta, 75 | }) 76 | 77 | // todo: do this async? 78 | const iBody = iCRLF + 2 79 | if (peek.length > (iBody + 1)) { 80 | emitFirstByte() 81 | // `data` contains the beginning of the body 82 | out.push(peek.slice(iBody)) 83 | } 84 | peek = null // allow garbage collection 85 | } 86 | 87 | // todo: emit error if readable ended without full response header(s) 88 | const out = new Transform({ 89 | write: (chunk, _, cb) => { 90 | onData(chunk) 91 | cb() 92 | }, 93 | writev: (chunks, cb) => { 94 | for (let i = 0; i < chunks.length; i++) { 95 | onData(chunks[i].chunk) 96 | } 97 | cb() 98 | }, 99 | }) 100 | return out 101 | } 102 | 103 | // const p = createGeminiResponseParser() 104 | // p.on('error', console.error) 105 | // p.on('header', h => console.log('header', h)) 106 | // p.on('body-first-byte', () => console.log(`first byte of the body received`)) 107 | // p.on('data', d => console.log('data', d.toString('utf8'))) 108 | // const b = str => Buffer.from(str, 'utf8') 109 | // p.write(b('31 gemini://examp')) 110 | // p.write(b('le.org/foo?bar\r\n')) 111 | 112 | export { 113 | createGeminiResponseParser as createResponseParser, 114 | } 115 | -------------------------------------------------------------------------------- /lib/response.js: -------------------------------------------------------------------------------- 1 | import {Transform} from 'node:stream' 2 | import {CODES} from './statuses.js' 3 | 4 | // https://gemini.circumlunar.space/docs/spec-spec.txt, 1.3.1 5 | // > Gemini response headers look like this: 6 | // > 7 | // > is a two-digit numeric status code, as described below in 8 | // > 1.3.2 and in Appendix 1. 9 | // > is any non-zero number of consecutive spaces or tabs. 10 | // > is a UTF-8 encoded string of maximum length 1024, whose 11 | // > meaning is dependent. 12 | 13 | const createGeminiResponse = () => { 14 | let headerSent = false 15 | const _sendHeader = () => { 16 | if (!res.writable) { 17 | // todo: debug-log: "response has already been closed/destroyed, cannot send header" 18 | return; 19 | } 20 | 21 | if (typeof res.statusCode !== 'number') { 22 | throw new Error('invalid res.statusCode') 23 | } 24 | const cat = Math.floor(res.statusCode / 10) 25 | 26 | if (cat === 2 && res.mimeType) { 27 | res.meta = res.mimeType // todo: validate 28 | } 29 | 30 | res.push(`${res.statusCode} ${res.meta}\r\n`) 31 | headerSent = true 32 | 33 | if (cat !== 2) res.push(null) // end 34 | } 35 | 36 | const sendHeader = (statusCode, meta = '') => { 37 | if (headerSent) throw new Error('header already sent') 38 | res.statusCode = statusCode 39 | res.meta = meta 40 | _sendHeader() 41 | } 42 | 43 | const write = (chunk, _, cb) => { 44 | if (!headerSent) _sendHeader() 45 | res.push(chunk) 46 | cb(null) 47 | } 48 | 49 | const res = new Transform({write}) 50 | res.statusCode = CODES.SUCCESS 51 | res.meta = '' 52 | res.mimeType = null 53 | res.sendHeader = sendHeader 54 | 55 | // convenience API 56 | res.prompt = (promptMsg) => { 57 | if (typeof promptMsg !== 'string') throw new Error('invalid promptMsg') 58 | sendHeader(CODES.INPUT, promptMsg) 59 | } 60 | res.redirect = (url, permanent = false) => { 61 | sendHeader(permanent ? CODES.REDIRECT_PERMANENT : CODES.REDIRECT_TEMPORARY, url) 62 | } 63 | res.proxyError = (msg) => { 64 | if (typeof msg !== 'string') throw new Error('invalid msg') 65 | sendHeader(CODES.PROXY_ERROR, msg) 66 | } 67 | res.slowDown = (waitForSeconds) => { 68 | if (!Number.isInteger(waitForSeconds)) { 69 | throw new Error('invalid waitForSeconds') 70 | } 71 | sendHeader(CODES.SLOW_DOWN, waitForSeconds + '') 72 | } 73 | res.notFound = () => { 74 | sendHeader(CODES.NOT_FOUND) 75 | } 76 | res.gone = () => { 77 | sendHeader(CODES.GONE) 78 | } 79 | res.badRequest = (msg) => { 80 | if (typeof msg !== 'string') throw new Error('invalid msg') 81 | sendHeader(CODES.BAD_REQUEST, msg) 82 | } 83 | res.requestTransientClientCert = (reason) => { 84 | if (typeof reason !== 'string') throw new Error('invalid reason') 85 | sendHeader(CODES.TRANSIENT_CERT_REQUESTED, reason) 86 | } 87 | res.requestAuthorizedClientCert = (reason) => { 88 | if (typeof reason !== 'string') throw new Error('invalid reason') 89 | sendHeader(CODES.AUTHORISED_CERT_REQUIRED, reason) 90 | } 91 | 92 | return res 93 | } 94 | 95 | export { 96 | createGeminiResponse as createResponse, 97 | } 98 | -------------------------------------------------------------------------------- /lib/statuses.js: -------------------------------------------------------------------------------- 1 | const CODES = Object.create(null) 2 | const MESSAGES = [] 3 | 4 | // https://gemini.circumlunar.space/docs/spec-spec.txt, Appendix 1 5 | // https://github.com/michael-lazar/jetforce/blob/5e0fd57f936112746b13b78db807e5bb9e9c94bc/jetforce.py#L76-L106 6 | const INPUT = CODES.INPUT = 10 7 | MESSAGES[INPUT] = 'INPUT' 8 | 9 | const SUCCESS = CODES.SUCCESS = 20 10 | MESSAGES[SUCCESS] = 'SUCCESS' 11 | const SUCCESS_END_OF_SESSION = CODES.SUCCESS_END_OF_SESSION = 21 12 | MESSAGES[SUCCESS_END_OF_SESSION] = 'SUCCESS – END OF SESSION' 13 | 14 | const REDIRECT_TEMPORARY = CODES.REDIRECT_TEMPORARY = 30 15 | MESSAGES[REDIRECT_TEMPORARY] = 'REDIRECT – TEMPORARY' 16 | const REDIRECT_PERMANENT = CODES.REDIRECT_PERMANENT = 31 17 | MESSAGES[REDIRECT_PERMANENT] = 'REDIRECT – PERMANENT' 18 | 19 | const TEMPORARY_FAILURE = CODES.TEMPORARY_FAILURE = 40 20 | MESSAGES[TEMPORARY_FAILURE] = 'TEMPORARY FAILURE' 21 | const SERVER_UNAVAILABLE = CODES.SERVER_UNAVAILABLE = 41 22 | MESSAGES[SERVER_UNAVAILABLE] = 'SERVER UNAVAILABLE' 23 | const CGI_ERROR = CODES.CGI_ERROR = 42 24 | MESSAGES[CGI_ERROR] = 'CGI ERROR' 25 | const PROXY_ERROR = CODES.PROXY_ERROR = 43 26 | MESSAGES[PROXY_ERROR] = 'PROXY ERROR' 27 | const SLOW_DOWN = CODES.SLOW_DOWN = 44 28 | MESSAGES[SLOW_DOWN] = 'SLOW DOWN' 29 | 30 | const PERMANENT_FAILURE = CODES.PERMANENT_FAILURE = 50 31 | MESSAGES[PERMANENT_FAILURE] = 'PERMANENT FAILURE' 32 | const NOT_FOUND = CODES.NOT_FOUND = 51 33 | MESSAGES[NOT_FOUND] = 'NOT FOUND' 34 | const GONE = CODES.GONE = 52 35 | MESSAGES[GONE] = 'GONE' 36 | const PROXY_REQUEST_REFUSED = CODES.PROXY_REQUEST_REFUSED = 53 37 | MESSAGES[PROXY_REQUEST_REFUSED] = 'PROXY REQUEST REFUSED' 38 | const BAD_REQUEST = CODES.BAD_REQUEST = 59 39 | MESSAGES[BAD_REQUEST] = 'BAD REQUEST' 40 | 41 | const CLIENT_CERT_REQUIRED = CODES.CLIENT_CERT_REQUIRED = 60 42 | MESSAGES[CLIENT_CERT_REQUIRED] = 'CLIENT CERTIFICATE REQUIRED' 43 | const TRANSIENT_CERT_REQUESTED = CODES.TRANSIENT_CERT_REQUESTED = 61 44 | MESSAGES[TRANSIENT_CERT_REQUESTED] = 'TRANSIENT CERTIFICATE REQUESTED' 45 | const AUTHORISED_CERT_REQUIRED = CODES.AUTHORISED_CERT_REQUIRED = 62 46 | MESSAGES[AUTHORISED_CERT_REQUIRED] = 'AUTHORISED CERTIFICATE REQUIRED' 47 | const CERTIFICATE_NOT_ACCEPTED = CODES.CERTIFICATE_NOT_ACCEPTED = 63 48 | MESSAGES[CERTIFICATE_NOT_ACCEPTED] = 'CERTIFICATE NOT ACCEPTED' 49 | const FUTURE_CERT_REJECTED = CODES.FUTURE_CERT_REJECTED = 64 50 | MESSAGES[FUTURE_CERT_REJECTED] = 'FUTURE CERTIFICATE REJECTED' 51 | const EXPIRED_CERT_REJECTED = CODES.EXPIRED_CERT_REJECTED = 65 52 | MESSAGES[EXPIRED_CERT_REJECTED] = 'EXPIRED CERTIFICATE REJECTED' 53 | 54 | export { 55 | CODES, 56 | MESSAGES, 57 | } 58 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | // todo: clarify if ALPN is wanted & if this ID is correct 2 | const ALPN_ID = 'gemini' 3 | 4 | // https://gemini.circumlunar.space/docs/spec-spec.txt, 1. 5 | // > When Gemini is served over TCP/IP, servers should listen on port 1965 6 | // > (the first manned Gemini mission, Gemini 3, flew in March '65). 7 | const DEFAULT_PORT = 1965 8 | 9 | // https://gemini.circumlunar.space/docs/spec-spec.txt, 1.4.1 10 | // > Servers MUST use TLS version 1.2 or higher and SHOULD use TLS version 11 | // > 1.3 or higher. 12 | const MIN_TLS_VERSION = 'TLSv1.2' 13 | 14 | export { 15 | ALPN_ID, 16 | DEFAULT_PORT, 17 | MIN_TLS_VERSION, 18 | } 19 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, Jannis R 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@derhuerst/gemini", 3 | "description": "Experimental Gemini server & client.", 4 | "version": "2.0.1", 5 | "type": "module", 6 | "main": "index.js", 7 | "files": [ 8 | "index.js", 9 | "server.js", 10 | "client.js", 11 | "connect.js", 12 | "lib", 13 | "examples", 14 | "test.js" 15 | ], 16 | "keywords": [ 17 | "gemini", 18 | "protocol", 19 | "server" 20 | ], 21 | "author": "Jannis R ", 22 | "contributors": [ 23 | "", 24 | "Björn Westergard " 25 | ], 26 | "homepage": "https://github.com/derhuerst/gemini", 27 | "repository": "derhuerst/gemini", 28 | "bugs": "https://github.com/derhuerst/gemini/issues", 29 | "license": "ISC", 30 | "engines": { 31 | "node": ">=18" 32 | }, 33 | "dependencies": { 34 | "debug": "^4.1.1", 35 | "pem": "^1.14.4" 36 | }, 37 | "devDependencies": { 38 | "create-cert": "^1.0.6", 39 | "eslint": "^9.22.0" 40 | }, 41 | "scripts": { 42 | "lint": "eslint .", 43 | "test": "node test.js", 44 | "prepublishOnly": "npm run lint && npm run test" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # gemini 2 | 3 | **[Gemini protocol](https://geminiprotocol.net/) server & client.** 4 | 5 | [![npm version](https://img.shields.io/npm/v/@derhuerst/gemini.svg)](https://www.npmjs.com/package/@derhuerst/gemini) 6 | ![ISC-licensed](https://img.shields.io/github/license/derhuerst/gemini.svg) 7 | ![minimum Node.js version](https://img.shields.io/node/v/@derhuerst/gemini.svg) 8 | [![support me via GitHub Sponsors](https://img.shields.io/badge/support%20me-donate-fa7664.svg)](https://github.com/sponsors/derhuerst) 9 | [![chat with me on Twitter](https://img.shields.io/badge/chat%20with%20me-on%20Twitter-1da1f2.svg)](https://twitter.com/derhuerst) 10 | 11 | > [!IMPORTANT] 12 | > 13 | > This package implements the Gemini specification as of end of 2022. 14 | 15 | 16 | ## Installation 17 | 18 | ```shell 19 | npm install @derhuerst/gemini 20 | ``` 21 | 22 | 23 | ## Usage 24 | 25 | ### Server 26 | 27 | The following code assumes that you have a valid SSL certificate & key. 28 | 29 | ```js 30 | import {createServer, DEFAULT_PORT} from '@derhuerst/gemini' 31 | 32 | const handleRequest = (req, res) => { 33 | if (req.path === '/foo') { 34 | if (!req.clientFingerprint) { 35 | return res.requestTransientClientCert('/foo is secret!') 36 | } 37 | res.write('foo') 38 | res.end('!') 39 | } else if (req.path === '/bar') { 40 | res.redirect('/foo') 41 | } else { 42 | res.gone() 43 | } 44 | } 45 | 46 | const server = createServer({ 47 | cert: …, // certificate (+ chain) 48 | key: …, // private key 49 | passphrase: …, // passphrase, if the key is encrypted 50 | }, handleRequest) 51 | 52 | server.listen(DEFAULT_PORT) 53 | server.on('error', console.error) 54 | ``` 55 | 56 | ### Client 57 | 58 | ```js 59 | import {sendGeminiRequest as request} from '@derhuerst/gemini/client.js' 60 | 61 | request('/bar', (err, res) => { 62 | if (err) { 63 | console.error(err) 64 | process.exit(1) 65 | } 66 | 67 | console.log(res.statusCode, res.statusMessage) 68 | if (res.meta) console.log(res.meta) 69 | res.pipe(process.stdout) 70 | }) 71 | ``` 72 | 73 | #### [TOFU](https://en.wikipedia.org/wiki/Trust_on_first_use)-style client certificates 74 | 75 | > 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. 76 | – [Gemini spec](https://gemini.circumlunar.space/docs/spec-spec.txt), section 1.4.3 77 | 78 | This library leaves it up to *you* how to ask the user for approval. As an example, we're going to build a simple CLI prompt: 79 | 80 | ```js 81 | import {createInterface} from 'node:readline' 82 | 83 | const letUserConfirmClientCertUsage = ({host, reason}, cb) => { 84 | const prompt = createInterface({ 85 | input: process.stdin, 86 | output: process.stdout, 87 | }) 88 | prompt.question(`Send client cert to ${host}? Server says: "${reason}". y/n > `, (confirmed) => { 89 | prompt.close() 90 | cb(confirmed === 'y' || confirmed === 'Y') 91 | }) 92 | } 93 | 94 | request('/foo', { 95 | // opt into client certificates 96 | useClientCerts: true, 97 | letUserConfirmClientCertUsage, 98 | }, cb) 99 | ``` 100 | 101 | 102 | ## API 103 | 104 | ### `createServer` 105 | 106 | ```js 107 | import {createGeminiServer as createServer} from '@derhuerst/gemini/server.js' 108 | createServer(opt = {}, onRequest) 109 | ``` 110 | 111 | `opt` extends the following defaults: 112 | 113 | ```js 114 | { 115 | // SSL certificate & key 116 | cert: null, key: null, passphrase: null, 117 | // additional options to be passed into `tls.createServer` 118 | tlsOpt: {}, 119 | // verify the ALPN ID requested by the client 120 | // see https://de.wikipedia.org/wiki/Application-Layer_Protocol_Negotiation 121 | verifyAlpnId: alpnId => alpnId ? alpnId === ALPN_ID : true, 122 | } 123 | ``` 124 | 125 | ### `request` 126 | 127 | ```js 128 | import {sendGeminiRequest as request} from '@derhuerst/gemini/client.js' 129 | request(pathOrUrl, opt = {}, cb) 130 | ``` 131 | 132 | `opt` extends the following defaults: 133 | 134 | ```js 135 | { 136 | // follow redirects automatically 137 | // Can also be a function `(nrOfRedirects, response) => boolean`. 138 | followRedirects: false, 139 | // client certificates 140 | useClientCerts: false, 141 | letUserConfirmClientCertUsage: null, 142 | clientCertStore: defaultClientCertStore, 143 | // time to wait for socket connection & TLS handshake 144 | connectTimeout: 60 * 1000, // 60s 145 | // time to wait for response headers *after* the socket is connected 146 | headersTimeout: 30 * 1000, // 30s 147 | // time to wait for the first byte of the response body *after* the socket is connected 148 | timeout: 40 * 1000, // 40s 149 | // additional options to be passed into `tls.connect` 150 | tlsOpt: {}, 151 | // verify the ALPN ID chosen by the server 152 | // see https://de.wikipedia.org/wiki/Application-Layer_Protocol_Negotiation 153 | verifyAlpnId: alpnId => alpnId ? (alpnId === ALPN_ID) : true, 154 | } 155 | ``` 156 | 157 | ### `connect` 158 | 159 | ```js 160 | import {connectToGeminiServer as connect} from '@derhuerst/gemini/connect.js' 161 | connect(opt = {}, cb) 162 | ``` 163 | 164 | `opt` extends the following defaults: 165 | 166 | ```js 167 | { 168 | hostname: '127.0.0.1', 169 | port: 1965, 170 | // client certificate 171 | cert: null, key: null, passphrase: null, 172 | // time to wait for socket connection & TLS handshake 173 | connectTimeout: 60 * 1000, // 60s 174 | // additional options to be passed into `tls.connect` 175 | tlsOpt: {}, 176 | } 177 | ``` 178 | 179 | 180 | ## Related 181 | 182 | - [`gemini-fetch`](https://github.com/RangerMauve/gemini-fetch) – Load data from the Gemini protocol the way you would fetch from HTTP in JavaScript 183 | - [`dioscuri`](https://github.com/wooorm/dioscuri) – A gemtext (`text/gemini`) parser with support for streaming, ASTs, and CSTs 184 | 185 | 186 | ## Contributing 187 | 188 | If you have a question or need support using `gemini`, please double-check your code and setup first. If you think you have found a bug or want to propose a feature, use [the issues page](https://github.com/derhuerst/gemini/issues). 189 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import {createServer as createTlsServer} from 'node:tls' 3 | import {pipeline as pipe} from 'node:stream' 4 | import {createRequestParser as createParser} from './lib/request-parser.js' 5 | import {createResponse} from './lib/response.js' 6 | import { 7 | ALPN_ID, 8 | MIN_TLS_VERSION, 9 | } from './lib/util.js' 10 | 11 | const debug = createDebug('gemini:server') 12 | 13 | const createGeminiServer = (opt = {}, onRequest) => { 14 | if (typeof opt === 'function') { 15 | onRequest = opt 16 | opt = {} 17 | } 18 | const { 19 | cert, key, passphrase, 20 | tlsOpt, 21 | verifyAlpnId, 22 | } = { 23 | cert: null, key: null, passphrase: null, 24 | tlsOpt: {}, 25 | verifyAlpnId: alpnId => alpnId ? alpnId === ALPN_ID : true, 26 | ...opt, 27 | } 28 | 29 | const onConnection = (socket) => { 30 | debug('connection', socket) 31 | 32 | // todo: clarify if this is desired behavior 33 | if (verifyAlpnId(socket.alpnProtocol) !== true) { 34 | debug('invalid ALPN ID, closing socket') 35 | socket.destroy() 36 | return; 37 | } 38 | if ( 39 | socket.authorizationError && 40 | // allow self-signed certs 41 | socket.authorizationError !== 'SELF_SIGNED_CERT_IN_CHAIN' && 42 | socket.authorizationError !== 'DEPTH_ZERO_SELF_SIGNED_CERT' && 43 | socket.authorizationError !== 'UNABLE_TO_GET_ISSUER_CERT' 44 | ) { 45 | debug('authorization error, closing socket') 46 | socket.destroy(new Error(socket.authorizationError)) 47 | return; 48 | } 49 | const clientCert = socket.getPeerCertificate() 50 | 51 | const req = createParser() 52 | pipe( 53 | socket, 54 | req, 55 | (err) => { 56 | if (err) debug('error receiving request', err) 57 | if (timeout && err) { 58 | debug('socket closed while waiting for header') 59 | } 60 | // todo? https://nodejs.org/api/http.html#http_event_clienterror 61 | }, 62 | ) 63 | 64 | const reportTimeout = () => { 65 | socket.destroy(new Error('timeout waiting for header')) 66 | } 67 | let timeout = setTimeout(reportTimeout, 20 * 1000) 68 | timeout.unref() 69 | 70 | req.once('header', (header) => { 71 | clearTimeout(timeout) 72 | timeout = null 73 | debug('received header', header) 74 | 75 | // prepare req 76 | req.socket = socket 77 | req.url = header.url 78 | const url = new URL(header.url, 'http://foo/') 79 | req.path = url.pathname 80 | if (clientCert && clientCert.fingerprint) { 81 | req.clientFingerprint = clientCert.fingerprint 82 | } 83 | // todo: req.abort(), req.destroy() 84 | 85 | // prepare res 86 | const res = createResponse() 87 | Object.defineProperty(res, 'socket', {value: socket}) 88 | 89 | pipe( 90 | res, 91 | socket, 92 | (err) => { 93 | if (err) debug('error sending response', err) 94 | }, 95 | ) 96 | 97 | onRequest(req, res) 98 | server.emit('request', req, res) 99 | }) 100 | } 101 | 102 | const server = createTlsServer({ 103 | // Disabled ALPNProtocols to mitigate connection issues in gemini 104 | // clients as reported in #5 105 | // ALPNProtocols: [ALPN_ID], 106 | minVersion: MIN_TLS_VERSION, 107 | // > Usually the server specifies in the Server Hello message if a 108 | // > client certificate is needed/wanted. 109 | // > Does anybody know if it is possible to perform an authentication 110 | // > via client cert if the server does not request it? 111 | // 112 | // > The client won't send a certificate unless the server asks for it 113 | // > with a `Certificate Request` message (see the standard, section 114 | // > 7.4.4). If the server does not ask for a certificate, the sending 115 | // > of a `Certificate` and a `CertificateVerify` message from the 116 | // > client is likely to imply an immediate termination from the server 117 | // > (with an unexpected_message alert). 118 | // https://security.stackexchange.com/a/36101 119 | requestCert: true, 120 | // > Gemini requests typically will be made without a client 121 | // > certificate being sent to the server. If a requested resource 122 | // > is part of a server-side application which requires persistent 123 | // > state, a Gemini server can [...] request that the client repeat 124 | // the request with a "transient certificate" to initiate a client 125 | // > certificate section. 126 | rejectUnauthorized: false, 127 | cert, key, passphrase, 128 | ...tlsOpt, 129 | }, onConnection) 130 | 131 | return server 132 | } 133 | 134 | export { 135 | createGeminiServer, 136 | } 137 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import createCert from 'create-cert' 2 | import {promisify} from 'node:util' 3 | import {strictEqual, fail} from 'node:assert' 4 | import { 5 | createServer, 6 | DEFAULT_PORT, 7 | request, 8 | } from './index.js' 9 | 10 | const r = promisify(request) 11 | 12 | const readIntoString = async (readableStream) => { 13 | return Buffer.concat(await readableStream.toArray()).toString('utf8') 14 | } 15 | 16 | const onRequest = (req, res) => { 17 | console.log('request', req.url) 18 | if (req.clientFingerprint) console.log('client fingerprint:', req.clientFingerprint) 19 | 20 | if (req.path === '/foo') { 21 | setTimeout(() => { 22 | if (!req.clientFingerprint) { 23 | return res.requestTransientClientCert('/foo is secret!') 24 | } 25 | res.write('foo') 26 | res.end('!') 27 | }, 500) 28 | } else if (req.path === '/bar') { 29 | setTimeout(() => { 30 | res.redirect('/foo') 31 | }, 500) 32 | } else { 33 | res.gone() 34 | } 35 | } 36 | 37 | const onError = (err) => { 38 | console.error(err) 39 | process.exit(1) 40 | } 41 | 42 | { 43 | const server = createServer({ 44 | tlsOpt: await createCert('example.org'), 45 | }, onRequest) 46 | 47 | server.on('error', onError) 48 | await promisify(server.listen.bind(server))(DEFAULT_PORT) 49 | 50 | const res1 = await r('/bar', { 51 | tlsOpt: {rejectUnauthorized: false}, 52 | }) 53 | strictEqual(res1.statusCode, 30) 54 | strictEqual(res1.meta, '/foo') 55 | 56 | const baseOpts = { 57 | tlsOpt: {rejectUnauthorized: false}, 58 | followRedirects: true, 59 | useClientCerts: true, 60 | letUserConfirmClientCertUsage: (_, cb) => cb(true), 61 | } 62 | const res2 = await r('/bar', { 63 | ...baseOpts, 64 | }) 65 | strictEqual(res2.statusCode, 20) 66 | strictEqual(await readIntoString(res2), 'foo!') 67 | 68 | { 69 | let threw = false 70 | try { 71 | await r('/bar', { 72 | ...baseOpts, 73 | useClientCerts: false, 74 | }) 75 | } catch (err) { 76 | strictEqual(err.message, 'server request client cert, but client is configured not to send one', 'err.message is invalid') 77 | threw = true 78 | } 79 | if (!threw) fail(`request() didn't throw despite short timeout`) 80 | } 81 | 82 | { 83 | let threw = false 84 | try { 85 | await r('/bar', { 86 | ...baseOpts, 87 | headersTimeout: 100, // too short for the mock server to respond 88 | }) 89 | } catch (err) { 90 | strictEqual(err.code, 'ETIMEDOUT', 'err.code is invalid') 91 | strictEqual(err.message, 'timeout waiting for response headers', 'err.message is invalid') 92 | threw = true 93 | } 94 | if (!threw) fail(`request() didn't throw despite short timeout`) 95 | } 96 | 97 | { 98 | let threw = false 99 | try { 100 | await r('/bar', { 101 | ...baseOpts, 102 | timeout: 100, // too short for the mock server to send the body 103 | }) 104 | } catch (err) { 105 | strictEqual(err.code, 'ETIMEDOUT', 'err.code is invalid') 106 | strictEqual(err.message, 'timeout waiting for first byte of the response', 'err.message is invalid') 107 | threw = true 108 | } 109 | if (!threw) fail(`request() didn't throw despite short timeout`) 110 | } 111 | 112 | server.close() 113 | } 114 | --------------------------------------------------------------------------------