├── .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 | [](https://www.npmjs.com/package/@derhuerst/gemini)
6 | 
7 | 
8 | [](https://github.com/sponsors/derhuerst)
9 | [](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 |
--------------------------------------------------------------------------------