@metric-im/administrate 2.0.1 → 2.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.
Files changed (2) hide show
  1. package/certify.mjs +69 -14
  2. 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
- console.log(`ACME challenge request for token: ${token}`);
58
- console.log(`Available challenges:`, Object.keys(this.challenges));
59
- if (token in this.challenges) {
60
- console.log(`✓ Challenge found, responding with authorization`);
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(this.challenges[token]);
69
+ res.end(keyAuthorization);
63
70
  return;
64
71
  }
65
- console.log(`✗ Challenge not found, returning 404`);
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
- console.log(` Challenge URL: http://${sitename}/.well-known/acme-challenge/${challenge.token}`);
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
- delete this.challenges[challenge.token];
233
+ await this._delChallenge(challenge.token);
179
234
  },
180
235
  });
181
236
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metric-im/administrate",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "Tools for site administration",
5
5
  "homepage": "https://github.com/metric-im/administrate#readme",
6
6
  "bugs": {