@abtnode/router-provider 1.6.23 → 1.6.26

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
@@ -9,7 +9,7 @@ const { getProvider } = require('router-provider');
9
9
  const providerName = 'nginx';
10
10
  const Provider = getProvider(providerName);
11
11
 
12
- const provider = new Provider({ configDirectory });
12
+ const provider = new Provider({ configDir });
13
13
  provider.start();
14
14
  provider.stop();
15
15
  // ...
package/lib/base.js CHANGED
@@ -1,3 +1,5 @@
1
+ const os = require('os');
2
+
1
3
  class BaseProvider {
2
4
  constructor(name) {
3
5
  this.name = name;
@@ -34,6 +36,18 @@ class BaseProvider {
34
36
  rotateLogs() {
35
37
  throw new Error('rotateLogs method is not implemented');
36
38
  }
39
+
40
+ getWorkerProcess(max = +process.env.ABT_NODE_MAX_CLUSTER_SIZE) {
41
+ if (this.isTest) {
42
+ return 1;
43
+ }
44
+
45
+ if (process.env.NODE_ENV === 'production') {
46
+ return Math.min(os.cpus().length, max);
47
+ }
48
+
49
+ return 1;
50
+ }
37
51
  }
38
52
 
39
53
  module.exports = BaseProvider;
@@ -0,0 +1,316 @@
1
+ const url = require('url');
2
+ const path = require('path');
3
+ const fs = require('fs-extra');
4
+ const get = require('lodash/get');
5
+ const joinUrl = require('url-join');
6
+ const checkDomainMatch = require('@abtnode/util/lib/check-domain-match');
7
+ const { DEFAULT_IP_DNS_DOMAIN_SUFFIX, ROUTING_RULE_TYPES } = require('@abtnode/constant');
8
+
9
+ const logger = require('@abtnode/logger')('router:default:daemon', { filename: 'engine' });
10
+
11
+ const { findCertificate, isSpecificDomain, toSlotDomain, matchRule } = require('../util');
12
+ const ProxyServer = require('./proxy');
13
+
14
+ const configPath = process.env.ABT_NODE_ROUTER_CONFIG;
15
+ const httpPort = +process.env.ABT_NODE_ROUTER_HTTP_PORT;
16
+ const httpsPort = +process.env.ABT_NODE_ROUTER_HTTPS_PORT;
17
+ const servicePort = +process.env.ABT_NODE_SERVICE_PORT;
18
+
19
+ if (fs.existsSync(configPath) === false) {
20
+ throw new Error('Router config file not found');
21
+ }
22
+
23
+ const wwwDir = path.join(path.dirname(configPath), 'www');
24
+ const fileIndex = fs.readFileSync(path.join(wwwDir, 'index.html')).toString();
25
+ const file404 = fs.readFileSync(path.join(wwwDir, '404.html')).toString();
26
+ const file5xx = fs.readFileSync(path.join(wwwDir, '5xx.html')).toString();
27
+ const file502 = fs.readFileSync(path.join(wwwDir, '502.html')).toString();
28
+
29
+ process.on('message', (msg) => {
30
+ if (msg.data === 'reload') {
31
+ logger.info('update config', msg);
32
+ updateConfig(true);
33
+ }
34
+ });
35
+
36
+ const config = { sites: [], rules: {}, certs: [], headers: [] };
37
+
38
+ // eslint-disable-next-line
39
+ const createRequestHandler = (id) => (req, res, target) => {
40
+ const rule = config.rules[id];
41
+
42
+ if (rule.type === ROUTING_RULE_TYPES.DIRECT_RESPONSE) {
43
+ if (rule.response.contentType) {
44
+ res.writeHead(rule.response.status, { 'Content-Type': rule.response.contentType });
45
+ }
46
+
47
+ res.end(rule.response.body);
48
+
49
+ return { abort: true };
50
+ }
51
+
52
+ // Note: redirection required for relative asset loading
53
+ if (req.method === 'GET' && rule.prefix !== '/') {
54
+ const parsed = url.parse(req.url);
55
+ if (parsed.pathname === rule.prefix) {
56
+ logger.info('redirect url', { url: req.url, rule });
57
+ parsed.pathname = joinUrl(rule.prefix, '/');
58
+ res.writeHead(307, { Location: url.format(parsed) });
59
+ res.end();
60
+ return { abort: true };
61
+ }
62
+ }
63
+
64
+ if (rule.type === ROUTING_RULE_TYPES.GENERAL_PROXY) {
65
+ // we do not need rewrite for internal servers
66
+ } else {
67
+ req.url = joinUrl(rule.target, req.url.substr(rule.prefix.length));
68
+ }
69
+
70
+ if (req.url.startsWith('/') === false) {
71
+ req.url = `/${req.url}`;
72
+ }
73
+ logger.debug('transform url', { url: req.url, rule });
74
+
75
+ if (rule.did) {
76
+ req.headers['X-Blocklet-Did'] = rule.did;
77
+ }
78
+ if (rule.realDid && rule.did !== rule.realDid) {
79
+ req.headers['X-Blocklet-Real-Did'] = rule.realDid;
80
+ }
81
+
82
+ req.headers['X-Path-Prefix'] = rule.prefix;
83
+ req.headers['X-Group-Path-Prefix'] = rule.groupPrefix || '/';
84
+
85
+ if (rule.services.length) {
86
+ req.headers['X-Blocklet-Url'] = `http://127.0.0.1:${rule.port}`;
87
+ }
88
+
89
+ if (rule.ruleId) {
90
+ req.headers['X-Routing-Rule-Id'] = rule.ruleId;
91
+ }
92
+ };
93
+
94
+ // eslint-disable-next-line
95
+ const sharedResolver = (host, url, req) => {
96
+ // match slot domain
97
+ const domain = toSlotDomain(host);
98
+
99
+ // match specific domain
100
+ let site = config.sites.find((x) => x.domain === domain);
101
+
102
+ // match wildcard domain
103
+ if (!site) {
104
+ site = config.sites.find((x) => checkDomainMatch(x.domain, domain));
105
+ }
106
+
107
+ // fallback to default domain
108
+ if (!site) {
109
+ site = config.sites.find((x) => x.domain === '_');
110
+ }
111
+
112
+ const rule = matchRule(site.rules, url);
113
+ if (!rule) {
114
+ logger.warn('rule not found for request', { host, url, domain });
115
+ return null;
116
+ }
117
+
118
+ const match = findCertificate(config.certs, domain);
119
+ const upstream = `http://127.0.0.1:${rule.services.length ? servicePort : rule.port}`;
120
+ logger.debug('resolved request', { host, url, domain, upstream, rule });
121
+
122
+ return {
123
+ id: rule.id,
124
+ url: upstream,
125
+ path: '/',
126
+ opts: {
127
+ ssl: match ? { key: match.privateKey, cert: match.certificate } : null,
128
+ onRequest: createRequestHandler(rule.id),
129
+ },
130
+ };
131
+ };
132
+ sharedResolver.priority = 200;
133
+
134
+ const onError = (err, req, res) => {
135
+ logger.error('proxy error', { error: err });
136
+ if (typeof res.writeHead !== 'function') {
137
+ return;
138
+ }
139
+
140
+ if (err.code === 'ECONNREFUSED') {
141
+ res.writeHead(502);
142
+ res.end(file502);
143
+ } else if (err.code === 'NOTFOUND') {
144
+ const parsed = url.parse(req.url);
145
+ if (config.info.enableWelcomePage && parsed.pathname === '/') {
146
+ res.writeHead(404);
147
+ res.end(fileIndex);
148
+ } else {
149
+ res.writeHead(404);
150
+ res.end(file404);
151
+ }
152
+ } else {
153
+ res.writeHead(500);
154
+ res.end(file5xx);
155
+ }
156
+ };
157
+
158
+ const corsHandler = (host, req, res) => {
159
+ if (req.method === 'OPTIONS') {
160
+ const domain = toSlotDomain(host);
161
+ const site = config.sites.find((x) => x.domain === domain);
162
+ if (!site) {
163
+ return true;
164
+ }
165
+
166
+ const allowedOrigins = site.corsAllowedOrigins;
167
+ const currentOrigin = req.headers.origin;
168
+ if (allowedOrigins.includes('*')) {
169
+ res.writeHead(204, {
170
+ Vary: 'Origin',
171
+ 'Access-Control-Allow-Origin': '*',
172
+ 'Access-Control-Allow-Credentials': false,
173
+ 'Access-Control-Allow-Methods': 'POST, GET, HEAD, PUT, DELETE, OPTIONS',
174
+ 'Access-Control-Allow-Headers':
175
+ 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,$http_access_control_request_headers',
176
+ 'Access-Control-Max-Age': 1800,
177
+ });
178
+ res.end();
179
+ return false;
180
+ }
181
+
182
+ if (allowedOrigins.some((x) => checkDomainMatch(x, currentOrigin))) {
183
+ res.writeHead(204, {
184
+ Vary: 'Origin',
185
+ 'Access-Control-Allow-Origin': currentOrigin,
186
+ 'Access-Control-Allow-Credentials': false,
187
+ 'Access-Control-Allow-Methods': 'POST, GET, HEAD, PUT, DELETE, OPTIONS',
188
+ 'Access-Control-Allow-Headers':
189
+ 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,$http_access_control_request_headers',
190
+ 'Access-Control-Max-Age': 1800,
191
+ });
192
+ res.end();
193
+ return false;
194
+ }
195
+ }
196
+
197
+ return true;
198
+ };
199
+
200
+ // internal servers
201
+ const internalServers = {};
202
+ const ensureInternalServer = (port) => {
203
+ if (internalServers[port]) {
204
+ return;
205
+ }
206
+
207
+ const server = new ProxyServer({ xfwd: false, internal: true, port, headers: config.headers, onError, corsHandler });
208
+ server.addResolver(sharedResolver);
209
+ internalServers[port] = server;
210
+ logger.info('internal server ready on port', { port });
211
+ };
212
+
213
+ const updateConfig = (reload = false) => {
214
+ const { sites, certificates: rawCerts, info } = fs.readJsonSync(configPath);
215
+ config.info = info;
216
+
217
+ const obj = get(info, 'routing.headers', {});
218
+ config.headers = Object.keys(obj).map((key) => ({ key, value: JSON.parse(obj[key]) }));
219
+
220
+ // format certificates
221
+ config.certs = rawCerts.map((x) => {
222
+ x.privateKey = Buffer.from(x.privateKey);
223
+ x.certificate = Buffer.from(x.certificate);
224
+ return x;
225
+ });
226
+
227
+ // format sites
228
+ sites.forEach((site) => {
229
+ site.rules.forEach((rule) => {
230
+ config.rules[rule.id] = rule;
231
+ });
232
+ });
233
+
234
+ // re-register sites in reload mode
235
+ if (reload) {
236
+ const oldDomains = config.sites.map((x) => x.domain);
237
+ const newDomains = sites.map((x) => x.domain);
238
+
239
+ const addedDomains = newDomains.filter((x) => !oldDomains.includes(x));
240
+ for (const domain of addedDomains) {
241
+ if (isSpecificDomain(domain)) {
242
+ logger.info('register domain on reload', { domain });
243
+ const site = sites.find((x) => x.domain === domain);
244
+ const [rule] = site.rules.filter((x) => ['daemon', 'blocklet'].includes(x.type));
245
+ const match = findCertificate(config.certs, site.domain);
246
+ main.register({
247
+ src: site.domain,
248
+ target: `http://127.0.0.1:${rule.port}`,
249
+ ssl: match ? { key: match.privateKey, cert: match.certificate } : null,
250
+ onRequest: createRequestHandler(rule.id),
251
+ });
252
+ }
253
+ }
254
+
255
+ const removedDomains = oldDomains.filter((x) => !newDomains.includes(x));
256
+ for (const domain of removedDomains) {
257
+ main.unregister(domain);
258
+ logger.info('unregister domain on reload', { domain });
259
+ delete main.routing[domain];
260
+ logger.info('remove domain cache on reload', { domain });
261
+ }
262
+
263
+ config.sites.filter((x) => x.port).forEach((x) => ensureInternalServer(x.port));
264
+ }
265
+
266
+ config.sites = sites;
267
+ };
268
+
269
+ // load initial config
270
+ updateConfig();
271
+
272
+ // create main server
273
+ const defaultCert = config.certs.find((x) => x.domain.endsWith(DEFAULT_IP_DNS_DOMAIN_SUFFIX));
274
+ const main = new ProxyServer({
275
+ xfwd: true,
276
+ onError,
277
+ corsHandler,
278
+ port: httpPort,
279
+ headers: config.headers,
280
+ ssl: {
281
+ port: httpsPort,
282
+ key: defaultCert ? defaultCert.privateKey : null,
283
+ cert: defaultCert ? defaultCert.certificate : null,
284
+ opts: {
285
+ honorCipherOrder: false,
286
+ maxVersion: 'TLSv1.3',
287
+ minVersion: 'TLSv1.2',
288
+ },
289
+ },
290
+ });
291
+ main.sites = config.sites;
292
+ main.addResolver(sharedResolver);
293
+
294
+ // register sites: to ensure https certificates are loaded
295
+ config.sites.forEach((site) => {
296
+ const { domain } = site;
297
+ if (isSpecificDomain(domain) === false) {
298
+ return;
299
+ }
300
+
301
+ const [rule] = site.rules.filter((x) => ['daemon', 'blocklet'].includes(x.type));
302
+ const match = findCertificate(config.certs, domain);
303
+
304
+ logger.info('register domain on start', { domain });
305
+ main.register({
306
+ src: domain,
307
+ target: `http://127.0.0.1:${rule.port}`,
308
+ ssl: match ? { key: match.privateKey, cert: match.certificate } : null,
309
+ onRequest: createRequestHandler(rule.id),
310
+ });
311
+ });
312
+
313
+ // initialize internal servers
314
+ config.sites.filter((x) => x.port).forEach((x) => ensureInternalServer(x.port));
315
+
316
+ logger.info(`Default routing engine ready on ${httpPort} and ${httpsPort}`);
@@ -0,0 +1,221 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const shelljs = require('shelljs');
4
+ const get = require('lodash/get');
5
+ const omit = require('lodash/omit');
6
+ const sortBy = require('lodash/sortBy');
7
+ const objectHash = require('object-hash');
8
+ const promiseRetry = require('promise-retry');
9
+ const pm2 = require('@abtnode/util/lib/async-pm2');
10
+ const { PROCESS_NAME_ROUTER, DAEMON_MAX_MEM_LIMIT_IN_MB } = require('@abtnode/constant');
11
+
12
+ const logger = require('@abtnode/logger')('router:default:controller', { filename: 'engine' });
13
+
14
+ const BaseProvider = require('../base');
15
+ const {
16
+ decideHttpPort,
17
+ decideHttpsPort,
18
+ get404Template,
19
+ get502Template,
20
+ get5xxTemplate,
21
+ getWelcomeTemplate,
22
+ formatRoutingTable,
23
+ } = require('../util');
24
+
25
+ class DefaultProvider extends BaseProvider {
26
+ constructor({ configDir, httpPort, httpsPort, isTest }) {
27
+ super('default');
28
+
29
+ this.isTest = !!isTest;
30
+ this.configDir = configDir;
31
+
32
+ this.logDir = path.join(this.configDir, 'log');
33
+ this.wwwDir = path.join(this.configDir, 'www');
34
+
35
+ this.engineLog = path.join(this.logDir, 'engine.log'); // should be symbol link created by winston
36
+ this.errorLog = path.join(this.logDir, 'engine-error.log'); // should be symbol link created by winston
37
+ this.ctrlLog = path.join(this.logDir, 'controller.log'); // managed by pm2
38
+
39
+ this.configPath = path.join(this.configDir, 'config.json');
40
+
41
+ this.httpPort = decideHttpPort(httpPort);
42
+ this.httpsPort = decideHttpsPort(httpsPort);
43
+
44
+ // ensure directories
45
+ [this.configDir, this.logDir, this.wwwDir].forEach((dir) => {
46
+ if (!fs.existsSync(dir)) {
47
+ fs.mkdirSync(dir);
48
+ }
49
+ });
50
+
51
+ this.initialize();
52
+ }
53
+
54
+ // Services are not supported by default provider
55
+ update({ routingTable = [], certificates = [], nodeInfo = {} } = {}) {
56
+ this.info = nodeInfo;
57
+
58
+ logger.info('routing table:', routingTable);
59
+ this._addWwwFiles(nodeInfo);
60
+ const { sites: rawSites } = formatRoutingTable(routingTable);
61
+ const sites = sortBy(rawSites, (x) => -x.domain.length);
62
+ sites.forEach((site) => {
63
+ site.rules.forEach((rule) => {
64
+ // NOTE: we generate uniq hash from rule, because there are duplicate ruleId across different rules
65
+ rule.id = objectHash(rule);
66
+ });
67
+ });
68
+
69
+ fs.outputJSONSync(this.configPath, { sites, certificates, info: this.info }, { spaces: 2 });
70
+ }
71
+
72
+ async reload() {
73
+ await pm2.connectAsync();
74
+
75
+ // ensure daemon is live
76
+ let [info] = await pm2.describeAsync(PROCESS_NAME_ROUTER);
77
+ if (!info) {
78
+ await this.start();
79
+ [info] = await pm2.describeAsync(PROCESS_NAME_ROUTER);
80
+ logger.info('start daemon because not running', omit(info, ['pm2_env']));
81
+ } else {
82
+ logger.info('daemon already running', omit(info, ['pm2_env']));
83
+ }
84
+
85
+ // send signal to daemon
86
+ return new Promise((resolve) => {
87
+ pm2.sendDataToProcessId(info.pm2_env.pm_id, { type: 'process:msg', data: 'reload', topic: 'reload' }, (err) => {
88
+ if (err) {
89
+ logger.error('failed to reload', { error: err });
90
+ }
91
+
92
+ pm2.disconnect(resolve);
93
+ });
94
+ });
95
+ }
96
+
97
+ async start() {
98
+ await pm2.connectAsync();
99
+ await pm2.startAsync({
100
+ namespace: 'daemon',
101
+ name: PROCESS_NAME_ROUTER,
102
+ script: path.join(__dirname, 'daemon.js'),
103
+ max_memory_restart: `${get(this.info, 'runtimeConfig.daemonMaxMemoryLimit', DAEMON_MAX_MEM_LIMIT_IN_MB)}M`,
104
+ output: this.ctrlLog,
105
+ error: this.ctrlLog,
106
+ cwd: __dirname,
107
+ max_restarts: 1,
108
+ time: true,
109
+ mergeLogs: true,
110
+ execMode: 'fork', // Note: there are some issues with reloading when run in cluster mode
111
+ env: {
112
+ ABT_NODE_LOG_DIR: this.logDir, // Note: this tells logger module to write log to router log
113
+ ABT_NODE_ROUTER_CONFIG: this.configPath,
114
+ ABT_NODE_ROUTER_HTTP_PORT: this.httpPort,
115
+ ABT_NODE_ROUTER_HTTPS_PORT: this.httpsPort,
116
+ },
117
+ });
118
+ }
119
+
120
+ async restart() {
121
+ await pm2.connectAsync();
122
+ await pm2.restartAsync(PROCESS_NAME_ROUTER, { updateEnv: true });
123
+ }
124
+
125
+ async stop() {
126
+ await pm2.connectAsync();
127
+ const [info] = await pm2.describeAsync(PROCESS_NAME_ROUTER);
128
+ if (!info) {
129
+ return null;
130
+ }
131
+
132
+ const proc = await pm2.deleteAsync(PROCESS_NAME_ROUTER);
133
+ return proc;
134
+ }
135
+
136
+ initialize() {}
137
+
138
+ validateConfig() {}
139
+
140
+ rotateLogs() {}
141
+
142
+ _addWwwFiles(nodeInfo) {
143
+ const welcomePage = nodeInfo.enableWelcomePage ? getWelcomeTemplate(nodeInfo) : get404Template(nodeInfo);
144
+ fs.writeFileSync(`${this.wwwDir}/index.html`, welcomePage); // disable index.html
145
+ fs.writeFileSync(`${this.wwwDir}/404.html`, get404Template(nodeInfo));
146
+ fs.writeFileSync(`${this.wwwDir}/502.html`, get502Template(nodeInfo));
147
+ fs.writeFileSync(`${this.wwwDir}/5xx.html`, get5xxTemplate(nodeInfo));
148
+ }
149
+
150
+ getLogFilesForToday() {
151
+ return {
152
+ access: fs.realpathSync(this.engineLog),
153
+ error: fs.realpathSync(this.errorLog),
154
+ };
155
+ }
156
+ }
157
+
158
+ DefaultProvider.describe = async ({ configDir = '' } = {}) => {
159
+ const meta = {
160
+ name: 'default',
161
+ description: 'Use default to provide a lightweight routing layer for development purpose',
162
+ };
163
+
164
+ try {
165
+ // Use retry as a workaround for race-conditions between check and normal start
166
+ const result = await promiseRetry((retry) => DefaultProvider.check({ configDir }).catch(retry), { retries: 3 });
167
+ return { ...meta, ...result };
168
+ } catch (err) {
169
+ return { ...meta, error: err.message, available: false, running: false };
170
+ }
171
+ };
172
+
173
+ const getRouterStatus = async (configDir) => {
174
+ const result = {
175
+ managed: false,
176
+ pid: 0,
177
+ running: false,
178
+ };
179
+
180
+ await pm2.connectAsync();
181
+
182
+ const [info] = await pm2.describeAsync(PROCESS_NAME_ROUTER);
183
+ if (!info) {
184
+ return result;
185
+ }
186
+
187
+ result.running = true;
188
+ if (info.pm2_env.ABT_NODE_ROUTER_CONFIG.indexOf(configDir) > -1) {
189
+ result.managed = true;
190
+ result.pid = info.pid;
191
+ }
192
+
193
+ return result;
194
+ };
195
+
196
+ DefaultProvider.exists = () => true;
197
+ DefaultProvider.getStatus = getRouterStatus;
198
+ DefaultProvider.check = async ({ configDir = '' } = {}) => {
199
+ logger.info('check default provider', { configDir });
200
+ const binPath = shelljs.which('node').stdout;
201
+ const result = {
202
+ binPath,
203
+ available: true,
204
+ running: false,
205
+ managed: false,
206
+ error: '',
207
+ };
208
+
209
+ const status = await getRouterStatus(configDir);
210
+ result.managed = status.managed;
211
+ result.running = status.running;
212
+
213
+ if (status.running && !status.managed) {
214
+ result.available = false;
215
+ result.error = 'Seems the default routing engine is running, please terminate the process before try again.';
216
+ }
217
+
218
+ return result;
219
+ };
220
+
221
+ module.exports = DefaultProvider;