@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 CHANGED
@@ -1,8 +1,8 @@
1
- ## PX
1
+ # PX
2
2
 
3
- Reverse proxy to map local services to http(s) subdomains
3
+ Reverse proxy to map local services and external http(s) domains
4
4
 
5
- #### Usage
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
- #### API
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
- import { IncomingMessage, ServerResponse } from 'http';
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, Certificates>;
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
- SNICallback(domain: string, cb: Function): any;
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, stat } from 'fs/promises';
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 = await Promise.all(localCerts.map(async (dir) => {
67
- const status = await stat(join(certificatesFolder, dir));
68
- return status.isDirectory() ? dir : '';
69
- }));
70
- folders.filter(Boolean).forEach(async (rootDomain) => {
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
- // TODO sanitize string to avoid file navigation!
136
- const rootDomain = findRootDomain(domain);
137
- if (rootDomain in server.certs) {
138
- return cb(null, server.certs[rootDomain]);
135
+ const rootDomain = server.findRootDomain(domain);
136
+ if (rootDomain) {
137
+ return cb(null, rootDomain);
139
138
  }
140
- throw new Error('Not found', { cause: 404 });
139
+ cb(new Error('Not found', { cause: 404 }), null);
141
140
  },
142
141
  };
143
142
  }
144
- }
145
- function findRootDomain(domain) {
146
- let rootDomain = '';
147
- const parts = domain.split('/')[0].split('.');
148
- while (parts.length) {
149
- rootDomain = parts.join('.');
150
- if (existsSync(join(certificatesFolder, rootDomain, certificateFile))) {
151
- return rootDomain;
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
- parts.shift();
153
+ return null;
154
154
  }
155
- return '';
156
- }
157
- function setHeaders(from, to) {
158
- const headers = Object.entries(from.headers);
159
- for (const header of headers) {
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
- function handleError(error, res) {
164
- console.log(error);
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
- if (!res.headersSent) {
171
- res.writeHead(500);
172
- res.end();
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloud-cli/px",
3
- "version": "3.4.0",
3
+ "version": "3.4.2",
4
4
  "description": "Reverse Proxy",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",