@metric-im/administrate 1.0.0
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/.claude/settings.local.json +10 -0
- package/README.md +2 -0
- package/certify.mjs +155 -0
- package/config.mjs +95 -0
- package/index.mjs +4 -0
- package/multisite.mjs +327 -0
- package/package.json +28 -0
- package/synchronize.mjs +137 -0
package/README.md
ADDED
package/certify.mjs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import Acme from 'acme-client';
|
|
3
|
+
import tls from 'tls';
|
|
4
|
+
import { Config } from './config.mjs';
|
|
5
|
+
import moment from 'moment';
|
|
6
|
+
|
|
7
|
+
const MAX_AGE = 75; // days
|
|
8
|
+
const MAX_WAIT_TIME = 60; // seconds
|
|
9
|
+
|
|
10
|
+
export class Certify {
|
|
11
|
+
constructor(app,options) {
|
|
12
|
+
this.app = app;
|
|
13
|
+
this.options = options || {};
|
|
14
|
+
this.pending = {};
|
|
15
|
+
this.challenges = {};
|
|
16
|
+
this.contactEmail = undefined;
|
|
17
|
+
}
|
|
18
|
+
static async attach(app,options) {
|
|
19
|
+
const instance = new Certify(app,options);
|
|
20
|
+
instance.config = new Config();
|
|
21
|
+
if (!instance.config.data.ssl) instance.config.data.ssl = {};
|
|
22
|
+
instance.contactEmail = instance.config.profile?.email || instance.options.contactEmail;
|
|
23
|
+
instance.acme = new Acme.Client({
|
|
24
|
+
directoryUrl: Acme.directory.letsencrypt[process.env.PROFILE==='DEV'?'staging':'production'],
|
|
25
|
+
accountKey: await Acme.crypto.createPrivateKey(),
|
|
26
|
+
});
|
|
27
|
+
if (process.env.PROFILE==='DEV'){
|
|
28
|
+
Acme.setLogger((message) => {
|
|
29
|
+
console.log(message);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
app.use('/',instance.routes());
|
|
33
|
+
return instance;
|
|
34
|
+
}
|
|
35
|
+
get SNI() {
|
|
36
|
+
return {SNICallback: async (hostname, cb) => {
|
|
37
|
+
try {
|
|
38
|
+
console.log(`SNL get keys for ${hostname}`)
|
|
39
|
+
const siteKeys = await this.getSiteKeys(hostname);
|
|
40
|
+
cb(null, tls.createSecureContext(siteKeys));
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error(`Error loading certificate for ${hostname}:`, error);
|
|
43
|
+
cb(error);
|
|
44
|
+
}
|
|
45
|
+
}}
|
|
46
|
+
}
|
|
47
|
+
routes() {
|
|
48
|
+
const router = express.Router();
|
|
49
|
+
|
|
50
|
+
router.get(/^\/\.well-known\/acme-challenge\/([^\/]+)$/,(req,res)=>{
|
|
51
|
+
const token = req.params[0];
|
|
52
|
+
if (token in this.challenges) {
|
|
53
|
+
res.writeHead(200);
|
|
54
|
+
res.end(this.challenges[token]);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// Don't redirect ACME challenges - return 404 instead (says Claude, used to be 302)
|
|
58
|
+
res.writeHead(404);
|
|
59
|
+
res.end('Challenge not found');
|
|
60
|
+
});
|
|
61
|
+
// router.use('/',(req, res, next) => {
|
|
62
|
+
// if (!req.secure && process.env.FORCE_HTTPS?.toLowerCase() !== 'false') {
|
|
63
|
+
// return res.redirect(`https://${req.hostname}${req.url}`);
|
|
64
|
+
// }
|
|
65
|
+
// next();
|
|
66
|
+
// });
|
|
67
|
+
return router;
|
|
68
|
+
}
|
|
69
|
+
async getSiteKeys(sitename) {
|
|
70
|
+
// For localhost and local domains, don't try to get Let's Encrypt certificates
|
|
71
|
+
if (sitename === 'localhost' || sitename.includes('.local') || sitename.match(/^\d+\.\d+\.\d+\.\d+$/)) {
|
|
72
|
+
return null; // Use default certificate
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const site = this.config.data.ssl ? this.config.data.ssl[sitename] : undefined;
|
|
76
|
+
if (!site?.certified || moment().isAfter(moment(site.certified).add(MAX_AGE, 'days'))) {
|
|
77
|
+
if (!this.pending[sitename] || moment().isAfter(moment(this.pending[sitename]).add(MAX_WAIT_TIME, 'seconds'))) {
|
|
78
|
+
try {
|
|
79
|
+
this.pending[sitename] = moment();
|
|
80
|
+
setTimeout(async () => {
|
|
81
|
+
try {
|
|
82
|
+
await this.renewCert(sitename);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
console.error(`Failed to renew certificate for ${sitename}:`, e);
|
|
85
|
+
delete this.pending[sitename];
|
|
86
|
+
}
|
|
87
|
+
},10);
|
|
88
|
+
} catch(e) {
|
|
89
|
+
console.error(e);
|
|
90
|
+
delete this.pending[sitename];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (site) {
|
|
95
|
+
const key = this.config.readFile(site.key).toString();
|
|
96
|
+
const cert = this.config.readFile(site.cert).toString();
|
|
97
|
+
return {key: key, cert: cert};
|
|
98
|
+
} else {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async renewCert(sitename) {
|
|
103
|
+
const site = this.config.data.ssl ? this.config.data.ssl[sitename] : undefined;
|
|
104
|
+
if (site?.certified && moment().isBefore(moment(site.certified).add(MAX_AGE, 'days'))) {
|
|
105
|
+
return;
|
|
106
|
+
} else {
|
|
107
|
+
if (!this.contactEmail) throw new Error(`cannot request certificate without CONTACT_EMAIL set`);
|
|
108
|
+
this.pending[sitename] = true;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
// Add timeout wrapper for the entire certificate renewal process
|
|
112
|
+
await Promise.race([
|
|
113
|
+
this.doRenewCert(sitename),
|
|
114
|
+
new Promise((_, reject) =>
|
|
115
|
+
setTimeout(() => reject(new Error(`Certificate renewal timeout for ${sitename}`)), 120000) // 2 minute timeout
|
|
116
|
+
)
|
|
117
|
+
]);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.error(`Certificate renewal failed for ${sitename}:`, error);
|
|
120
|
+
delete this.pending[sitename];
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async doRenewCert(sitename) {
|
|
127
|
+
// create CSR
|
|
128
|
+
const [key, csr] = await Acme.crypto.createCsr({
|
|
129
|
+
altNames: [sitename],
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// order certificate with timeout
|
|
133
|
+
const cert = await this.acme.auto({
|
|
134
|
+
csr,
|
|
135
|
+
email: this.contactEmail,
|
|
136
|
+
termsOfServiceAgreed: true,
|
|
137
|
+
challengePriority: ['http-01'],
|
|
138
|
+
challengeCreateFn: (authz, challenge, keyAuthorization) => {
|
|
139
|
+
this.challenges[challenge.token] = keyAuthorization;
|
|
140
|
+
},
|
|
141
|
+
challengeRemoveFn: (authz, challenge) => {
|
|
142
|
+
delete this.challenges[challenge.token];
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// save certificate
|
|
147
|
+
this.config.writeFile(sitename+'.cert', cert);
|
|
148
|
+
this.config.writeFile(sitename+'.key', key.toString());
|
|
149
|
+
this.config.data.ssl[sitename] = {key:sitename+'.key', cert:sitename+'.cert',certified:moment().format("YYYY-MM-DD")};
|
|
150
|
+
delete this.pending[sitename];
|
|
151
|
+
this.config.save();
|
|
152
|
+
|
|
153
|
+
console.log(`Certificate successfully renewed for ${sitename}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
package/config.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import ini from 'ini';
|
|
3
|
+
import {dirname, join, resolve} from "path";
|
|
4
|
+
import * as domain from "node:domain";
|
|
5
|
+
|
|
6
|
+
export class Config {
|
|
7
|
+
constructor(rootName) {
|
|
8
|
+
this.rootName = rootName || 'metric-im';
|
|
9
|
+
this.homeDir = (process.platform === 'win32' ? process.env.USERPROFILE : process.env.HOME);
|
|
10
|
+
this.configDir = join(this.homeDir, '.' + this.rootName);
|
|
11
|
+
this.configFile = join(this.configDir, 'config.ini');
|
|
12
|
+
if (!fs.existsSync(this.configFile)) {
|
|
13
|
+
this.initialize();
|
|
14
|
+
} else {
|
|
15
|
+
this.load();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
initialize() {
|
|
20
|
+
this.data = ini.decode("");
|
|
21
|
+
if (!fs.existsSync(this.configDir)) {
|
|
22
|
+
fs.mkdirSync(this.configDir);
|
|
23
|
+
}
|
|
24
|
+
this.save()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
load() {
|
|
28
|
+
let fileData = fs.readFileSync(this.configFile);
|
|
29
|
+
this.data = ini.decode(fileData.toString());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
save() {
|
|
33
|
+
let text = ini.stringify(this.data, {});
|
|
34
|
+
fs.writeFileSync(this.configFile, text);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
readFile(filename) {
|
|
38
|
+
return fs.readFileSync(join(this.configDir, filename));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
writeFile(filename, data) {
|
|
42
|
+
fs.writeFileSync(join(this.configDir, filename), data);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class DomainConfig extends Config {
|
|
47
|
+
constructor(rootName,domain) {
|
|
48
|
+
super(rootName);
|
|
49
|
+
this.domain = domain;
|
|
50
|
+
this.domainData = {};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
setDomain(domain) {
|
|
54
|
+
this.domain = domain;
|
|
55
|
+
this.domainConfigDir = join(this.configDir, domain);
|
|
56
|
+
this.domainConfigFile = join(this.domainConfigDir, 'config.ini');
|
|
57
|
+
if (!fs.existsSync(this.domainConfigDir)) {
|
|
58
|
+
fs.mkdirSync(this.domainConfigDir);
|
|
59
|
+
fs.writeFileSync(this.domainConfigFile,JSON.stringify([]));
|
|
60
|
+
}
|
|
61
|
+
this.load();
|
|
62
|
+
}
|
|
63
|
+
getDomain(domain) {
|
|
64
|
+
let filename = join(this.configDir, domain, 'config.ini');
|
|
65
|
+
if (fs.existsSync(filename)) {
|
|
66
|
+
const text = fs.readFileSync(filename);
|
|
67
|
+
return ini.decode(text.toString());
|
|
68
|
+
} else {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
load() {
|
|
73
|
+
if (this.domain) {
|
|
74
|
+
const text = fs.readFileSync(this.domainConfigFile);
|
|
75
|
+
this.domainData = ini.decode(text.toString());
|
|
76
|
+
}
|
|
77
|
+
super.load();
|
|
78
|
+
}
|
|
79
|
+
save() {
|
|
80
|
+
if (this.domain) {
|
|
81
|
+
const text = ini.stringify(this.domainData);
|
|
82
|
+
fs.writeFileSync(this.domainConfigFile, text);
|
|
83
|
+
}
|
|
84
|
+
super.save();
|
|
85
|
+
}
|
|
86
|
+
readFile(filename) {
|
|
87
|
+
if (this.domain) return fs.readFileSync(join(this.domainConfigDir, filename));
|
|
88
|
+
else return super.readFile(filename);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
writeFile(filename, data) {
|
|
92
|
+
if (this.domain) fs.writeFileSync(join(this.domainConfigDir, filename), data);
|
|
93
|
+
else return super.writeFile(filename);
|
|
94
|
+
}
|
|
95
|
+
}
|
package/index.mjs
ADDED
package/multisite.mjs
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import tls from 'tls';
|
|
3
|
+
import { Config } from './config.mjs';
|
|
4
|
+
import moment from 'moment';
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import {resolve} from "path";
|
|
7
|
+
import child_process from "child_process";
|
|
8
|
+
import axios from 'axios';
|
|
9
|
+
|
|
10
|
+
const MAX_AGE = 75; // days
|
|
11
|
+
const MAX_WAIT_TIME = 60; // seconds
|
|
12
|
+
|
|
13
|
+
export class MultiSite {
|
|
14
|
+
constructor(app,options) {
|
|
15
|
+
this.app = app;
|
|
16
|
+
this.options = options || {};
|
|
17
|
+
this.sites = {};
|
|
18
|
+
this._spawnPort = (parseInt(this.options.spawnPort||0) || 53874);
|
|
19
|
+
this.usedPorts = new Set();
|
|
20
|
+
this.healthCheckIntervals = new Map();
|
|
21
|
+
this.setupCleanup();
|
|
22
|
+
}
|
|
23
|
+
get spawnPort() {
|
|
24
|
+
const port = this._spawnPort++;
|
|
25
|
+
this.usedPorts.add(port);
|
|
26
|
+
return port;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
setupCleanup() {
|
|
30
|
+
process.on('SIGTERM', () => this.cleanup());
|
|
31
|
+
process.on('SIGINT', () => this.cleanup());
|
|
32
|
+
process.on('exit', () => this.cleanup());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async cleanup() {
|
|
36
|
+
console.log('Cleaning up spawned processes...');
|
|
37
|
+
const promises = Object.values(this.sites).map(async (site) => {
|
|
38
|
+
if (site.proc && !site.proc.killed) {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
const timeout = setTimeout(() => {
|
|
41
|
+
if (!site.proc.killed) {
|
|
42
|
+
console.log(`Force killing ${site.name} after timeout`);
|
|
43
|
+
site.proc.kill('SIGKILL');
|
|
44
|
+
}
|
|
45
|
+
resolve();
|
|
46
|
+
}, 10000); // 10 second timeout for graceful shutdown
|
|
47
|
+
|
|
48
|
+
site.proc.kill('SIGTERM');
|
|
49
|
+
|
|
50
|
+
site.proc.on('exit', () => {
|
|
51
|
+
console.log(`${site.name} process terminated gracefully`);
|
|
52
|
+
clearTimeout(timeout);
|
|
53
|
+
resolve();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await Promise.all(promises.filter(Boolean));
|
|
60
|
+
|
|
61
|
+
// Clear all health check intervals
|
|
62
|
+
this.healthCheckIntervals.forEach(interval => clearInterval(interval));
|
|
63
|
+
this.healthCheckIntervals.clear();
|
|
64
|
+
|
|
65
|
+
console.log('All spawned processes cleaned up');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
startHealthCheck(site) {
|
|
69
|
+
const intervalId = setInterval(async () => {
|
|
70
|
+
if (!site.proc || site.proc.killed) {
|
|
71
|
+
clearInterval(intervalId);
|
|
72
|
+
this.healthCheckIntervals.delete(site.name);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
await axios.get(`http://127.0.0.1:${site.options.env.PORT}/health`, {
|
|
78
|
+
timeout: 5000,
|
|
79
|
+
validateStatus: () => true
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
|
|
83
|
+
console.log(`Health check failed for ${site.name}, marking for restart...`);
|
|
84
|
+
clearInterval(intervalId);
|
|
85
|
+
this.healthCheckIntervals.delete(site.name);
|
|
86
|
+
|
|
87
|
+
// Mark the process as failed for cleanup
|
|
88
|
+
if (site.proc && !site.proc.killed) {
|
|
89
|
+
site.proc.kill('SIGTERM');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}, 30000); // Check every 30 seconds
|
|
94
|
+
|
|
95
|
+
this.healthCheckIntervals.set(site.name, intervalId);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
restartSite(domain) {
|
|
99
|
+
const oldSite = this.sites[domain];
|
|
100
|
+
if (oldSite) {
|
|
101
|
+
// Clear health check
|
|
102
|
+
const intervalId = this.healthCheckIntervals.get(oldSite.name);
|
|
103
|
+
if (intervalId) {
|
|
104
|
+
clearInterval(intervalId);
|
|
105
|
+
this.healthCheckIntervals.delete(oldSite.name);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Kill old process
|
|
109
|
+
if (oldSite.proc && !oldSite.proc.killed) {
|
|
110
|
+
oldSite.proc.kill('SIGTERM');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Remove from used ports
|
|
114
|
+
this.usedPorts.delete(oldSite.options.env.PORT);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Create new site
|
|
118
|
+
this.sites[domain] = Site.Clone(domain, this);
|
|
119
|
+
console.log(`Restarted site for domain: ${domain}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
removeDeadSites() {
|
|
123
|
+
Object.keys(this.sites).forEach(domain => {
|
|
124
|
+
const site = this.sites[domain];
|
|
125
|
+
if (site.proc && site.proc.killed) {
|
|
126
|
+
this.usedPorts.delete(site.options.env.PORT);
|
|
127
|
+
delete this.sites[domain];
|
|
128
|
+
console.log(`Removed dead site: ${domain}`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
static async attach(app,options) {
|
|
133
|
+
const instance = new MultiSite(app,options);
|
|
134
|
+
instance.config = new Config();
|
|
135
|
+
// spawn declared sites
|
|
136
|
+
if (fs.existsSync(resolve('./sites'))) {
|
|
137
|
+
instance.sites = (fs.readdirSync(resolve('./sites'))).reduce((result,hostName)=>{
|
|
138
|
+
const domainName = Site.GetId(hostName);
|
|
139
|
+
const options = {
|
|
140
|
+
cwd: resolve(`./sites/${hostName}`),
|
|
141
|
+
env: {PORT:instance.spawnPort,meta:instance.options}
|
|
142
|
+
};
|
|
143
|
+
result[domainName] = Site.Spawn(domainName,options,instance);
|
|
144
|
+
return result;
|
|
145
|
+
},{});
|
|
146
|
+
}
|
|
147
|
+
app.use('/',instance.routes());
|
|
148
|
+
return instance;
|
|
149
|
+
}
|
|
150
|
+
routes() {
|
|
151
|
+
const router = express.Router();
|
|
152
|
+
|
|
153
|
+
router.all(/.*/, async (req, res) => {
|
|
154
|
+
const domain = Site.GetId(req.hostname);
|
|
155
|
+
const site = this.sites[domain];
|
|
156
|
+
if (site) {
|
|
157
|
+
let target = `http://127.0.0.1:${site.options.env.PORT}${req.url}`;
|
|
158
|
+
const method = req.method;
|
|
159
|
+
const isBodyMethod = ['POST', 'PUT', 'PATCH'].includes(method);
|
|
160
|
+
const payload = isBodyMethod ? req.body : null;
|
|
161
|
+
|
|
162
|
+
// Debug logging for POST requests
|
|
163
|
+
if (method === 'POST') {
|
|
164
|
+
console.log(`[Proxy] ${method} ${target}`);
|
|
165
|
+
console.log(`[Proxy] Request body:`, req.body);
|
|
166
|
+
console.log(`[Proxy] Payload:`, payload);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const headersToForward = {
|
|
170
|
+
...req.headers,
|
|
171
|
+
'content-length': undefined // Let Axios compute
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
// Determine if this is a binary file request
|
|
177
|
+
const isBinaryRequest = /\.(woff2?|ttf|eot|otf|ico|png|jpe?g|gif|svg|pdf|zip|exe)(\?.*)?$/i.test(req.url);
|
|
178
|
+
|
|
179
|
+
if (isBinaryRequest) {
|
|
180
|
+
// Use streaming for binary files to avoid corruption
|
|
181
|
+
const response = await axios({
|
|
182
|
+
method,
|
|
183
|
+
url: target,
|
|
184
|
+
headers: headersToForward,
|
|
185
|
+
data: payload,
|
|
186
|
+
params: req.query,
|
|
187
|
+
validateStatus: () => true,
|
|
188
|
+
timeout: 30000,
|
|
189
|
+
responseType: 'stream'
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
console.log(`${response.status} ${target} (binary stream)`);
|
|
193
|
+
|
|
194
|
+
// Set headers without the problematic ones
|
|
195
|
+
const cleanHeaders = {...response.headers};
|
|
196
|
+
delete cleanHeaders['transfer-encoding'];
|
|
197
|
+
delete cleanHeaders['content-encoding'];
|
|
198
|
+
|
|
199
|
+
res.status(response.status).set(cleanHeaders);
|
|
200
|
+
response.data.pipe(res);
|
|
201
|
+
} else {
|
|
202
|
+
// Keep existing text/json handling
|
|
203
|
+
const response = await axios({
|
|
204
|
+
method,
|
|
205
|
+
url: target,
|
|
206
|
+
headers: headersToForward,
|
|
207
|
+
data: payload,
|
|
208
|
+
params: req.query,
|
|
209
|
+
validateStatus: () => true,
|
|
210
|
+
timeout: 30000,
|
|
211
|
+
responseType: 'text'
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
console.log(`${response.status} ${target}`);
|
|
215
|
+
|
|
216
|
+
const cleanHeaders = {...response.headers};
|
|
217
|
+
delete cleanHeaders['transfer-encoding'];
|
|
218
|
+
delete cleanHeaders['content-encoding'];
|
|
219
|
+
|
|
220
|
+
res.status(response.status).set(cleanHeaders).send(response.data);
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.error(`[Proxy] Error connecting to ${target}:`, error.message);
|
|
224
|
+
|
|
225
|
+
// // Clean up dead processes
|
|
226
|
+
// this.removeDeadSites();
|
|
227
|
+
//
|
|
228
|
+
// // Try to respawn the site if it's down
|
|
229
|
+
// if (error.code === 'ECONNREFUSED') {
|
|
230
|
+
// console.log(`[Proxy] Attempting to respawn site for ${domain}`);
|
|
231
|
+
// this.sites[domain] = Site.Clone(domain, this);
|
|
232
|
+
// return res.status(503).send('Service temporarily unavailable. Please try again in a few seconds.');
|
|
233
|
+
// }
|
|
234
|
+
|
|
235
|
+
res.status(502).send('Bad Gateway. Try reloading.');
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
this.sites[domain] = Site.Clone(domain,this);
|
|
239
|
+
// Retry the request after spawning
|
|
240
|
+
setTimeout(() => {
|
|
241
|
+
res.redirect(`//${req.hostname}${req.url}`);
|
|
242
|
+
}, 2000);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
return router;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
export class Site {
|
|
249
|
+
constructor(name,options={},parent) {
|
|
250
|
+
this.name = name;
|
|
251
|
+
this.options = options;
|
|
252
|
+
this.parent = parent;
|
|
253
|
+
}
|
|
254
|
+
static Spawn(name, options, parent) {
|
|
255
|
+
const instance = new Site(name, options, parent);
|
|
256
|
+
instance.spawn();
|
|
257
|
+
return instance;
|
|
258
|
+
}
|
|
259
|
+
static Clone(name, multisite) {
|
|
260
|
+
const options = {
|
|
261
|
+
cwd: process.cwd(),
|
|
262
|
+
env: {PORT:multisite.spawnPort,meta:multisite.options}
|
|
263
|
+
};
|
|
264
|
+
const instance = new Site(name,options);
|
|
265
|
+
instance.spawn();
|
|
266
|
+
return instance;
|
|
267
|
+
}
|
|
268
|
+
spawn() {
|
|
269
|
+
let commands = (['run', 'start', this.name]);
|
|
270
|
+
console.log(`Spawning site ${this.name} on port ${this.options.env.PORT}`);
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
this.proc = child_process.spawn('npm', commands, this.options);
|
|
274
|
+
|
|
275
|
+
this.proc.stdout.on('data', (data) => {
|
|
276
|
+
process.stdout.write(`${this.name}: ${data.toString()}`);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
this.proc.stderr.on('data', (data) => {
|
|
280
|
+
process.stdout.write(`${this.name}:E: ${data.toString()}`);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
this.proc.on('close', (code) => {
|
|
284
|
+
console.log(`${this.name}: process exited with code ${code}`);
|
|
285
|
+
this.proc = null;
|
|
286
|
+
|
|
287
|
+
// Clear health check when process exits
|
|
288
|
+
if (this.healthCheckIntervals) {
|
|
289
|
+
const intervalId = this.healthCheckIntervals.get(this.name);
|
|
290
|
+
if (intervalId) {
|
|
291
|
+
clearInterval(intervalId);
|
|
292
|
+
this.healthCheckIntervals.delete(this.name);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
this.proc.on('error', (err) => {
|
|
298
|
+
console.error(`${this.name}: Failed to start process:`, err);
|
|
299
|
+
this.proc = null;
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Give the process time to start, then begin health checks
|
|
303
|
+
setTimeout(() => {
|
|
304
|
+
if (this.proc && !this.proc.killed) {
|
|
305
|
+
console.log(`${this.name}: Successfully started on port ${this.options.env.PORT}`);
|
|
306
|
+
|
|
307
|
+
// Start health monitoring after a delay to allow process to fully start
|
|
308
|
+
if (this.startHealthCheck) {
|
|
309
|
+
setTimeout(() => {
|
|
310
|
+
if (this.proc && !this.proc.killed) {
|
|
311
|
+
this.startHealthCheck(this);
|
|
312
|
+
}
|
|
313
|
+
}, 5000); // Wait 5 seconds before starting health checks
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}, 1000);
|
|
317
|
+
|
|
318
|
+
} catch (err) {
|
|
319
|
+
console.error(`${this.name}: Exception while spawning:`, err);
|
|
320
|
+
this.proc = null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
static GetId(hostName="") {
|
|
324
|
+
if (hostName.match(/^[\d]{1,3}\.[\d]{1,3}\.[\d]{1,3}\.[\d]{1,3}$/)) return null
|
|
325
|
+
return hostName.toLowerCase().replace(/[^a-z0-9-]+/g,'_');
|
|
326
|
+
}
|
|
327
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@metric-im/administrate",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Tools for site administration",
|
|
5
|
+
"homepage": "https://github.com/metric-im/administrate#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/metric-im/administrate/issues"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/metric-im/administrate.git"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Michael Sprague",
|
|
15
|
+
"type": "commonjs",
|
|
16
|
+
"main": "index.mjs",
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@metric-im/componentry": "^1.4.4",
|
|
22
|
+
"acme-client": "^5.4.0",
|
|
23
|
+
"axios": "^1.7.0",
|
|
24
|
+
"express": "^5.1.0",
|
|
25
|
+
"ini": "^5.0.0",
|
|
26
|
+
"moment": "^2.30.1"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/synchronize.mjs
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronize
|
|
3
|
+
*
|
|
4
|
+
* Open a web hook, or monitor a branch, so that the app running this
|
|
5
|
+
* code will automatically remain in sync with the given branch of the
|
|
6
|
+
* given repository on Github.
|
|
7
|
+
*
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import express from 'express';
|
|
11
|
+
import {resolve} from "path";
|
|
12
|
+
import {spawn, exec} from 'child_process';
|
|
13
|
+
|
|
14
|
+
export class Synchronize {
|
|
15
|
+
constructor(branch) {
|
|
16
|
+
this.repositoryPath = this.discoverRepositoryPath();
|
|
17
|
+
this.branch = branch || 'main';
|
|
18
|
+
this.appName = this.getAppName();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static attach(app,branch) {
|
|
22
|
+
const instance = new Synchronize(branch);
|
|
23
|
+
app.use('/',instance.routes());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
discoverRepositoryPath() {
|
|
27
|
+
try {
|
|
28
|
+
const packageJsonPath = resolve(process.cwd(), 'package.json');
|
|
29
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
30
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
31
|
+
if (packageJson.repository) {
|
|
32
|
+
return process.cwd();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.warn('Could not find repository path:', error.message);
|
|
37
|
+
}
|
|
38
|
+
return process.cwd(); // fallback to current directory
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
getAppName() {
|
|
42
|
+
try {
|
|
43
|
+
const packageJsonPath = resolve(this.repositoryPath || process.cwd(), 'package.json');
|
|
44
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
45
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
46
|
+
return packageJson.name;
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.warn('Could not extract app name from package.json:', error.message);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Fallback: extract from repository path or current directory
|
|
53
|
+
const repoPath = this.repositoryPath || process.cwd();
|
|
54
|
+
return repoPath.split('/').pop();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
routes() {
|
|
58
|
+
const router = new express.Router();
|
|
59
|
+
router.get(/^\/_update/, async (req, res) => {
|
|
60
|
+
try {
|
|
61
|
+
await this.update();
|
|
62
|
+
res.json({success: true, message: 'Update completed successfully'});
|
|
63
|
+
} catch (error) {
|
|
64
|
+
res.status(500).json({success: false, error: error.message});
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
return router;
|
|
68
|
+
}
|
|
69
|
+
async update() {
|
|
70
|
+
console.log(`Starting deployment update for ${this.repositoryPath} on branch ${this.branch}`);
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
// Pull latest changes from GitHub
|
|
74
|
+
await this.executeCommand('git', ['fetch', 'origin', this.branch]);
|
|
75
|
+
await this.executeCommand('git', ['reset', '--hard', `origin/${this.branch}`]);
|
|
76
|
+
|
|
77
|
+
// Install/update dependencies
|
|
78
|
+
await this.executeCommand('npm', ['install']);
|
|
79
|
+
|
|
80
|
+
// Restart the service using systemctl
|
|
81
|
+
console.log(`Restarting service: ${this.appName}`);
|
|
82
|
+
await this.executeCommand('sudo', ['systemctl', 'restart', this.appName]);
|
|
83
|
+
|
|
84
|
+
console.log('Deployment update completed successfully');
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error('Deployment update failed:', error.message);
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
executeCommand(command, args) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
console.log(`Executing: ${command} ${args.join(' ')}`);
|
|
94
|
+
const process = spawn(command, args, {
|
|
95
|
+
stdio: 'inherit',
|
|
96
|
+
cwd: this.repositoryPath || process.cwd()
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
process.on('close', (code) => {
|
|
100
|
+
if (code === 0) {
|
|
101
|
+
resolve();
|
|
102
|
+
} else {
|
|
103
|
+
reject(new Error(`Command "${command} ${args.join(' ')}" failed with exit code ${code}`));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
process.on('error', (error) => {
|
|
108
|
+
reject(error);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
static async ActiveBranch() {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
exec('git branch --show-current', (error, stdout, stderr) => {
|
|
115
|
+
if (error) {
|
|
116
|
+
console.error(`Error executing git command: ${error.message}`);
|
|
117
|
+
resolve()
|
|
118
|
+
}
|
|
119
|
+
if (stderr) {
|
|
120
|
+
console.error(`Git stderr: ${stderr}`);
|
|
121
|
+
resolve()
|
|
122
|
+
}
|
|
123
|
+
resolve(stdout.trim());
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
static get Package() {
|
|
128
|
+
if (!Syncrhonize._Package) {
|
|
129
|
+
let text = fs.readFileSync(resolve('./package.json'), 'utf8');
|
|
130
|
+
Syncrhonize._Package = JSON.parse(text.toString())
|
|
131
|
+
}
|
|
132
|
+
return Syncrhonize._Package;
|
|
133
|
+
}
|
|
134
|
+
static get Version() {
|
|
135
|
+
return Synchronize.Package.version;
|
|
136
|
+
}
|
|
137
|
+
}
|