Skip to content

Commit

Permalink
feat(xo-proxy): implement reverse proxy + tests
Browse files Browse the repository at this point in the history
  • Loading branch information
fbeauchamp committed Jan 10, 2022
1 parent 7846134 commit 0c473c1
Show file tree
Hide file tree
Showing 10 changed files with 345 additions and 9 deletions.
22 changes: 22 additions & 0 deletions @xen-orchestra/proxy/USAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
## Reverse proxy configuration

You can define multiples proxies in the configuration file

```
[reverseProxies.localhttps]
path = '/https/'
target = 'https://localhost:8080/'
rejectUnauthorized = false
[reverseProxies.localhttp]
path = '/http/'
target = 'http://localhost:8081/'
```

these two proxies will redirect all the queries to `<url of the proxy>/proxy/v1/https/` to `https://localhost:8080/` and `<url of the proxy>/proxy/v1/http/` to `https://localhost:8080/`.

The additionnal options of a proxy's configuraiton's section are used to instantiate the `https` Agent(respectively the `http`) . A notable option is `rejectUnauthorized` which allow to connect to a HTTPS backend with an invalid/ self signed certificate

The target can have a path ( like `http://target/sub/directory/`), parameters (`?param=one`) and hash (`#jwt:32154`) that are automatically added to all queries transfered by the proxy.
If a parameter is present in the configuration and in the query, only the config parameter is transferred.

1 change: 1 addition & 0 deletions @xen-orchestra/proxy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"getopts": "^2.2.3",
"golike-defer": "^0.5.1",
"http-server-plus": "^0.11.0",
"http2-proxy": "^5.0.53",
"json-rpc-protocol": "^0.13.1",
"jsonrpc-websocket-client": "^0.7.2",
"koa": "^2.5.1",
Expand Down
20 changes: 11 additions & 9 deletions @xen-orchestra/proxy/src/app/mixins/api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ export default class Api {
constructor(app, { appVersion, httpServer }) {
this._ajv = new Ajv({ allErrors: true })
this._methods = { __proto__: null }
this.PREFIX_URL = '/api/v1'
const router = new Router({ prefix: this.PREFIX_URL }).post('/', async ctx => {
const PREFIX = '/api/v1'
const router = new Router({ prefix: PREFIX }).post('/', async ctx => {
// Before Node 13.0 there was an inactivity timeout of 2 mins, which may
// not be enough for the API.
ctx.req.setTimeout(0)
Expand Down Expand Up @@ -102,6 +102,7 @@ export default class Api {
// breaks, send some data every 10s to keep it opened.
const stopTimer = clearInterval.bind(
undefined,
// @to check : can this add space inside binary data ?
setInterval(() => stream.push(' '), keepAliveInterval)
)
stream.on('end', stopTimer).on('error', stopTimer)
Expand All @@ -113,18 +114,19 @@ export default class Api {

const koa = new Koa()
.on('error', warn)
// only answers to query to the root url of this mixin
.use( async(ctx, next)=>{
if(ctx.req.url.startsWith(this.PREFIX_URL)){
await next()
}
})
.use(helmet())
.use(compress())
.use(router.routes())
.use(router.allowedMethods())

httpServer.on('request', koa.callback())
const callback = koa.callback()
httpServer.on('request', (req, res)=>{
// only answers to query to the root url of this mixin
// do it before giving the request to Koa to ensure it's not modified
if(req.url.startsWith(PREFIX)){
callback(req, res)
}
} )

this.addMethods({
system: {
Expand Down
109 changes: 109 additions & 0 deletions @xen-orchestra/proxy/src/app/mixins/reverseProxy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { createLogger } from '@xen-orchestra/log'
import { urlToHttpOptions } from 'url' //node 14.18.0, 15.7.0 is it ok ?
import proxy from 'http2-proxy'

const RE_ABSOLUTE_URL = new RegExp('^(?:[a-z]+:)?//', 'i')

const { debug, warn } = createLogger('xo:proxy:reverse-proxy')

function removeSlash(str){
return str.replace(/^\/|\/$/g, '')
}

function mergeUrl(relative, base){
const res = new URL(base)
const relativeUrl = new URL(relative, base)
res.pathname = relativeUrl.pathname
relativeUrl.searchParams.forEach((value, name)=>{
// we do not allow to modify params already specified by config
if(!res.searchParams.has(name)){
res.searchParams.append(name, value)
}
})
res.hash = relativeUrl.hash.length > 0 ? relativeUrl.hash: res.hash
return res
}
export default class ReverseProxy {
constructor(app, { httpServer }) {
this.httpServer = httpServer
this.app = app
this.configs = {}
for(const [proxyId, {path, target, ...options}] of Object.entries(app.config.get('reverseProxies'))){
this.configs[proxyId] = {
path: '/proxy/v1/'+removeSlash(path),
target: new URL(target),
options
}
this.setupOneProxy(proxyId)
}
}

localToBackendUrl(proxyId, localPath){
const { path:basePath, target } = this.configs[proxyId]
let localPathWithoutBase = removeSlash(localPath).substring(basePath.length)
localPathWithoutBase = './'+removeSlash(localPathWithoutBase)
const url = mergeUrl(localPathWithoutBase, target)
return url
}

backendToLocalPath(proxyId, backendUrl){
const { path:basePath, target } = this.configs[proxyId]

// keep redirect url relative to local server
const localPath = `${basePath}/${backendUrl.pathname.substring(target.pathname.length)}${backendUrl.search}${backendUrl.hash}`
return localPath
}

proxy(proxyId, req,res){
const {path, target, options} = this.configs[proxyId]
const url = new URL(target)
if(!req.url.startsWith(path+'/')){
return
}
const targetUrl = this.localToBackendUrl(proxyId, req.originalUrl || req.url)
proxy.web(req, res, {
...urlToHttpOptions(targetUrl),
...options,
onRes: (err, req, proxyRes)=>{
// rewrite redirect to pass through this proxy
if(proxyRes.statusCode === 301 || proxyRes.statusCode === 302){
// handle relative/ absolute location
const redirectTargetLocation = new URL(proxyRes.headers.location, url)

// this proxy should only allow communication between known hosts. Don't open it too much
if(redirectTargetLocation.hostname !== url.hostname || redirectTargetLocation.protocol !== url.protocol){
throw new Error(`Can't redirect from ${url.hostname} to ${redirectTargetLocation.hostname} `)
}
res.writeHead(proxyRes.statusCode, {
...proxyRes.headers,
'location': this.backendToLocalPath(proxyId, redirectTargetLocation)});
res.end()
return
}
// pass through the anwer of the remote server
res.writeHead(proxyRes.statusCode, {
...proxyRes.headers});
// pass through content
proxyRes.pipe(res)
}
})
}

upgrade(proxyId, req, socket, head){
const {path, options} = this.configs[proxyId]
if(!req.url.startsWith(path+'/')){
return
}
const targetUrl = this.localToBackendUrl(proxyId, req.originalUrl || req.url)
proxy.ws(req, socket, head, {
...urlToHttpOptions(targetUrl),
...options
})
}

setupOneProxy(proxyId){
this.httpServer.on('request', (req, res) => this.proxy(proxyId, req, res))
this.httpServer.on('upgrade', (req, socket, head) =>this.upgrade(proxyId, req, socket, head))
}

}
19 changes: 19 additions & 0 deletions @xen-orchestra/proxy/tests/cert.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDETCCAfkCFHXO1U7YJHI61bPNhYDvyBNJYH4LMA0GCSqGSIb3DQEBCwUAMEUx
CzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl
cm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMjIwMTEwMTI0MTU4WhcNNDkwNTI3MTI0
MTU4WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UE
CgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOC
AQ8AMIIBCgKCAQEA1jMLdHuZu2R1fETyB2iRect1alwv76clp/7A8tx4zNaVA9qB
BcHbI83mkozuyrXpsEUblTvvcWkheBPAvWD4gj0eWSDSiuf0edcIS6aky+Lr/n0T
W/vL5kVNrgPTlsO8OyQcXjDeuUOR1xDWIa8G71Ynd6wtATB7oXe7kaV/Z6b2fENr
4wlW0YEDnMHik59c9jXDshhQYDlErwZsSyHuLwkC7xuYO26SUW9fPcHJA3uOfxeG
BrCxMuSMOJtdmslRWhLCjbk0PT12OYCCRlvuTvPHa8N57GEQbi4xAu+XATgO1DUm
Dq/oCSj0TcWUXXOykN/PAC2cjIyqkU2e7orGaQIDAQABMA0GCSqGSIb3DQEBCwUA
A4IBAQCTshhF3V5WVhnpFGHd+tPfeHmUVrUnbC+xW7fSeWpamNmTjHb7XB6uDR0O
DGswhEitbbSOsCiwz4/zpfE3/3+X07O8NPbdHTVHCei6D0uyegEeWQ2HoocfZs3X
8CORe8TItuvQAevV17D0WkGRoJGVAOiKo+izpjI55QXQ+FjkJ0bfl1iksnUJk0+I
ZNmRRNjNyOxo7NAzomSBHfJ5rDE+E440F2uvXIE9OIwHRiq6FGvQmvGijPeeP5J0
LzcSK98jfINFSsA/Wn5vWE+gfH9ySD2G3r2cDTS904T77PNiYH+cNSP6ujtmNzvK
Bgoa3jXZPRBi82TUOb2jj5DB33bg
-----END CERTIFICATE-----
27 changes: 27 additions & 0 deletions @xen-orchestra/proxy/tests/key.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA1jMLdHuZu2R1fETyB2iRect1alwv76clp/7A8tx4zNaVA9qB
BcHbI83mkozuyrXpsEUblTvvcWkheBPAvWD4gj0eWSDSiuf0edcIS6aky+Lr/n0T
W/vL5kVNrgPTlsO8OyQcXjDeuUOR1xDWIa8G71Ynd6wtATB7oXe7kaV/Z6b2fENr
4wlW0YEDnMHik59c9jXDshhQYDlErwZsSyHuLwkC7xuYO26SUW9fPcHJA3uOfxeG
BrCxMuSMOJtdmslRWhLCjbk0PT12OYCCRlvuTvPHa8N57GEQbi4xAu+XATgO1DUm
Dq/oCSj0TcWUXXOykN/PAC2cjIyqkU2e7orGaQIDAQABAoIBAQC65uVq6WLWGa1O
FtbdUggGL1svyGrngYChGvB/uZMKoX57U1DbljDCCCrV23WNmbfkYBjWWervmZ1j
qlC2roOJGQ1/Fd3A6O7w1YnegPUxFrt3XunijE55iiVi3uHknryDGlpKcfgVzfjW
oVFHKPMzKYjcqnbGn+hwlwoq5y7JYFTOa57/dZbyommbodRyy9Dpn0OES0grQqwR
VD1amrQ7XJhukcxQgYPuDc/jM3CuowoBsv9f+Q2zsPgr6CpHxxLLIs+kt8NQJT9v
neg/pm8ojcwOa9qoILdtu6ue7ee3VE9cFnB1vutxS1+MPeI5wgTJjaYrgPCMxXBM
2LdJJEmBAoGBAPA6LpuU1vv5R3x66hzenSk4LS1fj24K0WuBdTwFvzQmCr70oKdo
Yywxt+ZkBw5aEtzQlB8GewolHobDJrzxMorU+qEXX3jP2BIPDVQl2orfjr03Yyus
s5mYS/Qa6Zf1yObrjulTNm8oTn1WaG3TIvi8c5DyG2OK28N/9oMI1XGRAoGBAORD
YKyII/S66gZsJSf45qmrhq1hHuVt1xae5LUPP6lVD+MCCAmuoJnReV8fc9h7Dvgd
YPMINkWUTePFr3o4p1mh2ZC7ldczgDn6X4TldY2J3Zg47xJa5hL0L6JL4NiCGRIE
FV5rLJxkGh/DDBfmC9hQQ6Yg6cHvyewso5xVnBtZAoGAI+OdWPMIl0ZrrqYyWbPM
aP8SiMfRBtCo7tW9bQUyxpi0XEjxw3Dt+AlJfysMftFoJgMnTedK9H4NLHb1T579
PQ6KjwyN39+1WSVUiXDKUJsLmSswLrMzdcvx9PscUO6QYCdrB2K+LCcqasFBAr9b
ZyvIXCw/eUSihneUnYjxUnECgYAoPgCzKiU8ph9QFozOaUExNH4/3tl1lVHQOR8V
FKUik06DtP35xwGlXJrLPF5OEhPnhjZrYk0/IxBAUb/ICmjmknQq4gdes0Ot9QgW
A+Yfl+irR45ObBwXx1kGgd4YDYeh93pU9QweXj+Ezfw50mLQNgZXKYJMoJu2uX/2
tdkZsQKBgQCTfDcW8qBntI6V+3Gh+sIThz+fjdv5+qT54heO4EHadc98ykEZX0M1
sCWJiAQWM/zWXcsTndQDgDsvo23jpoulVPDitSEISp5gSe9FEN2njsVVID9h1OIM
f30s5kwcJoiV9kUCya/BFtuS7kbuQfAyPU0v3I+lUey6VCW6A83OTg==
-----END RSA PRIVATE KEY-----
66 changes: 66 additions & 0 deletions @xen-orchestra/proxy/tests/localServer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { createServer as creatServerHttps } from 'https';
import { createServer as creatServerHttp } from 'http';

import { parse } from 'url';
import { WebSocketServer } from 'ws';
import fs from 'fs'

const httpsServer = creatServerHttps({
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem')
});
const httpServer = creatServerHttp();

const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });

function upgrade(request, socket, head) {
const { pathname } = parse(request.url);
console.log('will upgrade', pathname)
// web socket server only on /foo url
if (pathname === '/foo') {
wss.handleUpgrade(request, socket, head, function done(ws) {
wss.emit('connection', ws, request);
ws.on('message', function message(data) {
console.log('received: %s', data);
ws.send(data);

});
});
} else {
socket.destroy();
}
}

function httpHandler(req, res){
console.log(req.url)
switch(req.url){
case '/index.html':
res.end('hi')
return
case '/redirect':
res.writeHead(301, {
'Location': 'index.html'
})
res.end()
return
case '/chainRedirect':
res.writeHead(301, {
'Location': '/redirect'
})
res.end()
return
default:
res.writeHEad(404)
res.end()
}
}

httpsServer.on('upgrade', upgrade);
httpServer.on('upgrade', upgrade);

httpsServer.on('request', httpHandler)
httpServer.on('request', httpHandler)


httpsServer.listen(8080);
httpServer.listen(8081);
84 changes: 84 additions & 0 deletions @xen-orchestra/proxy/tests/reverseProxy.unit.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import ReverseProxy from '../dist/app/mixins/reverseProxy.mjs'
import { strictEqual } from 'assert'


function makeApp(reverseProxies){
return {
config:{
get: ()=>reverseProxies
}
}
}

const app = makeApp(
{
https: {
path: '/https',
target: 'https://localhost:8080/remotePath/?baseParm=1#one=2&another=3'
}
}
)

const expectedLocalToRemote = {
https: [
{
local: '/proxy/v1/https/',
remote: 'https://localhost:8080/remotePath/?baseParm=1#one=2&another=3'
},
{
local: '/proxy/v1/https/sub',
remote: 'https://localhost:8080/remotePath/sub?baseParm=1#one=2&another=3'
},
{
local: '/proxy/v1/https/sub/index.html',
remote: 'https://localhost:8080/remotePath/sub/index.html?baseParm=1#one=2&another=3'
},
{
local: '/proxy/v1/https/sub?param=1',
remote: 'https://localhost:8080/remotePath/sub?baseParm=1&param=1#one=2&another=3'
},
{
local: '/proxy/v1/https/sub?baseParm=willbeoverwritten&param=willstay',
remote: 'https://localhost:8080/remotePath/sub?baseParm=1&param=willstay#one=2&another=3'
},
{
local: '/proxy/v1/https/sub?param=1#another=willoverwrite',
remote: 'https://localhost:8080/remotePath/sub?baseParm=1&param=1#another=willoverwrite'
}
]
}
const proxy = new ReverseProxy(app, {httpServer: { on : ()=>{}}})
for(const proxyId in expectedLocalToRemote){
for( const {local, remote} of expectedLocalToRemote[proxyId]){
strictEqual(proxy.localToBackendUrl('https', local).href, remote, "error converting to backend")
}
}

const expectedRemoteToLocal = {
https: [
{
local: '/proxy/v1/https/',
remote: 'https://localhost:8080/remotePath/'
},
{
local: '/proxy/v1/https/sub/index.html',
remote: 'https://localhost:8080/remotePath/sub/index.html'
},
{
local: '/proxy/v1/https/?baseParm=1#one=2&another=3',
remote: 'https://localhost:8080/remotePath/?baseParm=1#one=2&another=3'
},
{
local: '/proxy/v1/https/sub?baseParm=1#one=2&another=3',
remote: 'https://localhost:8080/remotePath/sub?baseParm=1#one=2&another=3'
}
]
}

for(const proxyId in expectedRemoteToLocal){
for( const {local, remote} of expectedRemoteToLocal[proxyId]){
const url = new URL(remote, 'https://localhost:8080/remotePath/?baseParm=1#one=2&another=3')
strictEqual(proxy.backendToLocalPath('https', url), local, "error converting to local")
}
}

1 change: 1 addition & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
### Enhancements

> Users must be able to say: “Nice enhancement, I'm eager to test it”
- [xo-proxy] : A visible xo-proxy can be used to transfer queries to another proxy/ host hidden from xo [PR#6071](https://github.com/vatesfr/xen-orchestra/pull/6072)

### Bug fixes

Expand Down
Loading

0 comments on commit 0c473c1

Please sign in to comment.