@metric-im/administrate 2.0.1 → 2.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/certify.mjs +69 -14
- package/multisite.mjs +5 -1
- package/package.json +1 -1
package/certify.mjs
CHANGED
|
@@ -6,18 +6,24 @@ import moment from 'moment';
|
|
|
6
6
|
|
|
7
7
|
const MAX_AGE = 75; // days
|
|
8
8
|
const MAX_WAIT_TIME = 60; // seconds
|
|
9
|
+
const LOCK_TTL = 180; // seconds — renew lease held in the shared store
|
|
10
|
+
const CHALLENGE_PATH = '/.acme-challenges'; // shared subtree (under the role's root) of token→keyAuthorization
|
|
11
|
+
const LOCK_PATH = '/.acme-lock'; // shared subtree of sitename→{holder,ts}
|
|
9
12
|
|
|
10
13
|
export class Certify {
|
|
11
14
|
constructor(app,options) {
|
|
12
15
|
this.app = app;
|
|
13
16
|
this.options = options || {};
|
|
14
17
|
this.pending = {};
|
|
15
|
-
this.challenges = {};
|
|
18
|
+
this.challenges = {}; // local fast-path mirror of the shared challenge store
|
|
16
19
|
this.contactEmail = undefined;
|
|
20
|
+
this.shared = null; // Config handle for the pool-shared subtree (challenges + renew lease)
|
|
21
|
+
this.nodeId = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
17
22
|
}
|
|
18
23
|
static async attach(app,options) {
|
|
19
24
|
const instance = new Certify(app,options);
|
|
20
25
|
instance.config = new Config();
|
|
26
|
+
instance.shared = new Config(); // same backend/root; pool-shared challenge tokens + renew lease
|
|
21
27
|
|
|
22
28
|
// Load root config to get contact email
|
|
23
29
|
await instance.config.setPath('/');
|
|
@@ -52,18 +58,18 @@ export class Certify {
|
|
|
52
58
|
routes() {
|
|
53
59
|
const router = express.Router();
|
|
54
60
|
|
|
55
|
-
router.get(/^\/\.well-known\/acme-challenge\/([^\/]+)$/,(req,res)=>{
|
|
61
|
+
router.get(/^\/\.well-known\/acme-challenge\/([^\/]+)$/, async (req,res)=>{
|
|
56
62
|
const token = req.params[0];
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
// Pool-safe: the renewing member may not be the one Let's Encrypt's
|
|
64
|
+
// validator reaches (the L4 LB round-robins :80), so fall back to the
|
|
65
|
+
// shared store any member can read.
|
|
66
|
+
const keyAuthorization = await this._lookupChallenge(token);
|
|
67
|
+
if (keyAuthorization) {
|
|
61
68
|
res.writeHead(200);
|
|
62
|
-
res.end(
|
|
69
|
+
res.end(keyAuthorization);
|
|
63
70
|
return;
|
|
64
71
|
}
|
|
65
|
-
console.log(`✗
|
|
66
|
-
// Don't redirect ACME challenges - return 404 instead (says Claude, used to be 302)
|
|
72
|
+
console.log(`✗ ACME challenge token not found (local or shared): ${token}`);
|
|
67
73
|
res.writeHead(404);
|
|
68
74
|
res.end('Challenge not found');
|
|
69
75
|
});
|
|
@@ -75,6 +81,48 @@ export class Certify {
|
|
|
75
81
|
// });
|
|
76
82
|
return router;
|
|
77
83
|
}
|
|
84
|
+
// ── pool coordination, via the shared authority store ────────────────────
|
|
85
|
+
// http-01 tokens are written to a shared subtree so ANY member can answer
|
|
86
|
+
// LE's validation; a TTL lease ensures only one member renews at a time.
|
|
87
|
+
// With no shared store (single node) these degrade to local-only behavior.
|
|
88
|
+
async _putChallenge(token, keyAuthorization) {
|
|
89
|
+
this.challenges[token] = keyAuthorization; // local fast path
|
|
90
|
+
try {
|
|
91
|
+
await this.shared.setPath(CHALLENGE_PATH);
|
|
92
|
+
this.shared.data[token] = keyAuthorization;
|
|
93
|
+
await this.shared.save();
|
|
94
|
+
} catch (e) { console.warn(`certify: shared challenge put failed (local still serves): ${e.message}`); }
|
|
95
|
+
}
|
|
96
|
+
async _delChallenge(token) {
|
|
97
|
+
delete this.challenges[token];
|
|
98
|
+
try {
|
|
99
|
+
await this.shared.setPath(CHALLENGE_PATH);
|
|
100
|
+
delete this.shared.data[token];
|
|
101
|
+
await this.shared.save();
|
|
102
|
+
} catch (e) { /* best effort — a stale token is harmless */ }
|
|
103
|
+
}
|
|
104
|
+
async _lookupChallenge(token) {
|
|
105
|
+
if (token in this.challenges) return this.challenges[token];
|
|
106
|
+
try { const d = await this.shared.read(CHALLENGE_PATH); return (d && d[token]) || null; }
|
|
107
|
+
catch { return null; }
|
|
108
|
+
}
|
|
109
|
+
async _acquireRenewLock(sitename) {
|
|
110
|
+
try {
|
|
111
|
+
await this.shared.setPath(LOCK_PATH);
|
|
112
|
+
const cur = this.shared.data[sitename];
|
|
113
|
+
if (cur && cur.ts && cur.holder !== this.nodeId &&
|
|
114
|
+
moment().isBefore(moment(cur.ts).add(LOCK_TTL, 'seconds'))) {
|
|
115
|
+
return false; // another member holds a fresh lease
|
|
116
|
+
}
|
|
117
|
+
this.shared.data[sitename] = { holder: this.nodeId, ts: moment().toISOString() };
|
|
118
|
+
await this.shared.save();
|
|
119
|
+
const check = await this.shared.read(LOCK_PATH); // confirm we won the write race
|
|
120
|
+
return !check || !check[sitename] || check[sitename].holder === this.nodeId;
|
|
121
|
+
} catch (e) {
|
|
122
|
+
console.warn(`certify: renew lease unavailable, proceeding solo: ${e.message}`);
|
|
123
|
+
return true; // no shared store → single-node behavior
|
|
124
|
+
}
|
|
125
|
+
}
|
|
78
126
|
async getSiteKeys(sitename) {
|
|
79
127
|
// For localhost and local domains, don't try to get Let's Encrypt certificates
|
|
80
128
|
if (sitename === 'localhost' || sitename.includes('.local') || sitename.match(/^\d+\.\d+\.\d+\.\d+$/)) {
|
|
@@ -120,6 +168,14 @@ export class Certify {
|
|
|
120
168
|
return;
|
|
121
169
|
} else {
|
|
122
170
|
if (!this.contactEmail) throw new Error(`cannot request certificate without CONTACT_EMAIL set`);
|
|
171
|
+
// Pool coordination: only one member renews a given cert at a time. If
|
|
172
|
+
// another holds a fresh lease, skip — the existing cert is still valid
|
|
173
|
+
// and the renewed one lands in the shared store for everyone to read.
|
|
174
|
+
if (!(await this._acquireRenewLock(sitename))) {
|
|
175
|
+
console.log(`certify: another pool member is renewing ${sitename}; skipping`);
|
|
176
|
+
delete this.pending[sitename];
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
123
179
|
this.pending[sitename] = moment();
|
|
124
180
|
|
|
125
181
|
try {
|
|
@@ -168,14 +224,13 @@ export class Certify {
|
|
|
168
224
|
email: this.contactEmail,
|
|
169
225
|
termsOfServiceAgreed: true,
|
|
170
226
|
challengePriority: ['http-01'],
|
|
171
|
-
challengeCreateFn: (authz, challenge, keyAuthorization) => {
|
|
227
|
+
challengeCreateFn: async (authz, challenge, keyAuthorization) => {
|
|
172
228
|
console.log(`✓ Challenge created - token: ${challenge.token}`);
|
|
173
|
-
|
|
174
|
-
this.challenges[challenge.token] = keyAuthorization;
|
|
229
|
+
await this._putChallenge(challenge.token, keyAuthorization); // local + shared store
|
|
175
230
|
},
|
|
176
|
-
challengeRemoveFn: (authz, challenge) => {
|
|
231
|
+
challengeRemoveFn: async (authz, challenge) => {
|
|
177
232
|
console.log(`✓ Challenge removed - token: ${challenge.token}`);
|
|
178
|
-
|
|
233
|
+
await this._delChallenge(challenge.token);
|
|
179
234
|
},
|
|
180
235
|
});
|
|
181
236
|
|
package/multisite.mjs
CHANGED
|
@@ -373,7 +373,11 @@ export class Site {
|
|
|
373
373
|
}
|
|
374
374
|
static Clone(name, multisite) {
|
|
375
375
|
const options = {
|
|
376
|
-
|
|
376
|
+
// The site runs from its own sites/<name> dir — same as the startup
|
|
377
|
+
// path. Using process.cwd() (the harness dir) here spawned the child
|
|
378
|
+
// in the wrong directory, so restartSite() and add-a-site-after-boot
|
|
379
|
+
// never launched the actual site.
|
|
380
|
+
cwd: resolve(`./sites/${name}`),
|
|
377
381
|
env: {PORT:multisite.spawnPort,meta:multisite.options}
|
|
378
382
|
};
|
|
379
383
|
const instance = new Site(name,options);
|