@metric-im/administrate 1.0.2 → 1.1.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 +13 -0
- package/certify.mjs +41 -21
- package/index.mjs +0 -1
- package/multisite.mjs +11 -3
- package/package.json +4 -2
- package/config.mjs +0 -95
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Read(//home/msprague/workspace/rootz/epistery/docs/**)",
|
|
5
|
+
"Read(//home/msprague/workspace/rootz/epistery/src/**)",
|
|
6
|
+
"Read(//home/msprague/workspace/rootz/epistery/**)",
|
|
7
|
+
"Read(//home/msprague/workspace/rootz/rhonda/node_modules/epistery/**)",
|
|
8
|
+
"Read(//home/msprague/workspace/rootz/rhonda/**)"
|
|
9
|
+
],
|
|
10
|
+
"deny": [],
|
|
11
|
+
"ask": []
|
|
12
|
+
}
|
|
13
|
+
}
|
package/certify.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import Acme from 'acme-client';
|
|
3
3
|
import tls from 'tls';
|
|
4
|
-
import { Config } from '
|
|
4
|
+
import { Config } from 'epistery';
|
|
5
5
|
import moment from 'moment';
|
|
6
6
|
|
|
7
7
|
const MAX_AGE = 75; // days
|
|
@@ -17,9 +17,13 @@ export class Certify {
|
|
|
17
17
|
}
|
|
18
18
|
static async attach(app,options) {
|
|
19
19
|
const instance = new Certify(app,options);
|
|
20
|
-
instance.config = new Config();
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
instance.config = new Config(); // Uses default 'epistery' root
|
|
21
|
+
|
|
22
|
+
// Load root config to get contact email
|
|
23
|
+
instance.config.setPath('/');
|
|
24
|
+
instance.config.load();
|
|
25
|
+
instance.contactEmail = instance.options.contactEmail || instance.config.data.ssl?.email || instance.config.data.profile?.email;
|
|
26
|
+
|
|
23
27
|
instance.acme = new Acme.Client({
|
|
24
28
|
directoryUrl: Acme.directory.letsencrypt[process.env.PROFILE==='DEV'?'staging':'production'],
|
|
25
29
|
accountKey: await Acme.crypto.createPrivateKey(),
|
|
@@ -71,9 +75,13 @@ export class Certify {
|
|
|
71
75
|
if (sitename === 'localhost' || sitename.includes('.local') || sitename.match(/^\d+\.\d+\.\d+\.\d+$/)) {
|
|
72
76
|
return null; // Use default certificate
|
|
73
77
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
|
|
79
|
+
// Load domain-specific config to check SSL section
|
|
80
|
+
this.config.setPath(`/${sitename}`);
|
|
81
|
+
this.config.load();
|
|
82
|
+
const sslConfig = this.config.data.ssl;
|
|
83
|
+
|
|
84
|
+
if (!sslConfig?.certified || moment().isAfter(moment(sslConfig.certified).add(MAX_AGE, 'days'))) {
|
|
77
85
|
if (!this.pending[sitename] || moment().isAfter(moment(this.pending[sitename]).add(MAX_WAIT_TIME, 'seconds'))) {
|
|
78
86
|
try {
|
|
79
87
|
this.pending[sitename] = moment();
|
|
@@ -91,27 +99,31 @@ export class Certify {
|
|
|
91
99
|
}
|
|
92
100
|
}
|
|
93
101
|
}
|
|
94
|
-
if (
|
|
95
|
-
const key = this.config.readFile(
|
|
96
|
-
const cert = this.config.readFile(
|
|
102
|
+
if (sslConfig?.key && sslConfig?.cert) {
|
|
103
|
+
const key = this.config.readFile(sslConfig.key).toString();
|
|
104
|
+
const cert = this.config.readFile(sslConfig.cert).toString();
|
|
97
105
|
return {key: key, cert: cert};
|
|
98
106
|
} else {
|
|
99
107
|
return null;
|
|
100
108
|
}
|
|
101
109
|
}
|
|
102
110
|
async renewCert(sitename) {
|
|
103
|
-
|
|
104
|
-
|
|
111
|
+
// Load domain config to check SSL certification date
|
|
112
|
+
this.config.setPath(`/${sitename}`);
|
|
113
|
+
this.config.load();
|
|
114
|
+
const sslConfig = this.config.data.ssl;
|
|
115
|
+
|
|
116
|
+
if (sslConfig?.certified && moment().isBefore(moment(sslConfig.certified).add(MAX_AGE, 'days'))) {
|
|
105
117
|
return;
|
|
106
118
|
} else {
|
|
107
119
|
if (!this.contactEmail) throw new Error(`cannot request certificate without CONTACT_EMAIL set`);
|
|
108
120
|
this.pending[sitename] = true;
|
|
109
|
-
|
|
121
|
+
|
|
110
122
|
try {
|
|
111
123
|
// Add timeout wrapper for the entire certificate renewal process
|
|
112
124
|
await Promise.race([
|
|
113
125
|
this.doRenewCert(sitename),
|
|
114
|
-
new Promise((_, reject) =>
|
|
126
|
+
new Promise((_, reject) =>
|
|
115
127
|
setTimeout(() => reject(new Error(`Certificate renewal timeout for ${sitename}`)), 120000) // 2 minute timeout
|
|
116
128
|
)
|
|
117
129
|
]);
|
|
@@ -128,7 +140,7 @@ export class Certify {
|
|
|
128
140
|
const [key, csr] = await Acme.crypto.createCsr({
|
|
129
141
|
altNames: [sitename],
|
|
130
142
|
});
|
|
131
|
-
|
|
143
|
+
|
|
132
144
|
// order certificate with timeout
|
|
133
145
|
const cert = await this.acme.auto({
|
|
134
146
|
csr,
|
|
@@ -142,14 +154,22 @@ export class Certify {
|
|
|
142
154
|
delete this.challenges[challenge.token];
|
|
143
155
|
},
|
|
144
156
|
});
|
|
145
|
-
|
|
146
|
-
// save certificate
|
|
147
|
-
this.config.
|
|
148
|
-
this.config.writeFile(
|
|
149
|
-
this.config.
|
|
157
|
+
|
|
158
|
+
// Set path to domain directory and save certificate files there
|
|
159
|
+
this.config.setPath(`/${sitename}`);
|
|
160
|
+
this.config.writeFile('ssl_cert.pem', cert);
|
|
161
|
+
this.config.writeFile('ssl_key.pem', key.toString());
|
|
162
|
+
|
|
163
|
+
// Load existing config and add/update [ssl] section
|
|
164
|
+
this.config.load();
|
|
165
|
+
if (!this.config.data.ssl) this.config.data.ssl = {};
|
|
166
|
+
this.config.data.ssl.key = 'ssl_key.pem';
|
|
167
|
+
this.config.data.ssl.cert = 'ssl_cert.pem';
|
|
168
|
+
this.config.data.ssl.certified = moment().format("YYYY-MM-DD");
|
|
169
|
+
|
|
150
170
|
delete this.pending[sitename];
|
|
151
171
|
this.config.save();
|
|
152
|
-
|
|
172
|
+
|
|
153
173
|
console.log(`Certificate successfully renewed for ${sitename}`);
|
|
154
174
|
}
|
|
155
175
|
}
|
package/index.mjs
CHANGED
package/multisite.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import tls from 'tls';
|
|
3
|
-
import { Config } from '
|
|
3
|
+
import { Config } from 'epistery';
|
|
4
4
|
import moment from 'moment';
|
|
5
5
|
import fs from "fs";
|
|
6
6
|
import {resolve} from "path";
|
|
@@ -211,6 +211,7 @@ export class MultiSite {
|
|
|
211
211
|
static async attach(app,options) {
|
|
212
212
|
const instance = new MultiSite(app,options);
|
|
213
213
|
instance.config = new Config();
|
|
214
|
+
|
|
214
215
|
// spawn declared sites
|
|
215
216
|
if (fs.existsSync(resolve('./sites'))) {
|
|
216
217
|
instance.sites = (fs.readdirSync(resolve('./sites'))).reduce((result,hostName)=>{
|
|
@@ -226,6 +227,7 @@ export class MultiSite {
|
|
|
226
227
|
app.use('/',instance.routes());
|
|
227
228
|
return instance;
|
|
228
229
|
}
|
|
230
|
+
|
|
229
231
|
routes() {
|
|
230
232
|
const router = express.Router();
|
|
231
233
|
|
|
@@ -263,7 +265,7 @@ export class MultiSite {
|
|
|
263
265
|
url: target,
|
|
264
266
|
headers: headersToForward,
|
|
265
267
|
data: payload,
|
|
266
|
-
params
|
|
268
|
+
// Don't pass params - req.url already contains query string
|
|
267
269
|
validateStatus: () => true,
|
|
268
270
|
timeout: 30000,
|
|
269
271
|
responseType: 'stream'
|
|
@@ -285,7 +287,7 @@ export class MultiSite {
|
|
|
285
287
|
url: target,
|
|
286
288
|
headers: headersToForward,
|
|
287
289
|
data: payload,
|
|
288
|
-
params
|
|
290
|
+
// Don't pass params - req.url already contains query string
|
|
289
291
|
validateStatus: () => true,
|
|
290
292
|
timeout: 30000,
|
|
291
293
|
responseType: 'text'
|
|
@@ -338,6 +340,12 @@ export class Site {
|
|
|
338
340
|
this.name = name;
|
|
339
341
|
this.options = options;
|
|
340
342
|
this.parent = parent;
|
|
343
|
+
try {
|
|
344
|
+
const envars = JSON.parse(process.env.SITE_ENV||{});
|
|
345
|
+
Object.assign(this.options.env,envars[this.name]||{});
|
|
346
|
+
} catch(e) {
|
|
347
|
+
console.log(`Unable to parse SITE_ENV... continuing: ${e.message}`);
|
|
348
|
+
}
|
|
341
349
|
}
|
|
342
350
|
static Spawn(name, options, parent) {
|
|
343
351
|
const instance = new Site(name, options, parent);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metric-im/administrate",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Tools for site administration",
|
|
5
5
|
"homepage": "https://github.com/metric-im/administrate#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
"acme-client": "^5.4.0",
|
|
23
23
|
"axios": "^1.7.0",
|
|
24
24
|
"express": "^5.1.0",
|
|
25
|
-
"ini": "^5.0.0",
|
|
26
25
|
"moment": "^2.30.1"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"epistery": "^1.0.0"
|
|
27
29
|
}
|
|
28
30
|
}
|
package/config.mjs
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
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,`name=${domain}`);
|
|
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
|
-
}
|