@cloud-cli/px 3.4.0 → 3.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -12
- package/dist/proxy.d.ts +14 -10
- package/dist/proxy.js +66 -64
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
# PX
|
|
2
2
|
|
|
3
|
-
Reverse proxy to map local services
|
|
3
|
+
Reverse proxy to map local services and external http(s) domains
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Usage
|
|
6
6
|
|
|
7
7
|
Use it with [Cloudy](https://github.com/cloud-cli/cli)
|
|
8
8
|
|
|
@@ -14,11 +14,11 @@ import proxy from '@cloud-cli/px';
|
|
|
14
14
|
export default { proxy };
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
## API
|
|
18
18
|
|
|
19
19
|
**Add a proxy to a local service**
|
|
20
20
|
|
|
21
|
-
```
|
|
21
|
+
```bash
|
|
22
22
|
cy proxy.add --domain "foo.example.com" --target "http://localhost:1234"
|
|
23
23
|
```
|
|
24
24
|
|
|
@@ -26,42 +26,43 @@ All available options:
|
|
|
26
26
|
|
|
27
27
|
- `domain`: entrypoint for reverse proxy
|
|
28
28
|
- `target`: any target for requests, can be local or remote
|
|
29
|
+
- `cors`: handle CORS request on proxy level. See notes below!
|
|
29
30
|
- `redirect`: if request comes as http, redirect to https
|
|
30
31
|
- `redirectUrl`: if provided, issues a `Location` header and terminates with status 302 instead of proxying a request
|
|
31
32
|
|
|
32
33
|
**Remove a proxy**
|
|
33
34
|
|
|
34
|
-
```
|
|
35
|
+
```bash
|
|
35
36
|
cy proxy.remove --domain "foo.example.com"
|
|
36
37
|
```
|
|
37
38
|
|
|
38
39
|
**Get details of a proxy**
|
|
39
40
|
|
|
40
|
-
```
|
|
41
|
+
```bash
|
|
41
42
|
cy proxy.get --domain "foo.example.com"
|
|
42
43
|
```
|
|
43
44
|
|
|
44
45
|
**List proxies**
|
|
45
46
|
|
|
46
|
-
```
|
|
47
|
+
```bash
|
|
47
48
|
cy proxy.list
|
|
48
49
|
```
|
|
49
50
|
|
|
50
51
|
**List registered domains**
|
|
51
52
|
|
|
52
|
-
```
|
|
53
|
+
```bash
|
|
53
54
|
cy proxy.domains
|
|
54
55
|
```
|
|
55
56
|
|
|
56
57
|
**Reload all configurations**
|
|
57
58
|
|
|
58
|
-
```
|
|
59
|
+
```bash
|
|
59
60
|
cy proxy.reload
|
|
60
61
|
```
|
|
61
62
|
|
|
62
63
|
## Certificate file resolution
|
|
63
64
|
|
|
64
|
-
```
|
|
65
|
+
```bash
|
|
65
66
|
certificatesFolder = '/etc/letsencrypt/live' or process.env.PX_CERTS_FOLDER
|
|
66
67
|
rootDomain = 'example.com' in 'xyz.example.com'
|
|
67
68
|
cert = certificatesFolder + '/' + rootDomain + '/' + certificateFile
|
|
@@ -70,7 +71,20 @@ key = certificatesFolder + '/' + rootDomain + '/' + keyFile
|
|
|
70
71
|
|
|
71
72
|
So xyz.example.com points to:
|
|
72
73
|
|
|
73
|
-
```
|
|
74
|
+
```bash
|
|
74
75
|
/etc/letsencrypt/live/example.com/fullchain.pem for certificate
|
|
75
76
|
/etc/letsencrypt/live/example.com/privkey.pem for private key
|
|
76
77
|
```
|
|
78
|
+
|
|
79
|
+
## Notes
|
|
80
|
+
|
|
81
|
+
CORS handling on proxy level is convenient, **but a security risk**. Beware:
|
|
82
|
+
|
|
83
|
+
- All origins are allowed, all common methods and headers, and credentials are enabled.
|
|
84
|
+
- In case of an `OPTIONS` request, if coming via CORS (a preflight), an empty response will be send and the request ends before calling the proxied target.
|
|
85
|
+
|
|
86
|
+
Every request has 2 additional headers:
|
|
87
|
+
|
|
88
|
+
- x-forwarded-for: the host of the original request.
|
|
89
|
+
If a proxy is from 'https://foo.example.com' to 'http://localhost:1234', the header will be 'foo.example.com'
|
|
90
|
+
- x-forwarded-proto: 'http:' or 'https:', depends on the original request
|
package/dist/proxy.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
|
-
|
|
2
|
+
/// <reference types="node" />
|
|
3
|
+
/// <reference types="node" />
|
|
4
|
+
/// <reference types="node" />
|
|
5
|
+
import { IncomingMessage, ClientRequest, ServerResponse } from 'http';
|
|
6
|
+
import { ServerOptions as HttpsServerOptions } from 'https';
|
|
7
|
+
import { SecureContext } from 'tls';
|
|
3
8
|
import { Resource } from '@cloud-cli/store';
|
|
4
9
|
export declare class ProxyEntry extends Resource {
|
|
5
10
|
id: number;
|
|
@@ -10,12 +15,8 @@ export declare class ProxyEntry extends Resource {
|
|
|
10
15
|
cors: boolean;
|
|
11
16
|
constructor(p: Partial<ProxyEntry>);
|
|
12
17
|
}
|
|
13
|
-
interface Certificates {
|
|
14
|
-
key: string;
|
|
15
|
-
cert: string;
|
|
16
|
-
}
|
|
17
18
|
export declare class ProxyServer {
|
|
18
|
-
protected certs: Record<string,
|
|
19
|
+
protected certs: Record<string, SecureContext>;
|
|
19
20
|
protected targets: Record<string, ProxyEntry>;
|
|
20
21
|
protected servers: any;
|
|
21
22
|
start(): void;
|
|
@@ -23,8 +24,11 @@ export declare class ProxyServer {
|
|
|
23
24
|
protected loadCertificates(): Promise<void>;
|
|
24
25
|
protected loadTargets(): Promise<void>;
|
|
25
26
|
protected serveRequest(req: IncomingMessage, res: ServerResponse, insecure?: boolean): void;
|
|
26
|
-
protected getSslOptions():
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
protected getSslOptions(): HttpsServerOptions;
|
|
28
|
+
protected findRootDomain(domain: string): SecureContext;
|
|
29
|
+
protected setHeaders(from: IncomingMessage, to: ServerResponse | ClientRequest): void;
|
|
30
|
+
protected getOrigin(req: IncomingMessage): URL;
|
|
31
|
+
protected getHost(string: string): string;
|
|
32
|
+
protected setCorsHeaders(req: IncomingMessage, res: ServerResponse): void;
|
|
33
|
+
protected handleError(error: any, res: ServerResponse): void;
|
|
29
34
|
}
|
|
30
|
-
export {};
|
package/dist/proxy.js
CHANGED
|
@@ -4,12 +4,11 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
4
4
|
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
5
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
6
|
};
|
|
7
|
-
import { createServer as createHttpServer, request as httpRequest } from 'http';
|
|
7
|
+
import { createServer as createHttpServer, request as httpRequest, } from 'http';
|
|
8
8
|
import { createServer as createHttpsServer, request as httpsRequest } from 'https';
|
|
9
9
|
import { createSecureContext } from 'tls';
|
|
10
10
|
import { Model, Primary, Property, Query, Resource } from '@cloud-cli/store';
|
|
11
|
-
import { readdir, readFile
|
|
12
|
-
import { existsSync } from 'fs';
|
|
11
|
+
import { readdir, readFile } from 'fs/promises';
|
|
13
12
|
import { join } from 'path';
|
|
14
13
|
const certificatesFolder = process.env.PX_CERTS_FOLDER || '/etc/letsencrypt/live';
|
|
15
14
|
const certificateFile = 'fullchain.pem';
|
|
@@ -62,18 +61,17 @@ export class ProxyServer {
|
|
|
62
61
|
}
|
|
63
62
|
async loadCertificates() {
|
|
64
63
|
const certs = (this.certs = {});
|
|
65
|
-
const localCerts = await readdir(certificatesFolder);
|
|
66
|
-
const folders =
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
console.log(`Loading certificate for ${rootDomain}`);
|
|
64
|
+
const localCerts = await readdir(certificatesFolder, { withFileTypes: true });
|
|
65
|
+
const folders = localCerts.filter((entry) => entry.isDirectory()).map((dir) => dir.name);
|
|
66
|
+
for (const rootDomain of folders) {
|
|
67
|
+
if (process.env.DEBUG) {
|
|
68
|
+
console.log(`Loading certificate for ${rootDomain}`);
|
|
69
|
+
}
|
|
72
70
|
certs[rootDomain] = createSecureContext({
|
|
73
71
|
cert: await readFile(join(certificatesFolder, rootDomain, certificateFile), 'utf8'),
|
|
74
72
|
key: await readFile(join(certificatesFolder, rootDomain, keyFile), 'utf8'),
|
|
75
73
|
});
|
|
76
|
-
}
|
|
74
|
+
}
|
|
77
75
|
}
|
|
78
76
|
async loadTargets() {
|
|
79
77
|
const targets = await Resource.find(ProxyEntry, new Query());
|
|
@@ -81,7 +79,7 @@ export class ProxyServer {
|
|
|
81
79
|
targets.forEach((entry) => (this.targets[entry.domain] = entry));
|
|
82
80
|
}
|
|
83
81
|
serveRequest(req, res, insecure = false) {
|
|
84
|
-
const origin = getOrigin(req);
|
|
82
|
+
const origin = this.getOrigin(req);
|
|
85
83
|
const proxyEntry = this.targets[origin?.hostname];
|
|
86
84
|
if (!origin.hostname || !proxyEntry) {
|
|
87
85
|
res.writeHead(404, 'Not found');
|
|
@@ -105,23 +103,25 @@ export class ProxyServer {
|
|
|
105
103
|
const url = new URL(req.url, target);
|
|
106
104
|
const isCorsPreflight = req.method === 'OPTIONS' && proxyEntry.cors;
|
|
107
105
|
if (isCorsPreflight) {
|
|
108
|
-
setCorsHeaders(req, res);
|
|
106
|
+
this.setCorsHeaders(req, res);
|
|
109
107
|
res.writeHead(204, { 'Content-Length': '0' });
|
|
110
108
|
res.end();
|
|
111
109
|
return;
|
|
112
110
|
}
|
|
113
111
|
const proxyRequest = (url.protocol === 'https:' ? httpsRequest : httpRequest)(url, { method: req.method });
|
|
114
|
-
setHeaders(req, proxyRequest);
|
|
115
|
-
proxyRequest.setHeader('host', getHost(target));
|
|
112
|
+
this.setHeaders(req, proxyRequest);
|
|
113
|
+
proxyRequest.setHeader('host', this.getHost(target));
|
|
116
114
|
proxyRequest.setHeader('x-forwarded-for', req.headers.host);
|
|
115
|
+
proxyRequest.setHeader('x-forwarded-proto', insecure ? 'http' : 'https');
|
|
116
|
+
proxyRequest.setHeader('forwarded', 'host=' + req.headers.host + ';proto=' + insecure ? 'http' : 'https');
|
|
117
117
|
req.on('data', (chunk) => proxyRequest.write(chunk));
|
|
118
118
|
req.on('end', () => proxyRequest.end());
|
|
119
|
-
proxyRequest.on('error', (error) => handleError(error, res));
|
|
119
|
+
proxyRequest.on('error', (error) => this.handleError(error, res));
|
|
120
120
|
proxyRequest.on('response', (proxyRes) => {
|
|
121
|
-
setHeaders(proxyRes, res);
|
|
121
|
+
this.setHeaders(proxyRes, res);
|
|
122
122
|
const isCorsSimple = req.method !== 'OPTIONS' && proxyEntry.cors && req.headers.origin;
|
|
123
123
|
if (isCorsSimple) {
|
|
124
|
-
setCorsHeaders(req, res);
|
|
124
|
+
this.setCorsHeaders(req, res);
|
|
125
125
|
}
|
|
126
126
|
res.writeHead(proxyRes.statusCode, proxyRes.statusMessage);
|
|
127
127
|
proxyRes.on('data', (chunk) => res.write(chunk));
|
|
@@ -132,59 +132,61 @@ export class ProxyServer {
|
|
|
132
132
|
const server = this;
|
|
133
133
|
return {
|
|
134
134
|
SNICallback(domain, cb) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return cb(null, server.certs[rootDomain]);
|
|
135
|
+
const rootDomain = server.findRootDomain(domain);
|
|
136
|
+
if (rootDomain) {
|
|
137
|
+
return cb(null, rootDomain);
|
|
139
138
|
}
|
|
140
|
-
|
|
139
|
+
cb(new Error('Not found', { cause: 404 }), null);
|
|
141
140
|
},
|
|
142
141
|
};
|
|
143
142
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
143
|
+
findRootDomain(domain) {
|
|
144
|
+
const parts = domain.split('/')[0].split('.');
|
|
145
|
+
const certs = this.certs;
|
|
146
|
+
while (parts.length) {
|
|
147
|
+
const rootDomain = parts.join('.');
|
|
148
|
+
if (certs[rootDomain]) {
|
|
149
|
+
return certs[rootDomain];
|
|
150
|
+
}
|
|
151
|
+
parts.shift();
|
|
152
152
|
}
|
|
153
|
-
|
|
153
|
+
return null;
|
|
154
154
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
to.setHeader(header[0], header[1]);
|
|
155
|
+
setHeaders(from, to) {
|
|
156
|
+
const headers = Object.entries(from.headers);
|
|
157
|
+
for (const header of headers) {
|
|
158
|
+
to.setHeader(header[0], header[1]);
|
|
159
|
+
}
|
|
161
160
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (error.code === 'ECONNREFUSED') {
|
|
166
|
-
res.writeHead(502);
|
|
167
|
-
res.end();
|
|
168
|
-
return;
|
|
161
|
+
getOrigin(req) {
|
|
162
|
+
const host = req.headers['x-forwarded-for'] || req.headers.host || '';
|
|
163
|
+
return (host && new URL('http://' + host)) || null;
|
|
169
164
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
165
|
+
getHost(string) {
|
|
166
|
+
const url = new URL(string);
|
|
167
|
+
return url.hostname + (url.port ? ':' + url.port : '');
|
|
168
|
+
}
|
|
169
|
+
setCorsHeaders(req, res) {
|
|
170
|
+
const headers = req.headers;
|
|
171
|
+
const corsOrigin = new URL(req.headers.origin).origin;
|
|
172
|
+
const allowedMethod = headers['access-control-request-method'] || 'GET,HEAD,PUT,PATCH,POST,DELETE';
|
|
173
|
+
const allowedHeaders = headers['access-control-request-headers'] || '*';
|
|
174
|
+
res.setHeader('Vary', 'Origin');
|
|
175
|
+
res.setHeader('Access-Control-Allow-Origin', corsOrigin);
|
|
176
|
+
res.setHeader('Access-Control-Allow-Headers', allowedHeaders);
|
|
177
|
+
res.setHeader('Access-Control-Allow-Methods', allowedMethod);
|
|
178
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
179
|
+
}
|
|
180
|
+
handleError(error, res) {
|
|
181
|
+
console.error(error);
|
|
182
|
+
if (error.code === 'ECONNREFUSED') {
|
|
183
|
+
res.writeHead(502);
|
|
184
|
+
res.end();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (!res.headersSent) {
|
|
188
|
+
res.writeHead(500);
|
|
189
|
+
res.end();
|
|
190
|
+
}
|
|
173
191
|
}
|
|
174
|
-
}
|
|
175
|
-
function getOrigin(req) {
|
|
176
|
-
const host = req.headers['x-forwarded-host'] || req.headers.host || '';
|
|
177
|
-
return (host && new URL('http://' + host)) || null;
|
|
178
|
-
}
|
|
179
|
-
function getHost(string) {
|
|
180
|
-
const url = new URL(string);
|
|
181
|
-
return url.hostname + (url.port ? ':' + url.port : '');
|
|
182
|
-
}
|
|
183
|
-
function setCorsHeaders(req, res) {
|
|
184
|
-
const corsOrigin = new URL(req.headers.origin).origin;
|
|
185
|
-
res.setHeader('Vary', 'Origin');
|
|
186
|
-
res.setHeader('Access-Control-Allow-Origin', corsOrigin);
|
|
187
|
-
res.setHeader('Access-Control-Allow-Headers', '*');
|
|
188
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE');
|
|
189
|
-
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
190
192
|
}
|