Files
xen-orchestra/@xen-orchestra/mixins/HttpProxy.mjs
2022-05-09 14:46:25 +02:00

145 lines
4.0 KiB
JavaScript

import { createLogger } from '@xen-orchestra/log'
import { EventListenersManager } from '@vates/event-listeners-manager'
import { pipeline } from 'stream'
import { ServerResponse, request } from 'http'
import assert from 'assert'
import fromCallback from 'promise-toolbox/fromCallback'
import fromEvent from 'promise-toolbox/fromEvent'
import net from 'net'
import { parseBasicAuth } from './_parseBasicAuth.mjs'
const { debug, warn } = createLogger('xo:mixins:HttpProxy')
const IGNORED_HEADERS = new Set([
// https://datatracker.ietf.org/doc/html/rfc2616#section-13.5.1
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailers',
'transfer-encoding',
'upgrade',
// don't forward original host
'host',
])
export default class HttpProxy {
#app
constructor(app, { httpServer }) {
// don't setup the proxy if httpServer is not present
//
// that can happen when the app is instanciated in another context like xo-server-recover-account
if (httpServer === undefined) {
return
}
this.#app = app
const events = new EventListenersManager(httpServer)
app.config.watch('http.proxy.enabled', (enabled = false) => {
events.removeAll()
if (enabled) {
events.add('connect', this.#handleConnect.bind(this)).add('request', this.#handleRequest.bind(this))
}
})
}
async #handleAuthentication(req, res, next) {
const auth = parseBasicAuth(req.headers['proxy-authorization'])
let authenticated = false
if (auth !== undefined) {
const app = this.#app
if (app.authenticateUser !== undefined) {
// xo-server
try {
const { user } = await app.authenticateUser(auth)
authenticated = user.permission === 'admin'
} catch (error) {}
} else {
// xo-proxy
authenticated = (await app.authentication.findProfile(auth)) !== undefined
}
}
if (authenticated) {
return next()
}
// https://datatracker.ietf.org/doc/html/rfc7235#section-3.2
res.statusCode = '407'
res.setHeader('proxy-authenticate', 'Basic realm="proxy"')
return res.end('Proxy Authentication Required')
}
// https://nodejs.org/api/http.html#event-connect
async #handleConnect(req, clientSocket, head) {
const { url } = req
debug('CONNECT proxy', { url })
// https://github.com/TooTallNate/proxy/blob/d677ef31fd4ca9f7e868b34c18b9cb22b0ff69da/proxy.js#L391-L398
const res = new ServerResponse(req)
res.assignSocket(clientSocket)
try {
await this.#handleAuthentication(req, res, async () => {
const { port, hostname } = new URL('http://' + req.url)
const serverSocket = net.connect(port || 80, hostname)
await fromEvent(serverSocket, 'connect')
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
serverSocket.write(head)
fromCallback(pipeline, clientSocket, serverSocket).catch(warn)
fromCallback(pipeline, serverSocket, clientSocket).catch(warn)
})
} catch (error) {
warn(error)
clientSocket.end()
}
}
async #handleRequest(req, res) {
const { url } = req
if (url.startsWith('/')) {
// not a proxy request
return
}
debug('HTTP proxy', { url })
try {
assert(url.startsWith('http:'), 'HTTPS should use connect')
await this.#handleAuthentication(req, res, async () => {
const { headers } = req
const pHeaders = {}
for (const key of Object.keys(headers)) {
if (!IGNORED_HEADERS.has(key)) {
pHeaders[key] = headers[key]
}
}
const pReq = request(url, { headers: pHeaders, method: req.method })
fromCallback(pipeline, req, pReq).catch(warn)
const pRes = await fromEvent(pReq, 'response')
res.writeHead(pRes.statusCode, pRes.statusMessage, pRes.headers)
await fromCallback(pipeline, pRes, res)
})
} catch (error) {
res.statusCode = 500
res.end('Internal Server Error')
warn(error)
}
}
}