@metric-im/administrate 1.0.3 → 1.1.1
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/certify.mjs +41 -20
- package/index.mjs +0 -1
- package/multisite.mjs +1 -1
- package/package.json +4 -2
- package/config.mjs +0 -95
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
|
|
@@ -18,8 +18,13 @@ export class Certify {
|
|
|
18
18
|
static async attach(app,options) {
|
|
19
19
|
const instance = new Certify(app,options);
|
|
20
20
|
instance.config = new Config();
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
|
|
22
|
+
// Load root config to get contact email
|
|
23
|
+
instance.config.setPath('/');
|
|
24
|
+
instance.config.load();
|
|
25
|
+
|
|
26
|
+
instance.contactEmail = instance.options.contactEmail || instance.config.data.ssl?.email || instance.config.data.profile?.email;
|
|
27
|
+
|
|
23
28
|
instance.acme = new Acme.Client({
|
|
24
29
|
directoryUrl: Acme.directory.letsencrypt[process.env.PROFILE==='DEV'?'staging':'production'],
|
|
25
30
|
accountKey: await Acme.crypto.createPrivateKey(),
|
|
@@ -71,9 +76,13 @@ export class Certify {
|
|
|
71
76
|
if (sitename === 'localhost' || sitename.includes('.local') || sitename.match(/^\d+\.\d+\.\d+\.\d+$/)) {
|
|
72
77
|
return null; // Use default certificate
|
|
73
78
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
|
|
80
|
+
// Load domain-specific config to check SSL section
|
|
81
|
+
this.config.setPath(`/${sitename}`);
|
|
82
|
+
this.config.load();
|
|
83
|
+
const sslConfig = this.config.data.ssl;
|
|
84
|
+
|
|
85
|
+
if (!sslConfig?.certified || moment().isAfter(moment(sslConfig.certified).add(MAX_AGE, 'days'))) {
|
|
77
86
|
if (!this.pending[sitename] || moment().isAfter(moment(this.pending[sitename]).add(MAX_WAIT_TIME, 'seconds'))) {
|
|
78
87
|
try {
|
|
79
88
|
this.pending[sitename] = moment();
|
|
@@ -91,27 +100,31 @@ export class Certify {
|
|
|
91
100
|
}
|
|
92
101
|
}
|
|
93
102
|
}
|
|
94
|
-
if (
|
|
95
|
-
const key = this.config.readFile(
|
|
96
|
-
const cert = this.config.readFile(
|
|
103
|
+
if (sslConfig?.key && sslConfig?.cert) {
|
|
104
|
+
const key = this.config.readFile(sslConfig.key).toString();
|
|
105
|
+
const cert = this.config.readFile(sslConfig.cert).toString();
|
|
97
106
|
return {key: key, cert: cert};
|
|
98
107
|
} else {
|
|
99
108
|
return null;
|
|
100
109
|
}
|
|
101
110
|
}
|
|
102
111
|
async renewCert(sitename) {
|
|
103
|
-
|
|
104
|
-
|
|
112
|
+
// Load domain config to check SSL certification date
|
|
113
|
+
this.config.setPath(`/${sitename}`);
|
|
114
|
+
this.config.load();
|
|
115
|
+
const sslConfig = this.config.data.ssl;
|
|
116
|
+
|
|
117
|
+
if (sslConfig?.certified && moment().isBefore(moment(sslConfig.certified).add(MAX_AGE, 'days'))) {
|
|
105
118
|
return;
|
|
106
119
|
} else {
|
|
107
120
|
if (!this.contactEmail) throw new Error(`cannot request certificate without CONTACT_EMAIL set`);
|
|
108
121
|
this.pending[sitename] = true;
|
|
109
|
-
|
|
122
|
+
|
|
110
123
|
try {
|
|
111
124
|
// Add timeout wrapper for the entire certificate renewal process
|
|
112
125
|
await Promise.race([
|
|
113
126
|
this.doRenewCert(sitename),
|
|
114
|
-
new Promise((_, reject) =>
|
|
127
|
+
new Promise((_, reject) =>
|
|
115
128
|
setTimeout(() => reject(new Error(`Certificate renewal timeout for ${sitename}`)), 120000) // 2 minute timeout
|
|
116
129
|
)
|
|
117
130
|
]);
|
|
@@ -128,7 +141,7 @@ export class Certify {
|
|
|
128
141
|
const [key, csr] = await Acme.crypto.createCsr({
|
|
129
142
|
altNames: [sitename],
|
|
130
143
|
});
|
|
131
|
-
|
|
144
|
+
|
|
132
145
|
// order certificate with timeout
|
|
133
146
|
const cert = await this.acme.auto({
|
|
134
147
|
csr,
|
|
@@ -142,14 +155,22 @@ export class Certify {
|
|
|
142
155
|
delete this.challenges[challenge.token];
|
|
143
156
|
},
|
|
144
157
|
});
|
|
145
|
-
|
|
146
|
-
// save certificate
|
|
147
|
-
this.config.
|
|
148
|
-
this.config.writeFile(
|
|
149
|
-
this.config.
|
|
158
|
+
|
|
159
|
+
// Set path to domain directory and save certificate files there
|
|
160
|
+
this.config.setPath(`/${sitename}`);
|
|
161
|
+
this.config.writeFile('ssl_cert.pem', cert);
|
|
162
|
+
this.config.writeFile('ssl_key.pem', key.toString());
|
|
163
|
+
|
|
164
|
+
// Load existing config and add/update [ssl] section
|
|
165
|
+
this.config.load();
|
|
166
|
+
if (!this.config.data.ssl) this.config.data.ssl = {};
|
|
167
|
+
this.config.data.ssl.key = 'ssl_key.pem';
|
|
168
|
+
this.config.data.ssl.cert = 'ssl_cert.pem';
|
|
169
|
+
this.config.data.ssl.certified = moment().format("YYYY-MM-DD");
|
|
170
|
+
|
|
150
171
|
delete this.pending[sitename];
|
|
151
172
|
this.config.save();
|
|
152
|
-
|
|
173
|
+
|
|
153
174
|
console.log(`Certificate successfully renewed for ${sitename}`);
|
|
154
175
|
}
|
|
155
176
|
}
|
package/index.mjs
CHANGED
package/multisite.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metric-im/administrate",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
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.3"
|
|
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
|
-
}
|