From 7ff4531f043e817d2b7167c26dc8478485fe8363 Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Sat, 23 Apr 2022 23:11:38 +0200 Subject: [PATCH 01/17] feat(mixins/HttpProxy): HTTP/HTTP CONNECT proxy --- @xen-orchestra/mixins/HttpProxy.js | 109 +++++++++++++++++++++++ @xen-orchestra/mixins/_parseBasicAuth.js | 29 ++++++ @xen-orchestra/mixins/package.json | 3 +- @xen-orchestra/proxy/app/index.mjs | 5 +- CHANGELOG.unreleased.md | 3 +- packages/xo-server/src/index.mjs | 8 +- packages/xo-server/src/xo.mjs | 3 +- 7 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 @xen-orchestra/mixins/HttpProxy.js create mode 100644 @xen-orchestra/mixins/_parseBasicAuth.js diff --git a/@xen-orchestra/mixins/HttpProxy.js b/@xen-orchestra/mixins/HttpProxy.js new file mode 100644 index 00000000000..cfcb4b748fc --- /dev/null +++ b/@xen-orchestra/mixins/HttpProxy.js @@ -0,0 +1,109 @@ +'use strict' + +const { debug, warn } = require('@xen-orchestra/log').createLogger('xo:mixins:HttpProxy') +const { pipeline } = require('stream') +const { ServerResponse, request } = require('http') +const assert = require('assert') +const fromCallback = require('promise-toolbox/fromCallback') +const net = require('net') + +const { parseBasicAuth } = require('./_parseBasicAuth.js') +const fromEvent = require('promise-toolbox/fromEvent') + +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', +]) + +// https://nodejs.org/api/http.html#event-connect +module.exports = class HttpProxy { + #app + + constructor(app, { httpServer }) { + this.#app = app + + httpServer.on('connect', this.#handleConnect.bind(this)).on('request', this.#handleRequest.bind(this)) + } + + async #handleAuthentication(req, res, next) { + const authenticationToken = this.#app.config('http.proxies.authenticationToken') + + if (authenticationToken !== undefined) { + const auth = parseBasicAuth(req.headers['proxy-authorization']) + + // https://datatracker.ietf.org/doc/html/rfc7235#section-3.2 + if (auth === undefined || !(auth.user === authenticationToken && auth.password === '')) { + res.statusCode = '407' + res.setHeader('proxy-authenticate', 'Basic realm="proxy"') + return res.end('Proxy Authentication Required') + } + } + return next() + } + + 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) + + this.#handleAuthentication(req, res, () => { + const { port, hostname } = new URL('http://' + req.url) + const serverSocket = net.connect(port || 80, hostname, function () { + 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(warn) + } + + 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) + } + } +} diff --git a/@xen-orchestra/mixins/_parseBasicAuth.js b/@xen-orchestra/mixins/_parseBasicAuth.js new file mode 100644 index 00000000000..2f9fb8b57c6 --- /dev/null +++ b/@xen-orchestra/mixins/_parseBasicAuth.js @@ -0,0 +1,29 @@ +'use strict' + +const RE = /^\s*basic\s+(.+?)\s*$/i + +exports.parseBasicAuth = function parseBasicAuth(header) { + if (header === undefined) { + return + } + + const matches = RE.exec(header) + if (matches === null) { + return + } + + const credentials = Buffer.from(matches[1], 'base64').toString() + + // https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.1 + let user, password + const i = credentials.indexOf(':') + if (i === -1) { + user = credentials + password = '' + } else { + user = credentials.slice(0, i) + password = credentials.slice(i + 1) + } + + return { user, password } +} diff --git a/@xen-orchestra/mixins/package.json b/@xen-orchestra/mixins/package.json index 95e7b63fd42..e51504dd8f7 100644 --- a/@xen-orchestra/mixins/package.json +++ b/@xen-orchestra/mixins/package.json @@ -23,7 +23,8 @@ "@xen-orchestra/emit-async": "^0.1.0", "@xen-orchestra/log": "^0.3.0", "app-conf": "^2.1.0", - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "promise-toolbox": "^0.21.0" }, "scripts": { "postversion": "npm publish --access public" diff --git a/@xen-orchestra/proxy/app/index.mjs b/@xen-orchestra/proxy/app/index.mjs index 2e2e3ea7f7d..ac46c8998e9 100644 --- a/@xen-orchestra/proxy/app/index.mjs +++ b/@xen-orchestra/proxy/app/index.mjs @@ -1,5 +1,6 @@ import Config from '@xen-orchestra/mixins/Config.js' import Hooks from '@xen-orchestra/mixins/Hooks.js' +import HttpProxy from '@xen-orchestra/mixins/HttpProxy.js' import mixin from '@xen-orchestra/mixin' import { createDebounceResource } from '@vates/disposable/debounceResource.js' @@ -13,7 +14,9 @@ import ReverseProxy from './mixins/reverseProxy.mjs' export default class App { constructor(opts) { - mixin(this, { Api, Appliance, Authentication, Backups, Config, Hooks, Logs, Remotes, ReverseProxy }, [opts]) + mixin(this, { Api, Appliance, Authentication, Backups, Config, Hooks, HttpProxy, Logs, Remotes, ReverseProxy }, [ + opts, + ]) const debounceResource = createDebounceResource() this.config.watchDuration('resourceCacheDelay', delay => { diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 9700d748dc8..0f01d764a32 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -40,9 +40,10 @@ - @vates/cached-dns.lookup major - @vates/event-listeners-manager major - xen-api minor +- @xen-orchestra/mixins minor - xo-vmdk-to-vhd minor - @xen-orchestra/fs patch -- @xen-orchestra/proxy patch +- @xen-orchestra/proxy minor - @xen-orchestra/backups patch - xo-server minor - xo-web minor diff --git a/packages/xo-server/src/index.mjs b/packages/xo-server/src/index.mjs index ba6ebb357b0..89d24f7ec02 100644 --- a/packages/xo-server/src/index.mjs +++ b/packages/xo-server/src/index.mjs @@ -758,7 +758,12 @@ export default async function main(args) { } // Attaches express to the web server. - webServer.on('request', express) + webServer.on('request', (req, res) => { + // don't handle proxy requests + if (req.url.startsWith('/')) { + return express(req, res) + } + }) webServer.on('upgrade', (req, socket, head) => { express.emit('upgrade', req, socket, head) }) @@ -772,6 +777,7 @@ export default async function main(args) { appVersion: APP_VERSION, config, express, + httpServer: webServer, safeMode, }) diff --git a/packages/xo-server/src/xo.mjs b/packages/xo-server/src/xo.mjs index 3813d3af8b5..3e93faa9f2e 100644 --- a/packages/xo-server/src/xo.mjs +++ b/packages/xo-server/src/xo.mjs @@ -1,6 +1,7 @@ import Config from '@xen-orchestra/mixins/Config.js' import forEach from 'lodash/forEach.js' import Hooks from '@xen-orchestra/mixins/Hooks.js' +import HttpProxy from '@xen-orchestra/mixins/HttpProxy.js' import includes from 'lodash/includes.js' import isEmpty from 'lodash/isEmpty.js' import iteratee from 'lodash/iteratee.js' @@ -28,7 +29,7 @@ export default class Xo extends EventEmitter { constructor(opts) { super() - mixin(this, { Config, Hooks }, [opts]) + mixin(this, { Config, Hooks, HttpProxy }, [opts]) // a lot of mixins adds listener for start/stop/… events this.hooks.setMaxListeners(0) From 8381376f4919dd8b1adc37e0cde87d3ae6d0e4e7 Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Sun, 24 Apr 2022 14:27:39 +0200 Subject: [PATCH 02/17] fix --- @xen-orchestra/mixins/HttpProxy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/@xen-orchestra/mixins/HttpProxy.js b/@xen-orchestra/mixins/HttpProxy.js index cfcb4b748fc..de82d62252f 100644 --- a/@xen-orchestra/mixins/HttpProxy.js +++ b/@xen-orchestra/mixins/HttpProxy.js @@ -36,7 +36,7 @@ module.exports = class HttpProxy { } async #handleAuthentication(req, res, next) { - const authenticationToken = this.#app.config('http.proxies.authenticationToken') + const authenticationToken = this.#app.config.get('http.proxies.authenticationToken') if (authenticationToken !== undefined) { const auth = parseBasicAuth(req.headers['proxy-authorization']) From a1104d22396dd9bb29457285d01e9f1dd3245283 Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Sun, 24 Apr 2022 15:26:13 +0200 Subject: [PATCH 03/17] fix --- @xen-orchestra/mixins/HttpProxy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/@xen-orchestra/mixins/HttpProxy.js b/@xen-orchestra/mixins/HttpProxy.js index de82d62252f..8fdeb6c95f4 100644 --- a/@xen-orchestra/mixins/HttpProxy.js +++ b/@xen-orchestra/mixins/HttpProxy.js @@ -36,7 +36,7 @@ module.exports = class HttpProxy { } async #handleAuthentication(req, res, next) { - const authenticationToken = this.#app.config.get('http.proxies.authenticationToken') + const authenticationToken = this.#app.config.getOptional('http.proxies.authenticationToken') if (authenticationToken !== undefined) { const auth = parseBasicAuth(req.headers['proxy-authorization']) From e03f8100d4ca54fb86d8b48a701d818771261db4 Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Sun, 24 Apr 2022 18:38:37 +0200 Subject: [PATCH 04/17] various updates --- @xen-orchestra/mixins/HttpProxy.js | 13 ++++++++++--- @xen-orchestra/mixins/package.json | 1 + 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/@xen-orchestra/mixins/HttpProxy.js b/@xen-orchestra/mixins/HttpProxy.js index 8fdeb6c95f4..50f8ab707db 100644 --- a/@xen-orchestra/mixins/HttpProxy.js +++ b/@xen-orchestra/mixins/HttpProxy.js @@ -1,14 +1,15 @@ 'use strict' const { debug, warn } = require('@xen-orchestra/log').createLogger('xo:mixins:HttpProxy') +const { EventListenersManager } = require('@vates/event-listeners-manager') const { pipeline } = require('stream') const { ServerResponse, request } = require('http') const assert = require('assert') const fromCallback = require('promise-toolbox/fromCallback') +const fromEvent = require('promise-toolbox/fromEvent') const net = require('net') const { parseBasicAuth } = require('./_parseBasicAuth.js') -const fromEvent = require('promise-toolbox/fromEvent') const IGNORED_HEADERS = new Set([ // https://datatracker.ietf.org/doc/html/rfc2616#section-13.5.1 @@ -32,11 +33,17 @@ module.exports = class HttpProxy { constructor(app, { httpServer }) { this.#app = app - httpServer.on('connect', this.#handleConnect.bind(this)).on('request', this.#handleRequest.bind(this)) + const events = new EventListenersManager(httpServer) + app.config.watch('http.proxy.enabled', (enabled = false) => { + events.removeAll() + if (enabled) { + httpServer.on('connect', this.#handleConnect.bind(this)).on('request', this.#handleRequest.bind(this)) + } + }) } async #handleAuthentication(req, res, next) { - const authenticationToken = this.#app.config.getOptional('http.proxies.authenticationToken') + const authenticationToken = this.#app.config.getOptional('http.proxy.authenticationToken') if (authenticationToken !== undefined) { const auth = parseBasicAuth(req.headers['proxy-authorization']) diff --git a/@xen-orchestra/mixins/package.json b/@xen-orchestra/mixins/package.json index e51504dd8f7..7253f778a33 100644 --- a/@xen-orchestra/mixins/package.json +++ b/@xen-orchestra/mixins/package.json @@ -19,6 +19,7 @@ "node": ">=12" }, "dependencies": { + "@vates/event-listeners-manager": "^0.0.0", "@vates/parse-duration": "^0.1.1", "@xen-orchestra/emit-async": "^0.1.0", "@xen-orchestra/log": "^0.3.0", From 04fab94d6af59477da2a35ddaab93ffb118bb9db Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Mon, 25 Apr 2022 12:17:26 +0200 Subject: [PATCH 05/17] fix --- @xen-orchestra/mixins/HttpProxy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/@xen-orchestra/mixins/HttpProxy.js b/@xen-orchestra/mixins/HttpProxy.js index 50f8ab707db..52d67d92408 100644 --- a/@xen-orchestra/mixins/HttpProxy.js +++ b/@xen-orchestra/mixins/HttpProxy.js @@ -37,7 +37,7 @@ module.exports = class HttpProxy { app.config.watch('http.proxy.enabled', (enabled = false) => { events.removeAll() if (enabled) { - httpServer.on('connect', this.#handleConnect.bind(this)).on('request', this.#handleRequest.bind(this)) + events.add('connect', this.#handleConnect.bind(this)).add('request', this.#handleRequest.bind(this)) } }) } From 06e7f994bc9d845d7408270b9860509e0d051b48 Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Mon, 25 Apr 2022 13:55:22 +0200 Subject: [PATCH 06/17] various changes --- @xen-orchestra/mixins/HttpProxy.js | 19 +++++++++++++------ @xen-orchestra/mixins/_parseBasicAuth.js | 16 ++++++++-------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/@xen-orchestra/mixins/HttpProxy.js b/@xen-orchestra/mixins/HttpProxy.js index 52d67d92408..643fd274066 100644 --- a/@xen-orchestra/mixins/HttpProxy.js +++ b/@xen-orchestra/mixins/HttpProxy.js @@ -48,8 +48,8 @@ module.exports = class HttpProxy { if (authenticationToken !== undefined) { const auth = parseBasicAuth(req.headers['proxy-authorization']) - // https://datatracker.ietf.org/doc/html/rfc7235#section-3.2 - if (auth === undefined || !(auth.user === authenticationToken && auth.password === '')) { + if (auth === undefined || !(auth.token === authenticationToken)) { + // 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') @@ -67,15 +67,22 @@ module.exports = class HttpProxy { const res = new ServerResponse(req) res.assignSocket(clientSocket) - this.#handleAuthentication(req, res, () => { - const { port, hostname } = new URL('http://' + req.url) - const serverSocket = net.connect(port || 80, hostname, function () { + 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(warn) + } catch (error) { + warn(error) + clientSocket.end() + } } async #handleRequest(req, res) { diff --git a/@xen-orchestra/mixins/_parseBasicAuth.js b/@xen-orchestra/mixins/_parseBasicAuth.js index 2f9fb8b57c6..4b74068bb29 100644 --- a/@xen-orchestra/mixins/_parseBasicAuth.js +++ b/@xen-orchestra/mixins/_parseBasicAuth.js @@ -12,18 +12,18 @@ exports.parseBasicAuth = function parseBasicAuth(header) { return } - const credentials = Buffer.from(matches[1], 'base64').toString() + let credentials = Buffer.from(matches[1], 'base64').toString() - // https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.1 - let user, password const i = credentials.indexOf(':') if (i === -1) { - user = credentials - password = '' + credentials = { token: credentials } } else { - user = credentials.slice(0, i) - password = credentials.slice(i + 1) + // https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.1 + credentials = { + username: credentials.slice(0, i), + password: credentials.slice(i + 1), + } } - return { user, password } + return credentials } From b07255d6e863e3dac2e01700776a17db09c95207 Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Mon, 25 Apr 2022 15:04:40 +0200 Subject: [PATCH 07/17] various changes --- @xen-orchestra/mixins/HttpProxy.js | 6 +++--- @xen-orchestra/proxy/app/mixins/api.mjs | 2 +- @xen-orchestra/proxy/app/mixins/authentication.mjs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/@xen-orchestra/mixins/HttpProxy.js b/@xen-orchestra/mixins/HttpProxy.js index 643fd274066..204109df999 100644 --- a/@xen-orchestra/mixins/HttpProxy.js +++ b/@xen-orchestra/mixins/HttpProxy.js @@ -43,12 +43,12 @@ module.exports = class HttpProxy { } async #handleAuthentication(req, res, next) { - const authenticationToken = this.#app.config.getOptional('http.proxy.authenticationToken') + const { authentication } = this.#app - if (authenticationToken !== undefined) { + if (authentication !== undefined) { const auth = parseBasicAuth(req.headers['proxy-authorization']) - if (auth === undefined || !(auth.token === authenticationToken)) { + if (auth === undefined || !(await authentication.findProfile(auth))) { // https://datatracker.ietf.org/doc/html/rfc7235#section-3.2 res.statusCode = '407' res.setHeader('proxy-authenticate', 'Basic realm="proxy"') diff --git a/@xen-orchestra/proxy/app/mixins/api.mjs b/@xen-orchestra/proxy/app/mixins/api.mjs index c5e1a88fa41..f29bf3521ba 100644 --- a/@xen-orchestra/proxy/app/mixins/api.mjs +++ b/@xen-orchestra/proxy/app/mixins/api.mjs @@ -52,7 +52,7 @@ export default class Api { ctx.req.setTimeout(0) const profile = await app.authentication.findProfile({ - authenticationToken: ctx.cookies.get('authenticationToken'), + token: ctx.cookies.get('authenticationToken'), }) if (profile === undefined) { ctx.status = 401 diff --git a/@xen-orchestra/proxy/app/mixins/authentication.mjs b/@xen-orchestra/proxy/app/mixins/authentication.mjs index 452a357ed53..cd40e1f4342 100644 --- a/@xen-orchestra/proxy/app/mixins/authentication.mjs +++ b/@xen-orchestra/proxy/app/mixins/authentication.mjs @@ -52,7 +52,7 @@ export default class Authentication { } async findProfile(credentials) { - if (credentials?.authenticationToken === this.#token) { + if (credentials?.token === this.#token) { return new Profile() } } From 2af0785fb9c0c8058aa2d43d115e334610560fd1 Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Mon, 25 Apr 2022 16:40:37 +0200 Subject: [PATCH 08/17] feat(xen-api): support unauthorized cert for HTTPS proxies --- packages/xen-api/src/_parseUrl.js | 14 +++++++++++--- packages/xen-api/src/index.js | 6 +++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/xen-api/src/_parseUrl.js b/packages/xen-api/src/_parseUrl.js index b7eee32ad68..d6e2487af1c 100644 --- a/packages/xen-api/src/_parseUrl.js +++ b/packages/xen-api/src/_parseUrl.js @@ -1,4 +1,4 @@ -const URL_RE = /^(?:(https?:)\/*)?(?:([^:]+):([^@]+)@)?(?:\[([^\]]+)\]|([^:/]+))(?::([0-9]+))?(\/[^?#]*)?$/ +const URL_RE = /^(?:(https?:)\/*)?(?:(([^:]+):([^@]+))@)?(?:\[([^\]]+)\]|([^:/]+))(?::([0-9]+))?(\/[^?#]*)?$/ export default url => { const matches = URL_RE.exec(url) @@ -6,8 +6,16 @@ export default url => { throw new Error('invalid URL: ' + url) } - const [, protocol = 'https:', username, password, ipv6, hostname = ipv6, port, pathname = '/'] = matches - const parsedUrl = { protocol, hostname, port, pathname } + const [, protocol = 'https:', auth, username, password, ipv6, hostname = ipv6, port, pathname = '/'] = matches + const parsedUrl = { + protocol, + hostname, + port, + pathname, + + // compat with url.parse + auth, + } if (username !== undefined) { parsedUrl.username = decodeURIComponent(username) } diff --git a/packages/xen-api/src/index.js b/packages/xen-api/src/index.js index f54af8f68f4..a837262b34c 100644 --- a/packages/xen-api/src/index.js +++ b/packages/xen-api/src/index.js @@ -122,8 +122,12 @@ export class Xapi extends EventEmitter { } this._allowUnauthorized = opts.allowUnauthorized - const { httpProxy } = opts + let { httpProxy } = opts if (httpProxy !== undefined) { + if (httpProxy.startsWith('https:')) { + httpProxy = parseUrl(httpProxy) + httpProxy.rejectUnauthorized = !opts.allowUnauthorized + } this._httpAgent = new ProxyAgent(httpProxy) } this._setUrl(url) From a4f64507a82a22e95d715c0ae5e4a648c04f8689 Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Wed, 27 Apr 2022 10:22:21 +0200 Subject: [PATCH 09/17] xo-proxy & xo-server authentication --- @xen-orchestra/mixins/HttpProxy.js | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/@xen-orchestra/mixins/HttpProxy.js b/@xen-orchestra/mixins/HttpProxy.js index 204109df999..0a7b0a01848 100644 --- a/@xen-orchestra/mixins/HttpProxy.js +++ b/@xen-orchestra/mixins/HttpProxy.js @@ -43,18 +43,21 @@ module.exports = class HttpProxy { } async #handleAuthentication(req, res, next) { - const { authentication } = this.#app - - if (authentication !== undefined) { - const auth = parseBasicAuth(req.headers['proxy-authorization']) - - if (auth === undefined || !(await authentication.findProfile(auth))) { - // 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') - } + const auth = parseBasicAuth(req.headers['proxy-authorization']) + + const app = this.#app + if ( + auth === undefined || + !(await (app.authenticateUser !== undefined + ? app.authenticateUser(auth) // xo-server + : app.authentication.findProfile(auth))) // xo-proxy + ) { + // 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') } + return next() } From 2a6fbbedb17a9f4e6e5d566de4212e5051fa1cbf Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Wed, 27 Apr 2022 10:36:16 +0200 Subject: [PATCH 10/17] add documentation --- @xen-orchestra/mixins/docs/HttpProxy.md | 59 +++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 @xen-orchestra/mixins/docs/HttpProxy.md diff --git a/@xen-orchestra/mixins/docs/HttpProxy.md b/@xen-orchestra/mixins/docs/HttpProxy.md new file mode 100644 index 00000000000..dc3aa146000 --- /dev/null +++ b/@xen-orchestra/mixins/docs/HttpProxy.md @@ -0,0 +1,59 @@ +> This module provides an HTTP and HTTPS proxy for `xo-proxy` and `xo-server`. + +- [Set up](#set-up) +- [Usage](#usage) + - [`xo-proxy`](#xo-proxy) + - [`xo-server`](#xo-server) + +## Set up + +The proxy is disabled by default, to enable it, add the following lines to your config: + +```toml +[http.proxy] +enabled = true +``` + +## Usage + +For safety reaons, the proxy requires authentication to be used. + +### `xo-proxy` + +Use the authentication token: + +``` +$ cat ~/.config/xo-proxy/config.z-auto.json +{"authenticationToken":"J0BgKritQgPxoyZrBJ5ViafQfLk06YoyFwC3fmfO5wU"} +``` + +Proxy URL to use: + +``` +https://J0BgKritQgPxoyZrBJ5ViafQfLk06YoyFwC3fmfO5wU@xo-proxy.company.lan +``` + +### `xo-server` + +You can use your credentials: + +``` +https://user:password@xo.company.lan +``` + +Or create a dedicated token with `xo-cli`: + +``` +$ xo-cli --createToken xoa.company.lan admin@admin.net +Password: ******** +Successfully logged with admin@admin.net +Authentication token created + +DiYBFavJwf9GODZqQJs23eAx9eh3KlsRhBi8RcoX0KM +``` + +And use it in the URL: + +``` +https://DiYBFavJwf9GODZqQJs23eAx9eh3KlsRhBi8RcoX0KM@xo.company.lan +``` From 874c5345777d1a9db80822acfc89d207eebfcdcb Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Wed, 27 Apr 2022 10:42:27 +0200 Subject: [PATCH 11/17] changelog entry --- CHANGELOG.unreleased.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 0f01d764a32..aaa38816fa2 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -11,6 +11,7 @@ - [Backup] Add _Restore Health Check_: ensure a backup is viable by doing an automatic test restore (requires guest tools in the VM) [#6148](https://github.com/vatesfr/xen-orchestra/pull/6148) - [VM migrate] Allow to choose a private network for VIFs network (PR [#6200](https://github.com/vatesfr/xen-orchestra/pull/6200)) - [Proxy] Disable "Deploy proxy" button for source users (PR [#6199](https://github.com/vatesfr/xen-orchestra/pull/6199)) +- New HTTP/HTTPS proxy implemented in xo-proxy and xo-server, [see the documentation](https://github.com/vatesfr/xen-orchestra/blob/master/@xen-orchestra/mixins/docs/HttpProxy.md) (PR [#6201](https://github.com/vatesfr/xen-orchestra/pull/6201)) ### Bug fixes From 5803ce1e154684a882a3e7037e9a12e85a98fea2 Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Wed, 27 Apr 2022 11:58:54 +0200 Subject: [PATCH 12/17] restrict to admin users --- @xen-orchestra/mixins/HttpProxy.js | 35 ++++++++++++++++--------- @xen-orchestra/mixins/docs/HttpProxy.md | 2 ++ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/@xen-orchestra/mixins/HttpProxy.js b/@xen-orchestra/mixins/HttpProxy.js index 0a7b0a01848..f97f14f3b16 100644 --- a/@xen-orchestra/mixins/HttpProxy.js +++ b/@xen-orchestra/mixins/HttpProxy.js @@ -45,20 +45,31 @@ module.exports = class HttpProxy { async #handleAuthentication(req, res, next) { const auth = parseBasicAuth(req.headers['proxy-authorization']) - const app = this.#app - if ( - auth === undefined || - !(await (app.authenticateUser !== undefined - ? app.authenticateUser(auth) // xo-server - : app.authentication.findProfile(auth))) // xo-proxy - ) { - // 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') + 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() } - 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') } async #handleConnect(req, clientSocket, head) { diff --git a/@xen-orchestra/mixins/docs/HttpProxy.md b/@xen-orchestra/mixins/docs/HttpProxy.md index dc3aa146000..368e6ed2aa2 100644 --- a/@xen-orchestra/mixins/docs/HttpProxy.md +++ b/@xen-orchestra/mixins/docs/HttpProxy.md @@ -35,6 +35,8 @@ https://J0BgKritQgPxoyZrBJ5ViafQfLk06YoyFwC3fmfO5wU@xo-proxy.company.lan ### `xo-server` +> Only available for admin users. + You can use your credentials: ``` From 17948353e889f1011778c1bc7f10bf28dc86c532 Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Wed, 27 Apr 2022 11:59:07 +0200 Subject: [PATCH 13/17] document typical use cases --- @xen-orchestra/mixins/docs/HttpProxy.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/@xen-orchestra/mixins/docs/HttpProxy.md b/@xen-orchestra/mixins/docs/HttpProxy.md index 368e6ed2aa2..f2145545b1b 100644 --- a/@xen-orchestra/mixins/docs/HttpProxy.md +++ b/@xen-orchestra/mixins/docs/HttpProxy.md @@ -4,6 +4,9 @@ - [Usage](#usage) - [`xo-proxy`](#xo-proxy) - [`xo-server`](#xo-server) +- [Use cases](#use-cases) + - [Access hosts in a private network](#access-hosts-in-a-private-network) + - [Allow upgrading xo-proxy via xo-server](#allow-upgrading-xo-proxy-via-xo-server) ## Set up @@ -59,3 +62,13 @@ And use it in the URL: ``` https://DiYBFavJwf9GODZqQJs23eAx9eh3KlsRhBi8RcoX0KM@xo.company.lan ``` + +## Use cases + +### Access hosts in a private network + +To access hosts in a private network, deploy an XO Proxy in this network, expose its port 443 and use it as an HTTP proxy to connect to your servers in XO. + +### Allow upgrading xo-proxy via xo-server + +If your xo-proxy does not have direct Internet access, you can use xo-server as an HTTP proxy to make upgrades possible. From e698a89da7865a769b6b2a8bc5e39f2952011b50 Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Wed, 27 Apr 2022 13:41:03 +0200 Subject: [PATCH 14/17] Update @xen-orchestra/mixins/docs/HttpProxy.md Co-authored-by: Florent BEAUCHAMP --- @xen-orchestra/mixins/docs/HttpProxy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/@xen-orchestra/mixins/docs/HttpProxy.md b/@xen-orchestra/mixins/docs/HttpProxy.md index f2145545b1b..0337959a506 100644 --- a/@xen-orchestra/mixins/docs/HttpProxy.md +++ b/@xen-orchestra/mixins/docs/HttpProxy.md @@ -19,7 +19,7 @@ enabled = true ## Usage -For safety reaons, the proxy requires authentication to be used. +For safety reasons, the proxy requires authentication to be used. ### `xo-proxy` From d361b9308da514a080594b7625360b31a0a8c560 Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Wed, 27 Apr 2022 13:46:33 +0200 Subject: [PATCH 15/17] fix comment location --- @xen-orchestra/mixins/HttpProxy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/@xen-orchestra/mixins/HttpProxy.js b/@xen-orchestra/mixins/HttpProxy.js index f97f14f3b16..fb58609aa8c 100644 --- a/@xen-orchestra/mixins/HttpProxy.js +++ b/@xen-orchestra/mixins/HttpProxy.js @@ -26,7 +26,6 @@ const IGNORED_HEADERS = new Set([ 'host', ]) -// https://nodejs.org/api/http.html#event-connect module.exports = class HttpProxy { #app @@ -72,6 +71,7 @@ module.exports = class HttpProxy { return res.end('Proxy Authentication Required') } + // https://nodejs.org/api/http.html#event-connect async #handleConnect(req, clientSocket, head) { const { url } = req From f2056ceccee0a10f8fbf422e91d8a5d985f5f824 Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Wed, 27 Apr 2022 19:06:25 +0200 Subject: [PATCH 16/17] fix(xen-api/parseUrl): handle username without password --- package.json | 1 + packages/xen-api/package.json | 6 ++++-- packages/xen-api/src/_parseUrl.js | 9 +++++---- yarn.lock | 2 +- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 7724ed127a3..34c05263835 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "/@vates/predicates/", "/@xen-orchestra/audit-core/", "/dist/", + "/xen-api/", "/xo-server/", "/xo-server-test/", "/xo-web/" diff --git a/packages/xen-api/package.json b/packages/xen-api/package.json index f4595dd3c59..d9ce7256020 100644 --- a/packages/xen-api/package.json +++ b/packages/xen-api/package.json @@ -56,7 +56,8 @@ "@babel/plugin-proposal-decorators": "^7.0.0", "@babel/preset-env": "^7.8.0", "cross-env": "^7.0.2", - "rimraf": "^3.0.0" + "rimraf": "^3.0.0", + "tap": "^16.1.0" }, "scripts": { "build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/", @@ -65,6 +66,7 @@ "prebuild": "rimraf dist/", "predev": "yarn run prebuild", "prepublishOnly": "yarn run build", - "postversion": "npm publish" + "postversion": "npm publish", + "test": "tap" } } diff --git a/packages/xen-api/src/_parseUrl.js b/packages/xen-api/src/_parseUrl.js index d6e2487af1c..1a2e03932c4 100644 --- a/packages/xen-api/src/_parseUrl.js +++ b/packages/xen-api/src/_parseUrl.js @@ -1,4 +1,4 @@ -const URL_RE = /^(?:(https?:)\/*)?(?:(([^:]+):([^@]+))@)?(?:\[([^\]]+)\]|([^:/]+))(?::([0-9]+))?(\/[^?#]*)?$/ +const URL_RE = /^(?:(https?:)\/*)?(?:(([^:]*)(?::([^@]*))?)@)?(?:\[([^\]]+)\]|([^:/]+))(?::([0-9]+))?(\/[^?#]*)?$/ export default url => { const matches = URL_RE.exec(url) @@ -6,7 +6,8 @@ export default url => { throw new Error('invalid URL: ' + url) } - const [, protocol = 'https:', auth, username, password, ipv6, hostname = ipv6, port, pathname = '/'] = matches + const [, protocol = 'https:', auth, username = '', password = '', ipv6, hostname = ipv6, port, pathname = '/'] = + matches const parsedUrl = { protocol, hostname, @@ -16,10 +17,10 @@ export default url => { // compat with url.parse auth, } - if (username !== undefined) { + if (username !== '') { parsedUrl.username = decodeURIComponent(username) } - if (password !== undefined) { + if (password !== '') { parsedUrl.password = decodeURIComponent(password) } return parsedUrl diff --git a/yarn.lock b/yarn.lock index c7c62f9455b..f39a12a7a2f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17442,7 +17442,7 @@ tap-yaml@^1.0.0: dependencies: yaml "^1.5.0" -tap@^16.0.1: +tap@^16.0.1, tap@^16.1.0: version "16.1.0" resolved "https://registry.yarnpkg.com/tap/-/tap-16.1.0.tgz#85e989313afb318e6447dfa74c8aeb01b1770278" integrity sha512-EFERYEEDCLjvsT+B+z/qAVuxh5JPEmtn0aGh1ZT/2BN5nVLm6VbcL9fR/Y2FtsxvHuEC3Q2xLc1n1h7mnWVP9w== From bd6d21571afc7d4ca4213d5fe1ed298bbd3b85c0 Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Wed, 27 Apr 2022 19:10:58 +0200 Subject: [PATCH 17/17] add missing file --- packages/xen-api/_parseUrl.spec.js | 52 ++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 packages/xen-api/_parseUrl.spec.js diff --git a/packages/xen-api/_parseUrl.spec.js b/packages/xen-api/_parseUrl.spec.js new file mode 100644 index 00000000000..63038521956 --- /dev/null +++ b/packages/xen-api/_parseUrl.spec.js @@ -0,0 +1,52 @@ +'use strict' + +const t = require('tap') + +const parseUrl = require('./dist/_parseUrl.js').default + +const data = { + 'xcp.company.lan': { + hostname: 'xcp.company.lan', + pathname: '/', + protocol: 'https:', + }, + '[::1]': { + hostname: '::1', + pathname: '/', + protocol: 'https:', + }, + 'http://username:password@xcp.company.lan': { + auth: 'username:password', + hostname: 'xcp.company.lan', + password: 'password', + pathname: '/', + protocol: 'http:', + username: 'username', + }, + 'https://username@xcp.company.lan': { + auth: 'username', + hostname: 'xcp.company.lan', + pathname: '/', + protocol: 'https:', + username: 'username', + }, +} + +t.test('invalid url', function (t) { + t.throws(() => parseUrl('')) + t.end() +}) + +for (const url of Object.keys(data)) { + t.test(url, function (t) { + const parsed = parseUrl(url) + for (const key of Object.keys(parsed)) { + if (parsed[key] === undefined) { + delete parsed[key] + } + } + + t.same(parsed, data[url]) + t.end() + }) +}