Skip to content

Commit

Permalink
Expose Connector in the public API (#906)
Browse files Browse the repository at this point in the history
* Expose Connector in the public API

* Updated test

* Add ca fingerprint example

* Improve types

* Updated test

* Nit

* Make connector a function instead of a class

* Updated test

* Updated examples

* Updated docs

* Updated docs
  • Loading branch information
delvedor authored Jul 28, 2021
1 parent 6061920 commit 11c2db1
Show file tree
Hide file tree
Showing 13 changed files with 415 additions and 43 deletions.
25 changes: 25 additions & 0 deletions docs/api/Client.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,31 @@ import { Client } from 'undici'
const client = new Client('http://localhost:3000')
```

### Example - Custom connector

This will allow you to perform some additional check on the socket that will be used for the next request.

```js
'use strict'
import { Client, buildConnector } from 'undici'

const connector = buildConnector({ rejectUnauthorized: false })
const client = new Client('https://localhost:3000', {
connect (opts, cb) {
connector(opts, (err, socket) => {
if (err) {
cb(err)
} else if (/* assertion */) {
socket.destroy()
cb(new Error('kaboom'))
} else {
cb(null, socket)
}
})
}
})
```

## Instance Methods

### `Client.close([callback])`
Expand Down
109 changes: 109 additions & 0 deletions docs/api/Connector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Connector

Undici creates the underlying socket via the connector builder.
Normally, this happens automatically and you don't need to care about this,
but if you need to perform some additional check over the currently used socket,
this is the right place.

If you want to create a custom connector, you must import the `buildConnector` utility.

#### Parameter: `buildConnector.BuildOptions`

Every Tls option, see [here](https://nodejs.org/api/tls.html#tls_tls_connect_options_callback).
Furthermore, the following options can be passed:

* **socketPath** `string | null` (optional) - Default: `null` - An IPC endpoint, either Unix domain socket or Windows named pipe.
* **maxCachedSessions** `number | null` (optional) - Default: `100` - Maximum number of TLS cached sessions. Use 0 to disable TLS session caching. Default: 100.
* **timeout** `number | null` (optional) - Default `10e3`
* **servername** `string | null` (optional)

Once you call `buildConnector`, it will return a connector function, which takes the following parameters.

#### Parameter: `connector.Options`

* **hostname** `string` (required)
* **host** `string` (optional)
* **protocol** `string` (required)
* **port** `number` (required)
* **servername** `string` (optional)

### Basic example

```js
'use strict'

import { Client, buildConnector } from 'undici'

const connector = buildConnector({ rejectUnauthorized: false })
const client = new Client('https://localhost:3000', {
connect (opts, cb) {
connector(opts, (err, socket) => {
if (err) {
cb(err)
} else if (/* assertion */) {
socket.destroy()
cb(new Error('kaboom'))
} else {
cb(null, socket)
}
})
}
})
```

### Example: validate the CA fingerprint

```js
'use strict'

import { Client, buildConnector } from 'undici'

const caFingerprint = 'FO:OB:AR'
const connector = buildConnector({ rejectUnauthorized: false })
const client = new Client('https://localhost:3000', {
connect (opts, cb) {
connector(opts, (err, socket) => {
if (err) {
cb(err)
} else if (getIssuerCertificate(socket).fingerprint256 !== caFingerprint) {
socket.destroy()
cb(new Error('Fingerprint does not match'))
} else {
cb(null, socket)
}
})
}
})

client.request({
path: '/',
method: 'GET'
}, (err, data) => {
if (err) throw err

const bufs = []
data.body.on('data', (buf) => {
bufs.push(buf)
})
data.body.on('end', () => {
console.log(Buffer.concat(bufs).toString('utf8'))
client.close()
})
})

function getIssuerCertificate (socket) {
let certificate = socket.getPeerCertificate(true)
while (certificate && Object.keys(certificate).length > 0) {
if (certificate.issuerCertificate !== undefined) {
// For self-signed certificates, `issuerCertificate` may be a circular reference.
if (certificate.fingerprint256 === certificate.issuerCertificate.fingerprint256) {
break
}
certificate = certificate.issuerCertificate
} else {
break
}
}
return certificate
}
```
1 change: 1 addition & 0 deletions docsify/sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* [Client](/docs/api/Client.md "Undici API - Client")
* [Pool](/docs/api/Pool.md "Undici API - Pool")
* [Agent](/docs/api/Agent.md "Undici API - Agent")
* [Connector](/docs/api/Connector.md "Custom connector")
* [Errors](/docs/api/Errors.md "Undici API - Errors")
* [MockClient](/docs/api/MockClient.md "Undici API - MockClient")
* [MockPool](/docs/api/MockPool.md "Undici API - MockPool")
Expand Down
76 changes: 76 additions & 0 deletions examples/ca-fingerprint/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
'use strict'

const crypto = require('crypto')
const https = require('https')
const { Client, buildConnector } = require('../..')
const pem = require('https-pem')

const caFingerprint = getFingerprint(pem.cert.toString()
.split('\n')
.slice(1, -1)
.map(line => line.trim())
.join('')
)

const server = https.createServer(pem, (req, res) => {
res.setHeader('Content-Type', 'text/plain')
res.end('hello')
})

server.listen(0, function () {
const connector = buildConnector({ rejectUnauthorized: false })
const client = new Client(`https://localhost:${server.address().port}`, {
connect (opts, cb) {
connector(opts, (err, socket) => {
if (err) {
cb(err)
} else if (getIssuerCertificate(socket).fingerprint256 !== caFingerprint) {
socket.destroy()
cb(new Error('Fingerprint does not match'))
} else {
cb(null, socket)
}
})
}
})

client.request({
path: '/',
method: 'GET'
}, (err, data) => {
if (err) throw err

const bufs = []
data.body.on('data', (buf) => {
bufs.push(buf)
})
data.body.on('end', () => {
console.log(Buffer.concat(bufs).toString('utf8'))
client.close()
server.close()
})
})
})

function getIssuerCertificate (socket) {
let certificate = socket.getPeerCertificate(true)
while (certificate && Object.keys(certificate).length > 0) {
if (certificate.issuerCertificate !== undefined) {
// For self-signed certificates, `issuerCertificate` may be a circular reference.
if (certificate.fingerprint256 === certificate.issuerCertificate.fingerprint256) {
break
}
certificate = certificate.issuerCertificate
} else {
break
}
}
return certificate
}

function getFingerprint (content, inputEncoding = 'base64', outputEncoding = 'hex') {
const shasum = crypto.createHash('sha256')
shasum.update(content, inputEncoding)
const res = shasum.digest(outputEncoding)
return res.toUpperCase().match(/.{1,2}/g).join(':')
}
4 changes: 3 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Dispatcher from './types/dispatcher'
import { setGlobalDispatcher, getGlobalDispatcher } from './types/global-dispatcher'
import Pool from './types/pool'
import Client from './types/client'
import buildConnector from './types/connector'
import errors from './types/errors'
import Agent from './types/agent'
import MockClient from './types/mock-client'
Expand All @@ -10,7 +11,7 @@ import MockAgent from './types/mock-agent'
import mockErrors from './types/mock-errors'
import { request, pipeline, stream, connect, upgrade } from './types/api'

export { Dispatcher, Pool, Client, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, MockClient, MockPool, MockAgent, mockErrors }
export { Dispatcher, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, MockClient, MockPool, MockAgent, mockErrors }
export default Undici

declare function Undici(url: string, opts: Pool.Options): Pool
Expand All @@ -19,6 +20,7 @@ declare namespace Undici {
var Dispatcher: typeof import('./types/dispatcher')
var Pool: typeof import('./types/pool');
var Client: typeof import('./types/client');
var buildConnector: typeof import('./types/connector');
var errors: typeof import('./types/errors');
var Agent: typeof import('./types/agent');
var setGlobalDispatcher: typeof import('./types/global-dispatcher').setGlobalDispatcher;
Expand Down
2 changes: 2 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const Agent = require('./lib/agent')
const util = require('./lib/core/util')
const { InvalidArgumentError } = require('./lib/core/errors')
const api = require('./lib/api')
const buildConnector = require('./lib/core/connect')
const MockClient = require('./lib/mock/mock-client')
const MockAgent = require('./lib/mock/mock-agent')
const MockPool = require('./lib/mock/mock-pool')
Expand All @@ -20,6 +21,7 @@ module.exports.Client = Client
module.exports.Pool = Pool
module.exports.Agent = Agent

module.exports.buildConnector = buildConnector
module.exports.errors = errors

let globalDispatcher = new Agent()
Expand Down
4 changes: 2 additions & 2 deletions lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const {
BodyTimeoutError,
HTTPParserError
} = require('./core/errors')
const makeConnect = require('./core/connect')
const buildConnector = require('./core/connect')

const {
kUrl,
Expand Down Expand Up @@ -149,7 +149,7 @@ class Client extends Dispatcher {
}

if (typeof connect !== 'function') {
connect = makeConnect({
connect = buildConnector({
...tls,
maxCachedSessions,
socketPath,
Expand Down
53 changes: 23 additions & 30 deletions lib/core/connect.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,36 +11,31 @@ const { InvalidArgumentError, ConnectTimeoutError } = require('./errors')
// resolve the same servername multiple times even when
// re-use is enabled.

class Connector {
constructor ({ maxCachedSessions, socketPath, timeout, ...opts }) {
if (maxCachedSessions != null && (!Number.isInteger(maxCachedSessions) || maxCachedSessions < 0)) {
throw new InvalidArgumentError('maxCachedSessions must be a positive integer or zero')
}

this.opts = { path: socketPath, ...opts }
this.timeout = timeout == null ? 10e3 : timeout
this.sessionCache = new Map()
this.maxCachedSessions = maxCachedSessions == null ? 100 : maxCachedSessions
function buildConnector ({ maxCachedSessions, socketPath, timeout, ...opts }) {
if (maxCachedSessions != null && (!Number.isInteger(maxCachedSessions) || maxCachedSessions < 0)) {
throw new InvalidArgumentError('maxCachedSessions must be a positive integer or zero')
}

connect ({ hostname, host, protocol, port, servername }, callback) {
const options = { path: socketPath, ...opts }
const sessionCache = new Map()
timeout = timeout == null ? 10e3 : timeout
maxCachedSessions = maxCachedSessions == null ? 100 : maxCachedSessions

return function connect ({ hostname, host, protocol, port, servername }, callback) {
let socket
if (protocol === 'https:') {
servername = servername || this.opts.servername || util.getServerName(host)
servername = servername || options.servername || util.getServerName(host)

const session = this.sessionCache.get(servername) || null
const session = sessionCache.get(servername) || null

socket = tls.connect({
...this.opts,
...options,
servername,
session,
port: port || 443,
host: hostname
})

const cache = this.sessionCache
const maxCachedSessions = this.maxCachedSessions

socket
.on('session', function (session) {
assert(this.servername)
Expand All @@ -50,36 +45,36 @@ class Connector {
return
}

if (cache.size >= maxCachedSessions) {
if (sessionCache.size >= maxCachedSessions) {
// remove the oldest session
const { value: oldestKey } = cache.keys().next()
cache.delete(oldestKey)
const { value: oldestKey } = sessionCache.keys().next()
sessionCache.delete(oldestKey)
}

cache.set(this.servername, session)
sessionCache.set(this.servername, session)
})
.on('error', function (err) {
if (this.servername && err.code !== 'UND_ERR_INFO') {
// TODO (fix): Only delete for session related errors.
cache.delete(this.servername)
sessionCache.delete(this.servername)
}
})
} else {
socket = net.connect({
...this.opts,
...options,
port: port || 80,
host: hostname
})
}

const timeout = this.timeout
? setTimeout(onConnectTimeout, this.timeout, socket)
const timeoutId = timeout
? setTimeout(onConnectTimeout, timeout, socket)
: null

socket
.setNoDelay(true)
.once(protocol === 'https:' ? 'secureConnect' : 'connect', function () {
clearTimeout(timeout)
clearTimeout(timeoutId)

if (callback) {
const cb = callback
Expand All @@ -88,7 +83,7 @@ class Connector {
}
})
.on('error', function (err) {
clearTimeout(timeout)
clearTimeout(timeoutId)

if (callback) {
const cb = callback
Expand All @@ -105,6 +100,4 @@ function onConnectTimeout (socket) {
util.destroy(socket, new ConnectTimeoutError())
}

module.exports = (opts) => {
return Connector.prototype.connect.bind(new Connector(opts))
}
module.exports = buildConnector
Loading

0 comments on commit 11c2db1

Please sign in to comment.