Skip to content

Commit 916d85c

Browse files
authored
Add WebSocket host constraints (#332)
1 parent 2229d76 commit 916d85c

File tree

3 files changed

+164
-98
lines changed

3 files changed

+164
-98
lines changed

README.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,6 @@ Note that if property `wsUpstream` not specified then proxy will try to connect
219219

220220
The options passed to [`new ws.Server()`](https://github.com/websockets/ws/blob/HEAD/doc/ws.md#class-websocketserver).
221221

222-
In case multiple websocket proxies are attached to the same HTTP server at different paths.
223-
In this case, only the first `wsServerOptions` is applied.
224-
225222
### `wsClientOptions`
226223

227224
The options passed to the [`WebSocket` constructor](https://github.com/websockets/ws/blob/HEAD/doc/ws.md#class-websocket) for outgoing websockets.

index.js

Lines changed: 64 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const httpMethods = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT', 'OPTIONS']
1010
const urlPattern = /^https?:\/\//
1111
const kWs = Symbol('ws')
1212
const kWsHead = Symbol('wsHead')
13+
const kWsUpgradeListener = Symbol('wsUpgradeListener')
1314

1415
function liftErrorCode (code) {
1516
/* istanbul ignore next */
@@ -74,32 +75,46 @@ function proxyWebSockets (source, target) {
7475
target.on('unexpected-response', () => close(1011, 'unexpected response'))
7576
}
7677

78+
function handleUpgrade (fastify, rawRequest, socket, head) {
79+
// Save a reference to the socket and then dispatch the request through the normal fastify router so that it will invoke hooks and then eventually a route handler that might upgrade the socket.
80+
rawRequest[kWs] = socket
81+
rawRequest[kWsHead] = head
82+
83+
const rawResponse = new ServerResponse(rawRequest)
84+
rawResponse.assignSocket(socket)
85+
fastify.routing(rawRequest, rawResponse)
86+
87+
rawResponse.on('finish', () => {
88+
socket.destroy()
89+
})
90+
}
91+
7792
class WebSocketProxy {
78-
constructor (fastify, wsServerOptions) {
93+
constructor (fastify, { wsServerOptions, wsClientOptions, upstream, wsUpstream, replyOptions: { getUpstream } = {} }) {
7994
this.logger = fastify.log
95+
this.wsClientOptions = {
96+
rewriteRequestHeaders: defaultWsHeadersRewrite,
97+
headers: {},
98+
...wsClientOptions
99+
}
100+
this.upstream = convertUrlToWebSocket(upstream)
101+
this.wsUpstream = wsUpstream ? convertUrlToWebSocket(wsUpstream) : ''
102+
this.getUpstream = getUpstream
80103

81104
const wss = new WebSocket.Server({
82105
noServer: true,
83106
...wsServerOptions
84107
})
85108

86-
fastify.server.on('upgrade', (rawRequest, socket, head) => {
87-
// Save a reference to the socket and then dispatch the request through the normal fastify router so that it will invoke hooks and then eventually a route handler that might upgrade the socket.
88-
rawRequest[kWs] = socket
89-
rawRequest[kWsHead] = head
90-
91-
const rawResponse = new ServerResponse(rawRequest)
92-
rawResponse.assignSocket(socket)
93-
fastify.routing(rawRequest, rawResponse)
94-
95-
rawResponse.on('finish', () => {
96-
socket.destroy()
97-
})
98-
})
109+
if (!fastify.server[kWsUpgradeListener]) {
110+
fastify.server[kWsUpgradeListener] = (rawRequest, socket, head) =>
111+
handleUpgrade(fastify, rawRequest, socket, head)
112+
fastify.server.on('upgrade', fastify.server[kWsUpgradeListener])
113+
}
99114

100-
this.handleUpgrade = (request, cb) => {
115+
this.handleUpgrade = (request, dest, cb) => {
101116
wss.handleUpgrade(request.raw, request.raw[kWs], request.raw[kWsHead], (socket) => {
102-
this.handleConnection(socket, request)
117+
this.handleConnection(socket, request, dest)
103118
cb()
104119
})
105120
}
@@ -134,53 +149,41 @@ class WebSocketProxy {
134149
this.prefixList = []
135150
}
136151

137-
addUpstream (prefix, rewritePrefix, upstream, wsUpstream, wsClientOptions) {
138-
this.prefixList.push({
139-
prefix: new URL(prefix, 'ws://127.0.0.1').pathname,
140-
rewritePrefix,
141-
upstream: convertUrlToWebSocket(upstream),
142-
wsUpstream: wsUpstream ? convertUrlToWebSocket(wsUpstream) : '',
143-
wsClientOptions
144-
})
152+
findUpstream (request, dest) {
153+
const search = new URL(request.url, 'ws://127.0.0.1').search
145154

146-
// sort by decreasing prefix length, so that findUpstreamUrl() does longest prefix match
147-
this.prefixList.sort((a, b) => b.prefix.length - a.prefix.length)
148-
}
149-
150-
findUpstream (request) {
151-
const source = new URL(request.url, 'ws://127.0.0.1')
152-
153-
for (const { prefix, rewritePrefix, upstream, wsUpstream, wsClientOptions } of this.prefixList) {
154-
if (wsUpstream) {
155-
const target = new URL(wsUpstream)
156-
target.search = source.search
157-
return { target, wsClientOptions }
158-
}
155+
if (typeof this.wsUpstream === 'string' && this.wsUpstream !== '') {
156+
const target = new URL(this.wsUpstream)
157+
target.search = search
158+
return target
159+
}
159160

160-
if (source.pathname.startsWith(prefix)) {
161-
const target = new URL(source.pathname.replace(prefix, rewritePrefix), upstream)
162-
target.search = source.search
163-
return { target, wsClientOptions }
164-
}
161+
if (typeof this.upstream === 'string' && this.upstream !== '') {
162+
const target = new URL(dest, this.upstream)
163+
target.search = search
164+
return target
165165
}
166166

167+
const upstream = this.getUpstream(request, '')
168+
const target = new URL(dest, upstream)
167169
/* istanbul ignore next */
168-
throw new Error(`no upstream found for ${request.url}. this should not happened. Please report to https://github.com/fastify/fastify-http-proxy`)
170+
target.protocol = upstream.indexOf('http:') === 0 ? 'ws:' : 'wss'
171+
target.search = search
172+
return target
169173
}
170174

171-
handleConnection (source, request) {
172-
const upstream = this.findUpstream(request)
173-
const { target: url, wsClientOptions } = upstream
174-
const rewriteRequestHeaders = wsClientOptions?.rewriteRequestHeaders || defaultWsHeadersRewrite
175-
const headersToRewrite = wsClientOptions?.headers || {}
175+
handleConnection (source, request, dest) {
176+
const url = this.findUpstream(request, dest)
177+
const rewriteRequestHeaders = this.wsClientOptions.rewriteRequestHeaders
178+
const headersToRewrite = this.wsClientOptions.headers
176179

177180
const subprotocols = []
178181
if (source.protocol) {
179182
subprotocols.push(source.protocol)
180183
}
181184

182185
const headers = rewriteRequestHeaders(headersToRewrite, request)
183-
const optionsWs = { ...(wsClientOptions || {}), headers }
186+
const optionsWs = { ...this.wsClientOptions, headers }
184187

185188
const target = new WebSocket(url, subprotocols, optionsWs)
186189
this.logger.debug({ url: url.href }, 'proxy websocket')
@@ -195,41 +198,6 @@ function defaultWsHeadersRewrite (headers, request) {
195198
return { ...headers }
196199
}
197200

198-
const httpWss = new WeakMap() // http.Server => WebSocketProxy
199-
200-
function setupWebSocketProxy (fastify, options, rewritePrefix) {
201-
let wsProxy = httpWss.get(fastify.server)
202-
if (!wsProxy) {
203-
wsProxy = new WebSocketProxy(fastify, options.wsServerOptions)
204-
httpWss.set(fastify.server, wsProxy)
205-
}
206-
207-
if (
208-
(typeof options.wsUpstream === 'string' && options.wsUpstream !== '') ||
209-
(typeof options.upstream === 'string' && options.upstream !== '')
210-
) {
211-
wsProxy.addUpstream(
212-
fastify.prefix,
213-
rewritePrefix,
214-
options.upstream,
215-
options.wsUpstream,
216-
options.wsClientOptions
217-
)
218-
// The else block is validate earlier in the code
219-
} else {
220-
wsProxy.findUpstream = function (request) {
221-
const source = new URL(request.url, 'ws://127.0.0.1')
222-
const upstream = options.replyOptions.getUpstream(request, '')
223-
const target = new URL(source.pathname, upstream)
224-
/* istanbul ignore next */
225-
target.protocol = upstream.indexOf('http:') === 0 ? 'ws:' : 'wss'
226-
target.search = source.search
227-
return { target, wsClientOptions: options.wsClientOptions }
228-
}
229-
}
230-
return wsProxy
231-
}
232-
233201
function generateRewritePrefix (prefix, opts) {
234202
let rewritePrefix = opts.rewritePrefix || (opts.upstream ? new URL(opts.upstream).pathname : '/')
235203

@@ -303,7 +271,7 @@ async function fastifyHttpProxy (fastify, opts) {
303271
let wsProxy
304272

305273
if (opts.websocket) {
306-
wsProxy = setupWebSocketProxy(fastify, opts, rewritePrefix)
274+
wsProxy = new WebSocketProxy(fastify, opts)
307275
}
308276

309277
function extractUrlComponents (urlString) {
@@ -321,16 +289,6 @@ async function fastifyHttpProxy (fastify, opts) {
321289
}
322290

323291
function handler (request, reply) {
324-
if (request.raw[kWs]) {
325-
reply.hijack()
326-
try {
327-
wsProxy.handleUpgrade(request, noop)
328-
} catch (err) {
329-
/* istanbul ignore next */
330-
request.log.warn({ err }, 'websocket proxy error')
331-
}
332-
return
333-
}
334292
const { path, queryParams } = extractUrlComponents(request.url)
335293
let dest = path
336294

@@ -350,6 +308,17 @@ async function fastifyHttpProxy (fastify, opts) {
350308
} else {
351309
dest = dest.replace(this.prefix, rewritePrefix)
352310
}
311+
312+
if (request.raw[kWs]) {
313+
reply.hijack()
314+
try {
315+
wsProxy.handleUpgrade(request, dest || '/', noop)
316+
} catch (err) {
317+
/* istanbul ignore next */
318+
request.log.warn({ err }, 'websocket proxy error')
319+
}
320+
return
321+
}
353322
reply.from(dest || '/', replyOpts)
354323
}
355324
}

test/websocket.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,3 +474,103 @@ test('Proxy websocket with custom upstream url', async (t) => {
474474
server.close()
475475
])
476476
})
477+
478+
test('multiple websocket upstreams with host constraints', async (t) => {
479+
t.plan(4)
480+
481+
const server = Fastify()
482+
483+
for (const name of ['foo', 'bar']) {
484+
const origin = createServer()
485+
const wss = new WebSocket.Server({ server: origin })
486+
t.teardown(wss.close.bind(wss))
487+
t.teardown(origin.close.bind(origin))
488+
489+
wss.once('connection', (ws) => {
490+
ws.once('message', message => {
491+
t.equal(message.toString(), `hello ${name}`)
492+
// echo
493+
ws.send(message)
494+
})
495+
})
496+
497+
await promisify(origin.listen.bind(origin))({ port: 0, host: '127.0.0.1' })
498+
server.register(proxy, {
499+
upstream: `ws://127.0.0.1:${origin.address().port}`,
500+
websocket: true,
501+
constraints: { host: name }
502+
})
503+
}
504+
505+
await server.listen({ port: 0, host: '127.0.0.1' })
506+
t.teardown(server.close.bind(server))
507+
508+
const wsClients = []
509+
for (const name of ['foo', 'bar']) {
510+
const ws = new WebSocket(`ws://127.0.0.1:${server.server.address().port}`, { headers: { host: name } })
511+
await once(ws, 'open')
512+
ws.send(`hello ${name}`)
513+
const [reply] = await once(ws, 'message')
514+
t.equal(reply.toString(), `hello ${name}`)
515+
wsClients.push(ws)
516+
}
517+
518+
await Promise.all([
519+
...wsClients.map(ws => once(ws, 'close')),
520+
server.close()
521+
])
522+
})
523+
524+
test('multiple websocket upstreams with distinct server options', async (t) => {
525+
t.plan(4)
526+
527+
const server = Fastify()
528+
529+
for (const name of ['foo', 'bar']) {
530+
const origin = createServer()
531+
const wss = new WebSocket.Server({ server: origin })
532+
t.teardown(wss.close.bind(wss))
533+
t.teardown(origin.close.bind(origin))
534+
535+
wss.once('connection', (ws, req) => {
536+
t.equal(req.url, `/?q=${name}`)
537+
ws.once('message', message => {
538+
// echo
539+
ws.send(message)
540+
})
541+
})
542+
543+
await promisify(origin.listen.bind(origin))({ port: 0, host: '127.0.0.1' })
544+
server.register(proxy, {
545+
upstream: `ws://127.0.0.1:${origin.address().port}`,
546+
websocket: true,
547+
constraints: { host: name },
548+
wsServerOptions: {
549+
verifyClient: ({ req }) => {
550+
t.equal(req.url, `/?q=${name}`)
551+
return true
552+
}
553+
}
554+
})
555+
}
556+
557+
await server.listen({ port: 0, host: '127.0.0.1' })
558+
t.teardown(server.close.bind(server))
559+
560+
const wsClients = []
561+
for (const name of ['foo', 'bar']) {
562+
const ws = new WebSocket(
563+
`ws://127.0.0.1:${server.server.address().port}/?q=${name}`,
564+
{ headers: { host: name } }
565+
)
566+
await once(ws, 'open')
567+
ws.send(`hello ${name}`)
568+
await once(ws, 'message')
569+
wsClients.push(ws)
570+
}
571+
572+
await Promise.all([
573+
...wsClients.map(ws => once(ws, 'close')),
574+
server.close()
575+
])
576+
})

0 commit comments

Comments
 (0)