@metric-im/administrate 1.0.0 → 1.0.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 -0
- package/config.mjs +1 -1
- package/multisite.mjs +108 -17
- package/package.json +1 -1
- package/.claude/settings.local.json +0 -10
package/README.md
CHANGED
|
@@ -1,2 +1,28 @@
|
|
|
1
1
|
# administrate
|
|
2
2
|
Tools for site administration
|
|
3
|
+
|
|
4
|
+
## Certify
|
|
5
|
+
Certify will detect the hostname of incoming ssl requests and present the certs. If the domain name does not have a valid cert it requests one from letsencrypt and saves the keys to $HOME/.metric-im/
|
|
6
|
+
|
|
7
|
+
```javascript
|
|
8
|
+
import https from "https";
|
|
9
|
+
import { Certify } from '@metric-im/administrate';
|
|
10
|
+
const app = express();
|
|
11
|
+
const certify = await Certify.attach(app,{contactEmail:'me@there.com'});
|
|
12
|
+
// certify.SNI return {key: xxx, cert: ...} for the current domain and/or triggers a request for a cert
|
|
13
|
+
const https_server = https.createServer({...certify.SNI},app);
|
|
14
|
+
```
|
|
15
|
+
>NOTE: Set the environment variable PROFILE=DEV to instruct acme to fetch test circuits. Use this when testing or you will be throttled.
|
|
16
|
+
|
|
17
|
+
## Multisite
|
|
18
|
+
Multisite acts as a proxy service. It listens on port 80 (4080), 443 (4443) for all web traffic and routes to the designated service. A service is identified by domain name. multisite will look for a service matching the incoming domain name in the *sites* folder. It spawns the app found with npm start and assigns it an http port. All subsquent traffic for that domain are routed to this process.
|
|
19
|
+
|
|
20
|
+
Multisite can also launch a new instance of the current app with the sanitized domain name as the first argument. In this way the same code can run multiple named apps in separate silos.
|
|
21
|
+
|
|
22
|
+
```javascript
|
|
23
|
+
const multiSite = MultiSite.attach(app);
|
|
24
|
+
```
|
|
25
|
+
See the Roots project [Harness](https;//github.com/rootz-global/harness). This is a simple host for multisite. It expects symlinks to all the apps the server responds to by domain name.
|
|
26
|
+
|
|
27
|
+
## Syncrhonize
|
|
28
|
+
Provides a web hook to github for manaing synchronization with a code branch.
|
package/config.mjs
CHANGED
|
@@ -56,7 +56,7 @@ export class DomainConfig extends Config {
|
|
|
56
56
|
this.domainConfigFile = join(this.domainConfigDir, 'config.ini');
|
|
57
57
|
if (!fs.existsSync(this.domainConfigDir)) {
|
|
58
58
|
fs.mkdirSync(this.domainConfigDir);
|
|
59
|
-
fs.writeFileSync(this.domainConfigFile
|
|
59
|
+
fs.writeFileSync(this.domainConfigFile,`name=${domain}`);
|
|
60
60
|
}
|
|
61
61
|
this.load();
|
|
62
62
|
}
|
package/multisite.mjs
CHANGED
|
@@ -18,6 +18,8 @@ export class MultiSite {
|
|
|
18
18
|
this._spawnPort = (parseInt(this.options.spawnPort||0) || 53874);
|
|
19
19
|
this.usedPorts = new Set();
|
|
20
20
|
this.healthCheckIntervals = new Map();
|
|
21
|
+
this.isShuttingDown = false;
|
|
22
|
+
this.errorLogTracker = new Map(); // Track error frequency for rate limiting
|
|
21
23
|
this.setupCleanup();
|
|
22
24
|
}
|
|
23
25
|
get spawnPort() {
|
|
@@ -27,13 +29,58 @@ export class MultiSite {
|
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
setupCleanup() {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
// Set up centralized signal handlers that host applications can use
|
|
33
|
+
this.gracefulShutdown = this.gracefulShutdown.bind(this);
|
|
34
|
+
|
|
35
|
+
// Only handle the 'exit' event for emergency cleanup
|
|
36
|
+
process.on('exit', () => {
|
|
37
|
+
// Synchronous cleanup only - no async operations allowed in 'exit'
|
|
38
|
+
console.log('Emergency cleanup on exit...');
|
|
39
|
+
Object.values(this.sites).forEach((site) => {
|
|
40
|
+
if (site.proc && !site.proc.killed) {
|
|
41
|
+
site.proc.kill('SIGKILL');
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async gracefulShutdown(signal) {
|
|
48
|
+
if (this.isShuttingDown) {
|
|
49
|
+
console.log(`Received ${signal} again, forcing exit...`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
this.isShuttingDown = true;
|
|
54
|
+
console.log(`Received ${signal}, shutting down gracefully...`);
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
console.log("Cleaning up child processes...");
|
|
58
|
+
await this.cleanup();
|
|
59
|
+
|
|
60
|
+
console.log("Graceful shutdown complete");
|
|
61
|
+
process.exit(0);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error("Error during shutdown:", error);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
setupSignalHandlers() {
|
|
69
|
+
// Method host applications can call to set up proper signal handling
|
|
70
|
+
process.on('SIGINT', () => this.gracefulShutdown('SIGINT'));
|
|
71
|
+
process.on('SIGTERM', () => this.gracefulShutdown('SIGTERM'));
|
|
72
|
+
console.log('MultiSite signal handlers installed');
|
|
33
73
|
}
|
|
34
74
|
|
|
35
75
|
async cleanup() {
|
|
36
76
|
console.log('Cleaning up spawned processes...');
|
|
77
|
+
|
|
78
|
+
// Set a global timeout for the entire cleanup process
|
|
79
|
+
const cleanupTimeout = setTimeout(() => {
|
|
80
|
+
console.log('Cleanup taking too long, forcing exit...');
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}, 15000); // 15 second total timeout
|
|
83
|
+
|
|
37
84
|
const promises = Object.values(this.sites).map(async (site) => {
|
|
38
85
|
if (site.proc && !site.proc.killed) {
|
|
39
86
|
return new Promise((resolve) => {
|
|
@@ -43,7 +90,7 @@ export class MultiSite {
|
|
|
43
90
|
site.proc.kill('SIGKILL');
|
|
44
91
|
}
|
|
45
92
|
resolve();
|
|
46
|
-
},
|
|
93
|
+
}, 5000); // Reduced to 5 second timeout per process
|
|
47
94
|
|
|
48
95
|
site.proc.kill('SIGTERM');
|
|
49
96
|
|
|
@@ -62,6 +109,7 @@ export class MultiSite {
|
|
|
62
109
|
this.healthCheckIntervals.forEach(interval => clearInterval(interval));
|
|
63
110
|
this.healthCheckIntervals.clear();
|
|
64
111
|
|
|
112
|
+
clearTimeout(cleanupTimeout);
|
|
65
113
|
console.log('All spawned processes cleaned up');
|
|
66
114
|
}
|
|
67
115
|
|
|
@@ -129,13 +177,44 @@ export class MultiSite {
|
|
|
129
177
|
}
|
|
130
178
|
});
|
|
131
179
|
}
|
|
180
|
+
|
|
181
|
+
// Rate limit error logging to prevent spam
|
|
182
|
+
shouldLogError(target, clientIP) {
|
|
183
|
+
const key = `${clientIP}:${target}`;
|
|
184
|
+
const now = Date.now();
|
|
185
|
+
const logEntry = this.errorLogTracker.get(key);
|
|
186
|
+
|
|
187
|
+
if (!logEntry) {
|
|
188
|
+
this.errorLogTracker.set(key, { count: 1, firstSeen: now, lastLogged: now });
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
logEntry.count++;
|
|
193
|
+
|
|
194
|
+
// Reset counter if it's been more than 5 minutes since first error
|
|
195
|
+
if (now - logEntry.firstSeen > 300000) {
|
|
196
|
+
logEntry.count = 1;
|
|
197
|
+
logEntry.firstSeen = now;
|
|
198
|
+
logEntry.lastLogged = now;
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Log first error, then every 10th error, but not more than once per minute
|
|
203
|
+
if (logEntry.count === 1 ||
|
|
204
|
+
(logEntry.count % 10 === 0 && now - logEntry.lastLogged > 60000)) {
|
|
205
|
+
logEntry.lastLogged = now;
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
132
211
|
static async attach(app,options) {
|
|
133
212
|
const instance = new MultiSite(app,options);
|
|
134
213
|
instance.config = new Config();
|
|
135
214
|
// spawn declared sites
|
|
136
215
|
if (fs.existsSync(resolve('./sites'))) {
|
|
137
216
|
instance.sites = (fs.readdirSync(resolve('./sites'))).reduce((result,hostName)=>{
|
|
138
|
-
const domainName = Site.
|
|
217
|
+
const domainName = Site.WashName(hostName);
|
|
139
218
|
const options = {
|
|
140
219
|
cwd: resolve(`./sites/${hostName}`),
|
|
141
220
|
env: {PORT:instance.spawnPort,meta:instance.options}
|
|
@@ -151,13 +230,14 @@ export class MultiSite {
|
|
|
151
230
|
const router = express.Router();
|
|
152
231
|
|
|
153
232
|
router.all(/.*/, async (req, res) => {
|
|
154
|
-
const domain = Site.
|
|
233
|
+
const domain = Site.WashName(req.hostname);
|
|
155
234
|
const site = this.sites[domain];
|
|
156
235
|
if (site) {
|
|
157
236
|
let target = `http://127.0.0.1:${site.options.env.PORT}${req.url}`;
|
|
158
237
|
const method = req.method;
|
|
159
238
|
const isBodyMethod = ['POST', 'PUT', 'PATCH'].includes(method);
|
|
160
239
|
const payload = isBodyMethod ? req.body : null;
|
|
240
|
+
const clientIP = req.ip || req.connection.remoteAddress || req.headers['x-forwarded-for'];
|
|
161
241
|
|
|
162
242
|
// Debug logging for POST requests
|
|
163
243
|
if (method === 'POST') {
|
|
@@ -188,14 +268,14 @@ export class MultiSite {
|
|
|
188
268
|
timeout: 30000,
|
|
189
269
|
responseType: 'stream'
|
|
190
270
|
});
|
|
191
|
-
|
|
271
|
+
|
|
192
272
|
console.log(`${response.status} ${target} (binary stream)`);
|
|
193
|
-
|
|
273
|
+
|
|
194
274
|
// Set headers without the problematic ones
|
|
195
275
|
const cleanHeaders = {...response.headers};
|
|
196
276
|
delete cleanHeaders['transfer-encoding'];
|
|
197
277
|
delete cleanHeaders['content-encoding'];
|
|
198
|
-
|
|
278
|
+
|
|
199
279
|
res.status(response.status).set(cleanHeaders);
|
|
200
280
|
response.data.pipe(res);
|
|
201
281
|
} else {
|
|
@@ -210,17 +290,25 @@ export class MultiSite {
|
|
|
210
290
|
timeout: 30000,
|
|
211
291
|
responseType: 'text'
|
|
212
292
|
});
|
|
213
|
-
|
|
293
|
+
|
|
214
294
|
console.log(`${response.status} ${target}`);
|
|
215
|
-
|
|
295
|
+
|
|
216
296
|
const cleanHeaders = {...response.headers};
|
|
217
297
|
delete cleanHeaders['transfer-encoding'];
|
|
218
298
|
delete cleanHeaders['content-encoding'];
|
|
219
|
-
|
|
299
|
+
|
|
220
300
|
res.status(response.status).set(cleanHeaders).send(response.data);
|
|
221
301
|
}
|
|
222
302
|
} catch (error) {
|
|
223
|
-
|
|
303
|
+
// Rate limit error logging to prevent spam
|
|
304
|
+
if (this.shouldLogError(target, clientIP)) {
|
|
305
|
+
const logEntry = this.errorLogTracker.get(`${clientIP}:${target}`);
|
|
306
|
+
if (logEntry && logEntry.count > 1) {
|
|
307
|
+
console.error(`${clientIP}:E: [Proxy] Error connecting to ${target}: ${error.message} (${logEntry.count} times)`);
|
|
308
|
+
} else {
|
|
309
|
+
console.error(`${clientIP}:E: [Proxy] Error connecting to ${target}: ${error.message}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
224
312
|
|
|
225
313
|
// // Clean up dead processes
|
|
226
314
|
// this.removeDeadSites();
|
|
@@ -319,9 +407,12 @@ export class Site {
|
|
|
319
407
|
console.error(`${this.name}: Exception while spawning:`, err);
|
|
320
408
|
this.proc = null;
|
|
321
409
|
}
|
|
322
|
-
|
|
323
|
-
static
|
|
324
|
-
|
|
325
|
-
|
|
410
|
+
}
|
|
411
|
+
static WashName(hostName="") {
|
|
412
|
+
if (!hostName || hostName.match(/^[\d]{1,3}\.[\d]{1,3}\.[\d]{1,3}\.[\d]{1,3}$/)) return "";
|
|
413
|
+
else return hostName.toLowerCase();
|
|
414
|
+
}
|
|
415
|
+
static SafeName(hostName) {
|
|
416
|
+
return Site.WashName(hostName).replace(/[^a-z0-9-]+/g,'_');
|
|
326
417
|
}
|
|
327
418
|
}
|
package/package.json
CHANGED