diff options
| -rw-r--r-- | client.js | 69 | ||||
| -rw-r--r-- | connect.js | 50 | ||||
| -rw-r--r-- | examples/client.js | 23 | ||||
| -rw-r--r-- | index.js | 2 | ||||
| -rw-r--r-- | lib/response-parser.js | 90 | ||||
| -rw-r--r-- | lib/util.js | 6 | ||||
| -rw-r--r-- | package.json | 3 | ||||
| -rw-r--r-- | readme.md | 2 | ||||
| -rw-r--r-- | server.js | 10 |
9 files changed, 248 insertions, 7 deletions
diff --git a/client.js b/client.js new file mode 100644 index 0000000..77dd88b --- /dev/null +++ b/client.js @@ -0,0 +1,69 @@ +'use strict' + +const connect = require('./connect') +const createParser = require('./lib/response-parser') +const { + DEFAULT_PORT, + ALPN_ID, +} = require('./lib/util') + +const sendGeminiRequest = (pathOrUrl, opt, cb) => { + if (typeof pathOrUrl !== 'string' || !pathOrUrl) { + throw new Error('pathOrUrl must be a string & not empty') + } + if (typeof opt === 'function') { + cb = opt + opt = {} + } + const { + port, + tlsOpt, + } = { + port: DEFAULT_PORT, + tlsOpt: {}, + ...opt, + } + + connect({ + port, tlsOpt, + }, (err, socket) => { + if (err) return cb(err) + + if (socket.alpnProtocol !== ALPN_ID) { + socket.destroy() + return cb(new Error('invalid or missing ALPN protocol')) + } + + const res = createParser() + socket.pipe(res) + socket.once('error', (err) => { + socket.unpipe(res) + res.destroy(err) + }) + + const close = () => { + socket.destroy() + res.destroy() + } + let timeout = setTimeout(close, 20 * 1000) + + res.once('header', (header) => { + clearTimeout(timeout) + + // prepare res + res.socket = socket + res.statusCode = header.statusCode + res.statusMessage = header.statusMsg + res.meta = header.meta // todo: change name + // todo: res.abort(), res.destroy() + + cb(null, res) + socket.emit('response', res) + }) + + // send request + socket.end(encodeURI(pathOrUrl) + ' \r\n') + }) +} + +module.exports = sendGeminiRequest diff --git a/connect.js b/connect.js new file mode 100644 index 0000000..35e2297 --- /dev/null +++ b/connect.js @@ -0,0 +1,50 @@ +'use strict' + +const {connect: connectTls} = require('tls') +const { + DEFAULT_PORT, + ALPN_ID, + MIN_TLS_VERSION, +} = require('./lib/util') + +const connectToGeminiServer = (opt, cb) => { + if (typeof opt === 'function') { + cb = opt + opt = {} + } + const { + host, + port, + tlsOpt, + } = { + host: '127.0.0.1', + port: DEFAULT_PORT, + tlsOpt: {}, + // todo: TOFU via isTrustedCertificate() + ...opt, + } + + const socket = connectTls({ + ALPNProtocols: [ALPN_ID], + minVersion: MIN_TLS_VERSION, + host, port, + // todo: cert, key, passphrase + ...tlsOpt, + }) + + let cbCalled = false + socket.once('error', (err) => { + if (cbCalled) return; + cbCalled = true + cb(err) + }) + socket.once('secureConnect', () => { + if (cbCalled) return; + cbCalled = true + cb(null, socket) + }) + + return socket +} + +module.exports = connectToGeminiServer diff --git a/examples/client.js b/examples/client.js new file mode 100644 index 0000000..1695335 --- /dev/null +++ b/examples/client.js @@ -0,0 +1,23 @@ +'use strict' + +const { + request, + DEFAULT_PORT, +} = require('..') + +const onError = (err) => { + console.error(err) + process.exit(1) +} + +request('/foo', { + tlsOpt: { + rejectUnauthorized: false, + }, +}, (err, res) => { + if (err) return onError(err) + + console.log(res.statusCode, res.statusMessage) + if (res.meta) console.log(res.meta) + res.pipe(process.stdout) +}) @@ -2,5 +2,7 @@ module.exports = { createServer: require('./server'), + connect: require('./connect'), + request: require('./client'), ...require('./lib/util'), } diff --git a/lib/response-parser.js b/lib/response-parser.js new file mode 100644 index 0000000..cb50f0a --- /dev/null +++ b/lib/response-parser.js @@ -0,0 +1,90 @@ +'use strict' + +const {Transform} = require('stream') +const {MESSAGES} = require('./statuses') + +// https://gemini.circumlunar.space/docs/spec-spec.txt, 1.3.1 +// > Gemini response headers look like this: +// > <STATUS><whitespace><META><CR><LF> +// > <STATUS> is a two-digit numeric status code [...]. +// > <whitespace> is any non-zero number of consecutive spaces or tabs. +// > <META> is a UTF-8 encoded string of maximum length 1024, whose meaning is +// > <STATUS> dependent. + +const CRLF = '\r\n' +const MAX_HEADER_SIZE = 2048 // cutoff + +const createResponseParser = () => { + let headerParsed = false + let peek = Buffer.alloc(0) + + const invalid = () => { + peek = null + out.destroy(new Error('invalid Gemini request')) + } + + const onData = (data) => { + if (headerParsed) { + out.push(data) + return; + } + + peek = Buffer.concat([peek, data], peek.length + data.length) + if ( + data.indexOf(CRLF) < 0 && + peek.length < MAX_HEADER_SIZE + ) return; // keep peeking + + const statusCodeAndSpace = peek.slice(0, 3).toString('utf8') + if (!/\d{2} /.test(statusCodeAndSpace)) return invalid() + const iCRLF = peek.indexOf(CRLF) + if (iCRLF < 0) return invalid() + + let statusCode = parseInt(statusCodeAndSpace) + let statusMsg = MESSAGES[statusCode] + if (!statusMsg) { + statusCode = Math.floor(statusCode / 10) * 10 + statusMsg = MESSAGES[statusCode] + if (!statusMsg) return invalid() + } + + const meta = peek.slice(3, iCRLF).toString('utf8').trim() + + headerParsed = true + out.emit('header', { + statusCode, statusMsg, + meta, + }) + + const iBody = iCRLF + 2 + if (peek.length > (iBody + 1)) { + // `data` contains the beginning of the body + out.push(peek.slice(iBody)) + } + peek = null // allow garbage collection + } + + const out = new Transform({ + write: (chunk, _, cb) => { + onData(chunk) + cb() + }, + writev: (chunks, cb) => { + for (let i = 0; i < chunks.length; i++) { + onData(chunks[i].chunk) + } + cb() + }, + }) + return out +} + +const p = createResponseParser() +p.on('error', console.error) +p.on('header', h => console.log('header', h)) +p.on('data', d => console.log('data', d.toString('utf8'))) +const b = str => Buffer.from(str, 'utf8') +p.write(b('31 gemini://examp')) +p.write(b('le.org/foo?bar\r\n')) + +module.exports = createResponseParser diff --git a/lib/util.js b/lib/util.js index 3d46bfb..bd3cc93 100644 --- a/lib/util.js +++ b/lib/util.js @@ -8,7 +8,13 @@ const ALPN_ID = 'gemini' // > (the first manned Gemini mission, Gemini 3, flew in March '65). const DEFAULT_PORT = 1965 +// https://gemini.circumlunar.space/docs/spec-spec.txt, 1.4.1 +// > Servers MUST use TLS version 1.2 or higher and SHOULD use TLS version +// > 1.3 or higher. +const MIN_TLS_VERSION = 'TLSv1.2' + module.exports = { ALPN_ID, DEFAULT_PORT, + MIN_TLS_VERSION, } diff --git a/package.json b/package.json index e2fe031..3ad9064 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "@derhuerst/gemini", - "description": "An experimental Gemini server.", + "description": "Experimental Gemini server & client.", "version": "1.0.0", "main": "index.js", "files": [ "index.js", "server.js", + "client.js", "lib", "examples" ], @@ -1,6 +1,6 @@ # gemini -**An experimental [Gemini](https://gemini.circumlunar.space) server.** +**Experimental [Gemini](https://gemini.circumlunar.space) server & client.** [](https://www.npmjs.com/package/@derhuerst/gemini) [](https://travis-ci.org/derhuerst/gemini) @@ -4,7 +4,10 @@ const {createServer: createTlsServer} = require('tls') const {EventEmitter} = require('events') const createParser = require('./lib/request-parser') const createResponse = require('./lib/response') -const {ALPN_ID} = require('./lib/util') +const { + ALPN_ID, + MIN_TLS_VERSION, +} = require('./lib/util') const createGeminiServer = (opt = {}, onRequest) => { if (typeof opt === 'function') { @@ -59,10 +62,7 @@ const createGeminiServer = (opt = {}, onRequest) => { const server = createTlsServer({ ALPNProtocols: [ALPN_ID], - // https://gemini.circumlunar.space/docs/spec-spec.txt, 1.4.1 - // > Servers MUST use TLS version 1.2 or higher and SHOULD use TLS version - // > 1.3 or higher. - minVersion: 'TLSv1.2', + minVersion: MIN_TLS_VERSION, requestCert: !!alwaysRequireClientCert, // > Gemini requests typically will be made without a client // > certificate being sent to the server. If a requested resource |
