summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJannis R <mail@jannisr.de>2020-05-02 15:41:32 +0200
committerJannis R <mail@jannisr.de>2020-05-02 16:36:09 +0200
commit58d2e4c5e17b3306e35e7a8e28cce5cc4eb74f26 (patch)
tree44fd76efa940862b38442fa8926d6a0092268487
parent1b5af6f4bdfca939e780ae70bd6e1819425824f7 (diff)
basic client
-rw-r--r--client.js69
-rw-r--r--connect.js50
-rw-r--r--examples/client.js23
-rw-r--r--index.js2
-rw-r--r--lib/response-parser.js90
-rw-r--r--lib/util.js6
-rw-r--r--package.json3
-rw-r--r--readme.md2
-rw-r--r--server.js10
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)
+})
diff --git a/index.js b/index.js
index ee44e81..a2a3d3d 100644
--- a/index.js
+++ b/index.js
@@ -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"
],
diff --git a/readme.md b/readme.md
index 4a2c50b..2e3118f 100644
--- a/readme.md
+++ b/readme.md
@@ -1,6 +1,6 @@
# gemini
-**An experimental [Gemini](https://gemini.circumlunar.space) server.**
+**Experimental [Gemini](https://gemini.circumlunar.space) server & client.**
[![npm version](https://img.shields.io/npm/v/@derhuerst/gemini.svg)](https://www.npmjs.com/package/@derhuerst/gemini)
[![build status](https://api.travis-ci.org/derhuerst/gemini.svg?branch=master)](https://travis-ci.org/derhuerst/gemini)
diff --git a/server.js b/server.js
index 344728c..b077b84 100644
--- a/server.js
+++ b/server.js
@@ -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