-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathindex.js
More file actions
329 lines (298 loc) · 10.7 KB
/
Copy pathindex.js
File metadata and controls
329 lines (298 loc) · 10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
// Require dependencies (Node native)
const http = require('http');
const tls = require('tls');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
// Monkey patching needed before spdy require
monkeyPatchDeciever();
// Non native dependencies
const httpProxy = require('http-proxy');
const spdy = require('spdy');
// Our own module (with a dependency to npm brotli)
const AsyncBrotli = require(path.join(__dirname, '/AsyncBrotli.js'));
let settings = {
httpPort: 80,
httpsPort: 443,
pathToCerts: '/etc/letsencrypt/live',
xPoweredBy: 'Love',
http2MaxChunk: 8192,
http2MaxStreams: 80,
brotliCacheMaxSizeMb: 50,
/* 1-11, initial fast response when doing brotli */
brotliFastQuality: 1,
/* 1-11, recompress better when we have time left */
brotliRecompressQuality: 11,
/* compresss if true, ct = content-type header */
brotliCompress: ct =>
ct.includes('text') ||
ct.includes('javascript') ||
ct.includes('json') ||
ct.includes('svg')
};
// cacheMemory for brotli compression
const brotliCache = {};
let brotliCacheSizeMb = 0;
// prune brotli cache if too big
function pruneBrotliCache() {
function getCacheSize() {
brotliCacheSizeMb = 0;
for (let [key, val] of Object.entries(brotliCache)) {
brotliCacheSizeMb += (key.length + val.response.length) / 1024 / 1024;
}
}
function getOldestServed() {
let when = Infinity, keyToPrune;
for (let [key, val] of Object.entries(brotliCache)) {
if (val.lastServed < when) {
when = val.lastServed;
keyToPrune = key;
}
}
return keyToPrune;
}
while (brotliCacheSizeMb > settings.brotliCacheMaxSizeMb) {
let keyToPrune = getOldestServed();
delete brotliCache[keyToPrune];
let oldSize = brotliCacheSizeMb;
getCacheSize();
}
}
function oneWayKey(key) {
return crypto.createHash('sha256').update(key).digest('hex');
}
// args -> certificateName, routes
function createHttpsServerProxy(...args) {
const brotliFastCompress = new AsyncBrotli({ quality: settings.brotliFastQuality });
const brotliRecompress = new AsyncBrotli({ quality: settings.brotliRecompressQuality });
let routes = {}, currentCertName, certNameByDomain = {};
// map certs to domains
for (let arg of args) {
if (typeof arg === 'string') { currentCertName = arg; }
else {
routes = { ...routes, ...arg };
for (let key of Object.keys(arg)) {
certNameByDomain[key] = currentCertName;
}
}
}
let defaultCertName = Object.values(certNameByDomain)[0];
// Globals
const certs = readCerts();
// Create a new reverse proxy
const proxy = httpProxy.createProxyServer({ selfHandleResponse: true });
// Necessary with Node >= 15 to get spdy to work
// (does not seem to work with default chunking from the proxy,
// but works if we send the whole response at once...)
// - this is a BIG workaround but also allows us to brotli compress
// so all in all probably good
proxy.on('proxyRes', function (proxyRes, req, res) {
var body = [];
proxyRes.on('data', function (chunk) {
body.push(chunk);
});
proxyRes.on('end', async function () {
let h = { ...proxyRes.headers };
let response = Buffer.concat(body);
let ae = req.headers['Accept-Encoding'] || req.headers['accept-encoding'];
let ct = h['Content-Type'] || h['content-type'] || '';
let en = h['Content-Encoding'] || h['content-encoding'];
if (req.method === 'GET' && !en && ae.includes('br') && ct && settings.brotliCompress(ct.toLowerCase())) {
let cacheKey = oneWayKey(response.toString());
if (brotliCache[cacheKey]) {
// in cache
response = brotliCache[cacheKey].response;
brotliCache[cacheKey].lastServed = Date.now();
h['content-encoding'] = 'br';
}
else {
let beforeCompress = response;
response = await brotliFastCompress.compress(response);
if (!response) {
// sometimes we get null from brotli.compress.
// (this happens when there is not enough data to compress...)
// so then go back to uncompressed version
response = beforeCompress;
}
else {
brotliCache[cacheKey] = { lastServed: Date.now(), response };
brotliCacheSizeMb += (cacheKey.length + response.length) / 1024 / 1024;
if (brotliCacheSizeMb > settings.brotliCacheMaxSizeMb) { pruneBrotliCache(); }
h['content-encoding'] = 'br';
// recompress at a better brotli quality when we have the time
brotliRecompress.compress(beforeCompress).then(x =>
brotliCache[cacheKey] && (brotliCache[cacheKey].response = x)
);
}
}
h['Content-Length'] && (h['Content-Length'] = response.length);
h['content-length'] && (h['content-length'] = response.length);
}
for (let [header, value] of Object.entries(h)) {
res.setHeader(header, value);
}
res.statusCode = proxyRes.statusCode;
res.end(response);
});
});
// Handle proxy errors - thus not breaking the whole
// reverse-proxy app if an app doesn't answer
proxy.on('error', function (e) {
console.log('Proxy error', Date.now(), e);
})
createServer();
// Create our servers (https with http2 and http just for redirects)
function createServer() {
// Further monkey patching of spdy, in this case
// to avoid deprecation warning on ._headers (since Node 12)
eval('spdy.response.writeHead = ' + (spdy.response.writeHead + '')
.split('this._headers')
.join('this.getHeaders ? this.getHeaders() : this._headers'));
let spdyServer = spdy.createServer({
SNICallback: lookupCert,
key: certs[defaultCertName].key,
cert: certs[defaultCertName].cert,
spdy: { maxChunk: settings.http2MaxChunk, maxStreams: settings.http2MaxStreams }
}, serveHttps).listen(settings.httpsPort);
// Without this spdy cuts off some streams.
// (The whole repsonse is not delivered without this!!!)
spdyServer.on('request', (req, res) => {
res.spdyStream && res.spdyStream.once('finish', () => res.emit('finish'));
});
// Make sockets work with the proxy
spdyServer.on('upgrade', function (req, socket, head) {
let port = routes[req.headers.host];
let host = '127.0.0.1';
proxy.ws(req, socket, head, { target: `ws://${host}:${port}` }, e => { });
});
http.createServer(serveHttp).listen(settings.httpPort);
}
function serveHttp(req, res) {
// redirect to https
let url = 'https://' + req.headers.host + req.url;
res.writeHead(301, { 'Location': url });
res.end();
}
function serveHttps(req, res) {
// Set/replace response headers
setResponseHeaders(req, res);
// Routing
let host = req.headers.host,
url = req.url,
portToUse;
url += (url.slice(-1) != '/' ? '/' : '');
for (let route in routes) {
let port = routes[route];
if (route.includes('/')) {
route += (route.slice(-1) != '/' ? '/' : '')
}
if (route == host) {
portToUse = port;
}
else if (url != '/' && (host + url).indexOf(route) == 0) {
portToUse = port;
}
}
// Redirects
if (portToUse && portToUse.redirect) {
let url = 'https://' + portToUse.redirect + req.url;
res.writeHead(301, { 'Location': url });
res.end();
}
// Serve the correct app for a domain
else if (portToUse) {
proxy.web(req, res, { target: 'http://127.0.0.1:' + portToUse });
}
else {
res.statusCode = 404;
res.end('No such url!');
}
}
function setResponseHeaders(req, res) {
// there is a built in node function called res.writeHead
// that writes http response headers
// store that function in another property
res.oldWriteHead = res.writeHead;
// and then replace it with our function
res.writeHead = function (statusCode, headers) {
// set/replace our own headers
res.setHeader('x-powered-by', settings.xPoweredBy);
// call the original write head function
return res.oldWriteHead(statusCode, headers);
}
res.oldSetHeader = res.setHeader;
res.setHeader = function (...args) {
// If any app tries to redirect to http
// rewrite the redirect so that it goes to https
// (common problem with some Java Spring apps...)
if (args[0] === 'location' && args[1].indexOf('http:') === 0) {
args[1] = args[1].replace('http:', 'https:');
}
// call the original setHeader function
return res.oldSetHeader(...args);
}
}
// read https / tls (transport layer security) certs
function readCerts() {
let pathToCerts = settings.pathToCerts;
let certs = {};
let domains = fs.readdirSync(pathToCerts);
pathToCerts.slice(-1) === '/' || (pathToCerts += '/');
domains = domains.filter(x => fs.lstatSync(pathToCerts + x).isDirectory());
for (let domain of domains) {
let domainName = domain.split('-0')[0];
certs[domainName] = {
key: fs.readFileSync(path.join(pathToCerts, domain, 'privkey.pem')),
cert: fs.readFileSync(path.join(pathToCerts, domain, 'fullchain.pem'))
};
// SecureContext is needed for SNI support
certs[domainName].secureContext = tls.createSecureContext(certs[domainName]);
}
return certs;
}
// Support for SNI (Server Name Indication)
// making it possible to handle more than one TLS cert
function lookupCert(domain, callback) {
let certName = certNameByDomain[domain];
let theCert = certs[certName];
if (!theCert) { return; }
callback(null, theCert.secureContext);
}
}
// Deceiver is part of Spdy
// and causes this deprecationWarning since Node 16:
// Access to process.binding('http_parser') is deprecated.
// -> We fix this by monkeypatching process.binding
function monkeyPatchDeciever() {
let h = {
HTTPParser: {
kOnHeaders: 1,
kOnHeadersComplete: 2,
kOnBody: 3,
kOnMessageComplete: 4,
methods: [
'DELETE', 'GET', 'HEAD',
'POST', 'PUT', 'CONNECT',
'OPTIONS', 'TRACE', 'COPY',
'LOCK', 'MKCOL', 'MOVE',
'PROPFIND', 'PROPPATCH', 'SEARCH',
'UNLOCK', 'BIND', 'REBIND',
'UNBIND', 'ACL', 'REPORT',
'MKACTIVITY', 'CHECKOUT', 'MERGE',
'M-SEARCH', 'NOTIFY', 'SUBSCRIBE',
'UNSUBSCRIBE', 'PATCH', 'PURGE',
'MKCALENDAR', 'LINK', 'UNLINK',
'SOURCE'
]
}
};
let orgBinding = process.binding;
process.binding = (...args) => {
if (args[0] === 'http_parser') { return h; }
return orgBinding.apply(process, args);
}
}
createHttpsServerProxy.settings = s => {
settings = { ...settings, ...s };
}
module.exports = createHttpsServerProxy;