roster-server 2.0.6 → 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.
- package/README.md +43 -9
- package/demo/wildcard-example.js +31 -0
- package/index.js +216 -67
- package/package.json +3 -2
- package/skills/roster-server/SKILL.md +12 -4
- package/test/roster-server.test.js +446 -0
- package/vendor/acme-dns-01-cli-wrapper.js +161 -0
package/README.md
CHANGED
|
@@ -46,17 +46,42 @@ Your project should look something like this:
|
|
|
46
46
|
└── www/
|
|
47
47
|
├── example.com/
|
|
48
48
|
│ └── index.js
|
|
49
|
-
|
|
49
|
+
├── subdomain.example.com/
|
|
50
50
|
│ └── index.js
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
├── other-domain.com/
|
|
52
|
+
│ └── index.js
|
|
53
|
+
└── *.example.com/ # Wildcard: one handler for all subdomains (api.example.com, app.example.com, etc.)
|
|
54
|
+
└── index.js
|
|
53
55
|
```
|
|
54
56
|
|
|
57
|
+
### Wildcard DNS (*.example.com)
|
|
58
|
+
|
|
59
|
+
You can serve all subdomains of a domain with a single handler in three ways:
|
|
60
|
+
|
|
61
|
+
1. **Folder**: Create a directory named literally `*.example.com` under `www` (e.g. `www/*.example.com/index.js`). Any request to `api.example.com`, `app.example.com`, etc. will use that handler.
|
|
62
|
+
2. **Register (default port)**: `roster.register('*.example.com', handler)` for the default HTTPS port.
|
|
63
|
+
3. **Register (custom port)**: `roster.register('*.example.com:8080', handler)` for a specific port.
|
|
64
|
+
|
|
65
|
+
Wildcard SSL certificates require **DNS-01** validation (Let's Encrypt does not support HTTP-01 for wildcards). By default Roster uses `acme-dns-01-cli` through an internal wrapper (adds `propagationDelay` and modern plugin signatures). Override with a custom plugin:
|
|
66
|
+
|
|
67
|
+
```javascript
|
|
68
|
+
import Roster from 'roster-server';
|
|
69
|
+
|
|
70
|
+
const roster = new Roster({
|
|
71
|
+
email: 'admin@example.com',
|
|
72
|
+
wwwPath: '/srv/www',
|
|
73
|
+
greenlockStorePath: '/srv/greenlock.d',
|
|
74
|
+
dnsChallenge: { module: 'acme-dns-01-route53', /* provider options */ } // optional override
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Set `dnsChallenge: false` to disable. For other DNS providers install the plugin in your app and pass it. See [Greenlock DNS plugins](https://git.rootprojects.org/root/greenlock-express.js#dns-01-challenge-plugins).
|
|
79
|
+
|
|
55
80
|
### Setting Up Your Server
|
|
56
81
|
|
|
57
82
|
```javascript
|
|
58
83
|
// /srv/roster/server.js
|
|
59
|
-
|
|
84
|
+
import Roster from 'roster-server';
|
|
60
85
|
|
|
61
86
|
const options = {
|
|
62
87
|
email: 'admin@example.com',
|
|
@@ -78,7 +103,7 @@ I'll help analyze the example files shown. You have 3 different implementations
|
|
|
78
103
|
|
|
79
104
|
1. **Basic HTTP Handler**:
|
|
80
105
|
```javascript:demo/www/example.com/index.js
|
|
81
|
-
|
|
106
|
+
export default (httpsServer) => {
|
|
82
107
|
return (req, res) => {
|
|
83
108
|
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
84
109
|
res.end('"Loco de pensar, queriendo entrar en razón, y el corazón tiene razones que la propia razón nunca entenderá."');
|
|
@@ -88,9 +113,9 @@ module.exports = (httpsServer) => {
|
|
|
88
113
|
|
|
89
114
|
2. **Express App**:
|
|
90
115
|
```javascript:demo/www/express.example.com/index.js
|
|
91
|
-
|
|
116
|
+
import express from 'express';
|
|
92
117
|
|
|
93
|
-
|
|
118
|
+
export default (httpsServer) => {
|
|
94
119
|
const app = express();
|
|
95
120
|
app.get('/', (req, res) => {
|
|
96
121
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
@@ -103,9 +128,9 @@ module.exports = (httpsServer) => {
|
|
|
103
128
|
|
|
104
129
|
3. **Socket.IO Server**:
|
|
105
130
|
```javascript:demo/www/sio.example.com/index.js
|
|
106
|
-
|
|
131
|
+
import { Server } from 'socket.io';
|
|
107
132
|
|
|
108
|
-
|
|
133
|
+
export default (httpsServer) => {
|
|
109
134
|
const io = new Server(httpsServer);
|
|
110
135
|
|
|
111
136
|
io.on('connection', (socket) => {
|
|
@@ -185,6 +210,7 @@ When creating a new `RosterServer` instance, you can pass the following options:
|
|
|
185
210
|
- `email` (string): Your email for Let's Encrypt notifications.
|
|
186
211
|
- `wwwPath` (string): Path to your `www` directory containing your sites.
|
|
187
212
|
- `greenlockStorePath` (string): Directory for Greenlock configuration.
|
|
213
|
+
- `dnsChallenge` (object|false): Optional override for wildcard DNS-01 challenge config. Default is local/manual `acme-dns-01-cli` wrapper with `propagationDelay: 120000`, `autoContinue: false`, and `dryRunDelay: 120000`. This is safer for manual DNS providers (Linode/Cloudflare UI) because Roster waits longer and does not auto-advance in interactive terminals. Set `false` to disable. You can pass `{ module: '...', propagationDelay: 180000 }` to tune DNS wait time (ms). Set `autoContinue: true` (or env `ROSTER_DNS_AUTO_CONTINUE=1`) to continue automatically after delay. For Greenlock dry-runs (`_greenlock-dryrun-*`), delay defaults to `dryRunDelay` (same as `propagationDelay` unless overridden with `dnsChallenge.dryRunDelay` or env `ROSTER_DNS_DRYRUN_DELAY_MS`).
|
|
188
214
|
- `staging` (boolean): Set to `true` to use Let's Encrypt's staging environment (for testing).
|
|
189
215
|
- `local` (boolean): Set to `true` to run in local development mode.
|
|
190
216
|
- `minLocalPort` (number): Minimum port for local mode (default: 4000).
|
|
@@ -199,6 +225,8 @@ When `{ local: true }` is enabled, RosterServer **Skips SSL/HTTPS**: Runs pure H
|
|
|
199
225
|
### Setting Up Local Mode
|
|
200
226
|
|
|
201
227
|
```javascript
|
|
228
|
+
import Roster from 'roster-server';
|
|
229
|
+
|
|
202
230
|
const server = new Roster({
|
|
203
231
|
wwwPath: '/srv/www',
|
|
204
232
|
local: true, // Enable local development mode
|
|
@@ -219,6 +247,8 @@ In local mode, domains are automatically assigned ports based on a CRC32 hash of
|
|
|
219
247
|
You can customize the port range:
|
|
220
248
|
|
|
221
249
|
```javascript
|
|
250
|
+
import Roster from 'roster-server';
|
|
251
|
+
|
|
222
252
|
const roster = new Roster({
|
|
223
253
|
local: true,
|
|
224
254
|
minLocalPort: 5000, // Start from port 5000
|
|
@@ -233,6 +263,8 @@ RosterServer provides a method to get the URL for a domain that adapts automatic
|
|
|
233
263
|
**Instance Method: `roster.getUrl(domain)`**
|
|
234
264
|
|
|
235
265
|
```javascript
|
|
266
|
+
import Roster from 'roster-server';
|
|
267
|
+
|
|
236
268
|
const roster = new Roster({ local: true });
|
|
237
269
|
roster.register('example.com', handler);
|
|
238
270
|
|
|
@@ -255,6 +287,8 @@ This method:
|
|
|
255
287
|
**Example Usage:**
|
|
256
288
|
|
|
257
289
|
```javascript
|
|
290
|
+
import Roster from 'roster-server';
|
|
291
|
+
|
|
258
292
|
// Local development
|
|
259
293
|
const localRoster = new Roster({ local: true });
|
|
260
294
|
localRoster.register('example.com', handler);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wildcard DNS demo: one handler for all subdomains (*.example.com)
|
|
3
|
+
*
|
|
4
|
+
* Run from repo root: node demo/wildcard-example.js
|
|
5
|
+
* Then open the printed URL (or use curl with Host header) to see the wildcard response.
|
|
6
|
+
* Any subdomain (api.example.com, app.example.com, foo.example.com) uses the same handler.
|
|
7
|
+
*/
|
|
8
|
+
const Roster = require('../index.js');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { wildcardRoot } = require('../index.js');
|
|
11
|
+
|
|
12
|
+
const roster = new Roster({
|
|
13
|
+
local: true,
|
|
14
|
+
wwwPath: path.join(__dirname, 'www'),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
roster.start().then(() => {
|
|
18
|
+
const wildcardPattern = roster.domains.find((d) => d.startsWith('*.'));
|
|
19
|
+
const subdomain = wildcardPattern ? 'api.' + wildcardRoot(wildcardPattern) : 'api.example.com';
|
|
20
|
+
const wildcardUrl = roster.getUrl(subdomain);
|
|
21
|
+
|
|
22
|
+
console.log('\n🌐 Wildcard demo');
|
|
23
|
+
console.log(' Loaded:', wildcardPattern ? `https://${wildcardPattern}` : '(none)');
|
|
24
|
+
console.log(' Any subdomain uses the same handler.\n');
|
|
25
|
+
console.log(' Try:', wildcardUrl || '(no wildcard site in www path)');
|
|
26
|
+
if (wildcardUrl) {
|
|
27
|
+
const host = wildcardPattern ? 'api.' + wildcardRoot(wildcardPattern) : 'api.example.com';
|
|
28
|
+
console.log(' Or: curl -H "Host: ' + host + '"', wildcardUrl);
|
|
29
|
+
}
|
|
30
|
+
console.log('');
|
|
31
|
+
});
|
package/index.js
CHANGED
|
@@ -33,6 +33,20 @@ function domainToPort(domain, minPort = 3000, maxPort = 65535) {
|
|
|
33
33
|
return minPort + (hash % portRange);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// Wildcard helpers: *.example.com -> root "example.com"
|
|
37
|
+
function wildcardRoot(pattern) {
|
|
38
|
+
if (!pattern || !pattern.startsWith('*.')) return null;
|
|
39
|
+
return pattern.split('.').slice(1).join('.');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check if host matches wildcard pattern (e.g. api.example.com matches *.example.com)
|
|
43
|
+
function hostMatchesWildcard(host, pattern) {
|
|
44
|
+
if (!pattern || !pattern.startsWith('*.')) return false;
|
|
45
|
+
const h = (host || '').toLowerCase();
|
|
46
|
+
const suffix = pattern.slice(2).toLowerCase(); // "example.com"
|
|
47
|
+
return h.endsWith('.' + suffix) && h.length > suffix.length;
|
|
48
|
+
}
|
|
49
|
+
|
|
36
50
|
// Virtual Server that completely isolates applications
|
|
37
51
|
class VirtualServer extends EventEmitter {
|
|
38
52
|
constructor(domain) {
|
|
@@ -158,6 +172,7 @@ class Roster {
|
|
|
158
172
|
this.local = options.local || false;
|
|
159
173
|
this.domains = [];
|
|
160
174
|
this.sites = {};
|
|
175
|
+
this.wildcardZones = new Set(); // Root domains that have a wildcard site (e.g. "example.com" for *.example.com)
|
|
161
176
|
this.domainServers = {}; // Store separate servers for each domain
|
|
162
177
|
this.portServers = {}; // Store servers by port
|
|
163
178
|
this.domainPorts = {}; // Store domain → port mapping for local mode
|
|
@@ -174,6 +189,38 @@ class Roster {
|
|
|
174
189
|
throw new Error('⚠️ Port 80 is reserved for ACME challenge. Please use a different port.');
|
|
175
190
|
}
|
|
176
191
|
this.defaultPort = port;
|
|
192
|
+
// Use a local wrapper around acme-dns-01-cli so we can provide propagationDelay,
|
|
193
|
+
// zones(), and Promise-style signatures expected by newer ACME validators.
|
|
194
|
+
const defaultDnsChallengeModule = path.join(__dirname, 'vendor', 'acme-dns-01-cli-wrapper.js');
|
|
195
|
+
const shouldUseCliWrapper = (moduleName) =>
|
|
196
|
+
typeof moduleName === 'string' &&
|
|
197
|
+
/(^|[\\/])acme-dns-01-cli([\\/]|$)/.test(moduleName);
|
|
198
|
+
|
|
199
|
+
if (options.dnsChallenge === false) {
|
|
200
|
+
this.dnsChallenge = null;
|
|
201
|
+
} else if (options.dnsChallenge) {
|
|
202
|
+
const provided = { ...options.dnsChallenge };
|
|
203
|
+
if (shouldUseCliWrapper(provided.module) || provided.module === 'acme-dns-01-cli') {
|
|
204
|
+
provided.module = defaultDnsChallengeModule;
|
|
205
|
+
}
|
|
206
|
+
if (provided.propagationDelay === undefined) {
|
|
207
|
+
provided.propagationDelay = 120000;
|
|
208
|
+
}
|
|
209
|
+
if (provided.autoContinue === undefined) {
|
|
210
|
+
provided.autoContinue = false;
|
|
211
|
+
}
|
|
212
|
+
if (provided.dryRunDelay === undefined) {
|
|
213
|
+
provided.dryRunDelay = provided.propagationDelay;
|
|
214
|
+
}
|
|
215
|
+
this.dnsChallenge = provided;
|
|
216
|
+
} else {
|
|
217
|
+
this.dnsChallenge = {
|
|
218
|
+
module: defaultDnsChallengeModule,
|
|
219
|
+
propagationDelay: 120000,
|
|
220
|
+
autoContinue: false,
|
|
221
|
+
dryRunDelay: 120000
|
|
222
|
+
};
|
|
223
|
+
}
|
|
177
224
|
}
|
|
178
225
|
|
|
179
226
|
async loadSites() {
|
|
@@ -212,13 +259,21 @@ class Roster {
|
|
|
212
259
|
}
|
|
213
260
|
|
|
214
261
|
if (siteApp) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
this.sites[
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
262
|
+
if (domain.startsWith('*.')) {
|
|
263
|
+
// Wildcard site: one handler for all subdomains (e.g. *.example.com)
|
|
264
|
+
this.domains.push(domain);
|
|
265
|
+
this.sites[domain] = siteApp;
|
|
266
|
+
const root = wildcardRoot(domain);
|
|
267
|
+
if (root) this.wildcardZones.add(root);
|
|
268
|
+
log.info(`(✔) Loaded wildcard site: https://${domain}`);
|
|
269
|
+
} else {
|
|
270
|
+
const domainEntries = [domain, `www.${domain}`];
|
|
271
|
+
this.domains.push(...domainEntries);
|
|
272
|
+
domainEntries.forEach(d => {
|
|
273
|
+
this.sites[d] = siteApp;
|
|
274
|
+
});
|
|
275
|
+
log.info(`(✔) Loaded site: https://${domain}`);
|
|
276
|
+
}
|
|
222
277
|
} else {
|
|
223
278
|
log.warn(`⚠️ No index file (js/mjs/cjs) found in ${domainPath}`);
|
|
224
279
|
}
|
|
@@ -237,8 +292,8 @@ class Roster {
|
|
|
237
292
|
const uniqueDomains = new Set();
|
|
238
293
|
|
|
239
294
|
this.domains.forEach(domain => {
|
|
240
|
-
const
|
|
241
|
-
uniqueDomains.add(
|
|
295
|
+
const root = domain.startsWith('*.') ? wildcardRoot(domain) : domain.replace(/^www\./, '');
|
|
296
|
+
if (root) uniqueDomains.add(root);
|
|
242
297
|
});
|
|
243
298
|
|
|
244
299
|
let existingConfig = {};
|
|
@@ -252,6 +307,9 @@ class Roster {
|
|
|
252
307
|
if ((domain.match(/\./g) || []).length < 2) {
|
|
253
308
|
altnames.push(`www.${domain}`);
|
|
254
309
|
}
|
|
310
|
+
if (this.wildcardZones.has(domain)) {
|
|
311
|
+
altnames.push(`*.${domain}`);
|
|
312
|
+
}
|
|
255
313
|
|
|
256
314
|
let existingSite = null;
|
|
257
315
|
if (existingConfig.sites) {
|
|
@@ -264,7 +322,35 @@ class Roster {
|
|
|
264
322
|
};
|
|
265
323
|
|
|
266
324
|
if (existingSite && existingSite.renewAt) {
|
|
267
|
-
|
|
325
|
+
const existingAltnames = Array.isArray(existingSite.altnames)
|
|
326
|
+
? [...existingSite.altnames].sort()
|
|
327
|
+
: [];
|
|
328
|
+
const nextAltnames = [...altnames].sort();
|
|
329
|
+
const sameAltnames =
|
|
330
|
+
existingAltnames.length === nextAltnames.length &&
|
|
331
|
+
existingAltnames.every((name, idx) => name === nextAltnames[idx]);
|
|
332
|
+
|
|
333
|
+
// Keep renewAt only when certificate identifiers are unchanged.
|
|
334
|
+
// If altnames changed (e.g. wildcard added), force immediate re-issue.
|
|
335
|
+
if (sameAltnames) {
|
|
336
|
+
siteConfig.renewAt = existingSite.renewAt;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (this.wildcardZones.has(domain) && this.dnsChallenge) {
|
|
341
|
+
const dns01 = { ...this.dnsChallenge };
|
|
342
|
+
if (dns01.propagationDelay === undefined) {
|
|
343
|
+
dns01.propagationDelay = 120000; // 120s default for manual DNS (acme-dns-01-cli)
|
|
344
|
+
}
|
|
345
|
+
if (dns01.autoContinue === undefined) {
|
|
346
|
+
dns01.autoContinue = false;
|
|
347
|
+
}
|
|
348
|
+
if (dns01.dryRunDelay === undefined) {
|
|
349
|
+
dns01.dryRunDelay = dns01.propagationDelay;
|
|
350
|
+
}
|
|
351
|
+
siteConfig.challenges = {
|
|
352
|
+
'dns-01': dns01
|
|
353
|
+
};
|
|
268
354
|
}
|
|
269
355
|
|
|
270
356
|
sitesConfig.push(siteConfig);
|
|
@@ -310,6 +396,47 @@ class Roster {
|
|
|
310
396
|
log.info(`📁 config.json generated at ${configPath}`);
|
|
311
397
|
}
|
|
312
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Resolve handler for a host (exact match, then wildcard). Used when port is not in the key.
|
|
401
|
+
*/
|
|
402
|
+
getHandlerForHost(host) {
|
|
403
|
+
const resolved = this.getHandlerAndKeyForHost(host);
|
|
404
|
+
return resolved ? resolved.handler : null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Resolve handler and site key for a host (exact match, then wildcard). Used by getUrl for wildcard lookups.
|
|
409
|
+
*/
|
|
410
|
+
getHandlerAndKeyForHost(host) {
|
|
411
|
+
const siteApp = this.sites[host];
|
|
412
|
+
if (siteApp) return { handler: siteApp, siteKey: host };
|
|
413
|
+
for (const key of Object.keys(this.sites)) {
|
|
414
|
+
if (key.startsWith('*.')) {
|
|
415
|
+
const pattern = key.split(':')[0];
|
|
416
|
+
if (hostMatchesWildcard(host, pattern)) return { handler: this.sites[key], siteKey: key };
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Resolve virtualServer and appHandler for a host from portData (exact then wildcard).
|
|
424
|
+
*/
|
|
425
|
+
getHandlerForPortData(host, portData) {
|
|
426
|
+
const virtualServer = portData.virtualServers[host];
|
|
427
|
+
const appHandler = portData.appHandlers[host];
|
|
428
|
+
if (virtualServer && appHandler !== undefined) return { virtualServer, appHandler };
|
|
429
|
+
for (const key of Object.keys(portData.appHandlers)) {
|
|
430
|
+
if (key.startsWith('*.') && hostMatchesWildcard(host, key)) {
|
|
431
|
+
return {
|
|
432
|
+
virtualServer: portData.virtualServers[key],
|
|
433
|
+
appHandler: portData.appHandlers[key]
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
|
|
313
440
|
handleRequest(req, res) {
|
|
314
441
|
const host = req.headers.host || '';
|
|
315
442
|
|
|
@@ -320,7 +447,8 @@ class Roster {
|
|
|
320
447
|
return;
|
|
321
448
|
}
|
|
322
449
|
|
|
323
|
-
const
|
|
450
|
+
const hostWithoutPort = host.split(':')[0];
|
|
451
|
+
const siteApp = this.getHandlerForHost(hostWithoutPort);
|
|
324
452
|
if (siteApp) {
|
|
325
453
|
siteApp(req, res);
|
|
326
454
|
} else {
|
|
@@ -339,6 +467,16 @@ class Roster {
|
|
|
339
467
|
|
|
340
468
|
const { domain, port } = this.parseDomainWithPort(domainString);
|
|
341
469
|
|
|
470
|
+
if (domain.startsWith('*.')) {
|
|
471
|
+
const domainKey = port === this.defaultPort ? domain : `${domain}:${port}`;
|
|
472
|
+
this.domains.push(domain);
|
|
473
|
+
this.sites[domainKey] = requestHandler;
|
|
474
|
+
const root = wildcardRoot(domain);
|
|
475
|
+
if (root) this.wildcardZones.add(root);
|
|
476
|
+
log.info(`(✔) Registered wildcard site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
|
|
477
|
+
return this;
|
|
478
|
+
}
|
|
479
|
+
|
|
342
480
|
const domainEntries = [domain];
|
|
343
481
|
if ((domain.match(/\./g) || []).length < 2) {
|
|
344
482
|
domainEntries.push(`www.${domain}`);
|
|
@@ -346,7 +484,6 @@ class Roster {
|
|
|
346
484
|
|
|
347
485
|
this.domains.push(...domainEntries);
|
|
348
486
|
domainEntries.forEach(d => {
|
|
349
|
-
// Store with port information
|
|
350
487
|
const domainKey = port === this.defaultPort ? d : `${d}:${port}`;
|
|
351
488
|
this.sites[domainKey] = requestHandler;
|
|
352
489
|
});
|
|
@@ -370,31 +507,25 @@ class Roster {
|
|
|
370
507
|
|
|
371
508
|
/**
|
|
372
509
|
* Get the URL for a domain based on the current environment
|
|
373
|
-
* @param {string} domain - The domain name
|
|
374
|
-
* @returns {string|null} The URL if domain is registered, null otherwise
|
|
510
|
+
* @param {string} domain - The domain name (or subdomain that matches a wildcard site)
|
|
511
|
+
* @returns {string|null} The URL if domain is registered (exact or wildcard), null otherwise
|
|
375
512
|
*/
|
|
376
513
|
getUrl(domain) {
|
|
377
|
-
// Remove www prefix if present
|
|
378
514
|
const cleanDomain = domain.startsWith('www.') ? domain.slice(4) : domain;
|
|
379
515
|
|
|
380
|
-
|
|
381
|
-
const
|
|
382
|
-
if (!
|
|
383
|
-
return null;
|
|
384
|
-
}
|
|
516
|
+
const exactMatch = this.sites[cleanDomain] || this.sites[`www.${cleanDomain}`];
|
|
517
|
+
const resolved = exactMatch ? { handler: exactMatch, siteKey: cleanDomain } : this.getHandlerAndKeyForHost(cleanDomain);
|
|
518
|
+
if (!resolved) return null;
|
|
385
519
|
|
|
386
|
-
// Return URL based on environment
|
|
387
520
|
if (this.local) {
|
|
388
|
-
|
|
389
|
-
if (this.domainPorts && this.domainPorts[
|
|
390
|
-
return `http://localhost:${this.domainPorts[
|
|
521
|
+
const pattern = resolved.siteKey.split(':')[0];
|
|
522
|
+
if (this.domainPorts && this.domainPorts[pattern] !== undefined) {
|
|
523
|
+
return `http://localhost:${this.domainPorts[pattern]}`;
|
|
391
524
|
}
|
|
392
525
|
return null;
|
|
393
|
-
} else {
|
|
394
|
-
// Production mode: return HTTPS URL
|
|
395
|
-
const port = this.defaultPort === 443 ? '' : `:${this.defaultPort}`;
|
|
396
|
-
return `https://${cleanDomain}${port}`;
|
|
397
526
|
}
|
|
527
|
+
const port = this.defaultPort === 443 ? '' : `:${this.defaultPort}`;
|
|
528
|
+
return `https://${cleanDomain}${port}`;
|
|
398
529
|
}
|
|
399
530
|
|
|
400
531
|
createVirtualServer(domain) {
|
|
@@ -517,7 +648,18 @@ class Roster {
|
|
|
517
648
|
configDir: this.greenlockStorePath,
|
|
518
649
|
maintainerEmail: this.email,
|
|
519
650
|
cluster: this.cluster,
|
|
520
|
-
staging: this.staging
|
|
651
|
+
staging: this.staging,
|
|
652
|
+
notify: (event, details) => {
|
|
653
|
+
const msg = typeof details === 'string' ? details : (details?.message ?? JSON.stringify(details));
|
|
654
|
+
// Suppress known benign warnings from ACME when using acme-dns-01-cli
|
|
655
|
+
if (event === 'warning' && typeof msg === 'string') {
|
|
656
|
+
if (/acme-dns-01-cli.*(incorrect function signatures|deprecated use of callbacks)/i.test(msg)) return;
|
|
657
|
+
if (/dns-01 challenge plugin should have zones/i.test(msg)) return;
|
|
658
|
+
}
|
|
659
|
+
if (event === 'error') log.error(msg);
|
|
660
|
+
else if (event === 'warning') log.warn(msg);
|
|
661
|
+
else log.info(msg);
|
|
662
|
+
}
|
|
521
663
|
});
|
|
522
664
|
|
|
523
665
|
return greenlock.ready(glx => {
|
|
@@ -535,15 +677,15 @@ class Roster {
|
|
|
535
677
|
};
|
|
536
678
|
}
|
|
537
679
|
|
|
538
|
-
// Create completely isolated virtual server
|
|
539
680
|
const virtualServer = this.createVirtualServer(domain);
|
|
540
681
|
sitesByPort[port].virtualServers[domain] = virtualServer;
|
|
541
682
|
this.domainServers[domain] = virtualServer;
|
|
542
683
|
|
|
543
|
-
// Initialize app with virtual server
|
|
544
684
|
const appHandler = siteApp(virtualServer);
|
|
545
685
|
sitesByPort[port].appHandlers[domain] = appHandler;
|
|
546
|
-
|
|
686
|
+
if (!domain.startsWith('*.')) {
|
|
687
|
+
sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
|
|
688
|
+
}
|
|
547
689
|
}
|
|
548
690
|
}
|
|
549
691
|
|
|
@@ -552,27 +694,27 @@ class Roster {
|
|
|
552
694
|
return (req, res) => {
|
|
553
695
|
const host = req.headers.host || '';
|
|
554
696
|
|
|
555
|
-
|
|
556
|
-
const hostWithoutPort = host.split(':')[0];
|
|
697
|
+
const hostWithoutPort = host.split(':')[0].toLowerCase();
|
|
557
698
|
const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
|
|
558
699
|
|
|
559
|
-
// Handle www redirects
|
|
560
700
|
if (hostWithoutPort.startsWith('www.')) {
|
|
561
701
|
res.writeHead(301, { Location: `https://${domain}${req.url}` });
|
|
562
702
|
res.end();
|
|
563
703
|
return;
|
|
564
704
|
}
|
|
565
705
|
|
|
566
|
-
const
|
|
567
|
-
|
|
706
|
+
const resolved = this.getHandlerForPortData(domain, portData);
|
|
707
|
+
if (!resolved) {
|
|
708
|
+
res.writeHead(404);
|
|
709
|
+
res.end('Site not found');
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const { virtualServer, appHandler } = resolved;
|
|
568
713
|
|
|
569
714
|
if (virtualServer && virtualServer.requestListeners.length > 0) {
|
|
570
|
-
// Set fallback handler on virtual server for non-Socket.IO requests
|
|
571
715
|
virtualServer.fallbackHandler = appHandler;
|
|
572
|
-
// App registered listeners on virtual server - use them
|
|
573
716
|
virtualServer.processRequest(req, res);
|
|
574
717
|
} else if (appHandler) {
|
|
575
|
-
// App returned a handler function - use it
|
|
576
718
|
appHandler(req, res);
|
|
577
719
|
} else {
|
|
578
720
|
res.writeHead(404);
|
|
@@ -585,19 +727,16 @@ class Roster {
|
|
|
585
727
|
log.info('HTTP server listening on port 80');
|
|
586
728
|
});
|
|
587
729
|
|
|
588
|
-
// Create upgrade handler for WebSocket connections
|
|
589
730
|
const createUpgradeHandler = (portData) => {
|
|
590
731
|
return (req, socket, head) => {
|
|
591
732
|
const host = req.headers.host || '';
|
|
592
|
-
const hostWithoutPort = host.split(':')[0];
|
|
733
|
+
const hostWithoutPort = host.split(':')[0].toLowerCase();
|
|
593
734
|
const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
|
|
594
735
|
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
virtualServer.processUpgrade(req, socket, head);
|
|
736
|
+
const resolved = this.getHandlerForPortData(domain, portData);
|
|
737
|
+
if (resolved && resolved.virtualServer) {
|
|
738
|
+
resolved.virtualServer.processUpgrade(req, socket, head);
|
|
599
739
|
} else {
|
|
600
|
-
// No virtual server found, destroy the socket
|
|
601
740
|
socket.destroy();
|
|
602
741
|
}
|
|
603
742
|
};
|
|
@@ -618,7 +757,9 @@ class Roster {
|
|
|
618
757
|
|
|
619
758
|
if (isBunRuntime) {
|
|
620
759
|
const primaryDomain = Object.keys(portData.virtualServers)[0];
|
|
621
|
-
|
|
760
|
+
// Greenlock stores certs by subject (e.g. tagnu.com), not by wildcard (*.tagnu.com)
|
|
761
|
+
const certSubject = primaryDomain.startsWith('*.') ? wildcardRoot(primaryDomain) : primaryDomain;
|
|
762
|
+
const certPath = path.join(this.greenlockStorePath, 'live', certSubject);
|
|
622
763
|
const keyPath = path.join(certPath, 'privkey.pem');
|
|
623
764
|
const certFilePath = path.join(certPath, 'cert.pem');
|
|
624
765
|
const chainPath = path.join(certPath, 'chain.pem');
|
|
@@ -634,7 +775,7 @@ class Roster {
|
|
|
634
775
|
}, dispatcher);
|
|
635
776
|
log.warn(`⚠️ Bun runtime detected: using static TLS cert for ${primaryDomain} on port ${portNum}`);
|
|
636
777
|
} else {
|
|
637
|
-
log.warn(`⚠️ Bun runtime detected but cert files missing for ${primaryDomain}; falling back to Greenlock HTTPS server`);
|
|
778
|
+
log.warn(`⚠️ Bun runtime detected but cert files missing for ${certSubject} (${primaryDomain}); falling back to Greenlock HTTPS server`);
|
|
638
779
|
httpsServer = glx.httpsServer(tlsOpts, dispatcher);
|
|
639
780
|
}
|
|
640
781
|
} else {
|
|
@@ -651,28 +792,34 @@ class Roster {
|
|
|
651
792
|
});
|
|
652
793
|
} else {
|
|
653
794
|
// Create HTTPS server for custom ports using Greenlock certificates
|
|
795
|
+
const greenlockStorePath = this.greenlockStorePath;
|
|
796
|
+
const loadCert = (subjectDir) => {
|
|
797
|
+
const certPath = path.join(greenlockStorePath, 'live', subjectDir);
|
|
798
|
+
const keyPath = path.join(certPath, 'privkey.pem');
|
|
799
|
+
const certFilePath = path.join(certPath, 'cert.pem');
|
|
800
|
+
const chainPath = path.join(certPath, 'chain.pem');
|
|
801
|
+
if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
|
|
802
|
+
return {
|
|
803
|
+
key: fs.readFileSync(keyPath, 'utf8'),
|
|
804
|
+
cert: fs.readFileSync(certFilePath, 'utf8') + fs.readFileSync(chainPath, 'utf8')
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
return null;
|
|
808
|
+
};
|
|
654
809
|
const httpsOptions = {
|
|
655
810
|
minVersion: this.tlsMinVersion,
|
|
656
811
|
maxVersion: this.tlsMaxVersion,
|
|
657
|
-
|
|
658
|
-
SNICallback: (domain, callback) => {
|
|
812
|
+
SNICallback: (servername, callback) => {
|
|
659
813
|
try {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
if (
|
|
666
|
-
|
|
667
|
-
const cert = fs.readFileSync(certFilePath, 'utf8');
|
|
668
|
-
const chain = fs.readFileSync(chainPath, 'utf8');
|
|
669
|
-
|
|
670
|
-
callback(null, tls.createSecureContext({
|
|
671
|
-
key: key,
|
|
672
|
-
cert: cert + chain
|
|
673
|
-
}));
|
|
814
|
+
let pems = loadCert(servername);
|
|
815
|
+
if (!pems && hostMatchesWildcard(servername, '*.' + servername.split('.').slice(1).join('.'))) {
|
|
816
|
+
const zoneSubject = servername.split('.').slice(1).join('.');
|
|
817
|
+
pems = loadCert(zoneSubject);
|
|
818
|
+
}
|
|
819
|
+
if (pems) {
|
|
820
|
+
callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
|
|
674
821
|
} else {
|
|
675
|
-
callback(new Error(`No certificate files available for ${
|
|
822
|
+
callback(new Error(`No certificate files available for ${servername}`));
|
|
676
823
|
}
|
|
677
824
|
} catch (error) {
|
|
678
825
|
callback(error);
|
|
@@ -711,4 +858,6 @@ class Roster {
|
|
|
711
858
|
}
|
|
712
859
|
}
|
|
713
860
|
|
|
714
|
-
module.exports = Roster;
|
|
861
|
+
module.exports = Roster;
|
|
862
|
+
module.exports.wildcardRoot = wildcardRoot;
|
|
863
|
+
module.exports.hostMatchesWildcard = hostMatchesWildcard;
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roster-server",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "👾 RosterServer - A domain host router to host multiple HTTPS.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "
|
|
7
|
+
"test": "node --test 'test/**/*.test.js'"
|
|
8
8
|
},
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"homepage": "https://github.com/clasen/RosterServer#readme",
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@root/greenlock": "^4.0.5",
|
|
40
|
+
"acme-dns-01-cli": "^3.0.7",
|
|
40
41
|
"lemonlog": "^1.2.0",
|
|
41
42
|
"redirect-https": "^1.3.1"
|
|
42
43
|
}
|
|
@@ -40,8 +40,10 @@ project/
|
|
|
40
40
|
├── www/
|
|
41
41
|
│ ├── example.com/
|
|
42
42
|
│ │ └── index.js # Handler for example.com
|
|
43
|
-
│
|
|
44
|
-
│
|
|
43
|
+
│ ├── api.example.com/
|
|
44
|
+
│ │ └── index.js # Handler for subdomain
|
|
45
|
+
│ └── *.example.com/
|
|
46
|
+
│ └── index.js # Wildcard: one handler for all subdomains
|
|
45
47
|
└── server.js # Your setup
|
|
46
48
|
```
|
|
47
49
|
|
|
@@ -104,6 +106,10 @@ roster.register('example.com', (httpsServer) => {
|
|
|
104
106
|
|
|
105
107
|
// With custom port
|
|
106
108
|
roster.register('api.example.com:8443', handler);
|
|
109
|
+
|
|
110
|
+
// Wildcard: one handler for all subdomains (default port or custom)
|
|
111
|
+
roster.register('*.example.com', handler);
|
|
112
|
+
roster.register('*.example.com:8080', handler);
|
|
107
113
|
```
|
|
108
114
|
|
|
109
115
|
## Key Configuration Options
|
|
@@ -113,6 +119,7 @@ new Roster({
|
|
|
113
119
|
email: 'admin@example.com', // Required for SSL
|
|
114
120
|
wwwPath: '/srv/www', // Site handlers directory
|
|
115
121
|
greenlockStorePath: '/srv/greenlock.d', // SSL storage
|
|
122
|
+
dnsChallenge: { ... }, // Optional override. Default is local/manual DNS-01 (acme-dns-01-cli)
|
|
116
123
|
|
|
117
124
|
// Environment
|
|
118
125
|
local: false, // true = HTTP, false = HTTPS
|
|
@@ -138,13 +145,13 @@ new Roster({
|
|
|
138
145
|
Loads sites, generates SSL config, starts servers. Returns `Promise<void>`.
|
|
139
146
|
|
|
140
147
|
### `roster.register(domain, handler)`
|
|
141
|
-
Manually register a domain handler. Domain can include port: `'api.com:8443'`.
|
|
148
|
+
Manually register a domain handler. Domain can include port: `'api.com:8443'`. For wildcards use `'*.example.com'` or `'*.example.com:8080'`.
|
|
142
149
|
|
|
143
150
|
### `roster.getUrl(domain)`
|
|
144
151
|
Get environment-aware URL:
|
|
145
152
|
- Local mode: `http://localhost:{port}`
|
|
146
153
|
- Production: `https://{domain}` or `https://{domain}:{port}`
|
|
147
|
-
- Returns `null` if domain not registered
|
|
154
|
+
- Returns `null` if domain not registered. Supports wildcard-matched hosts (e.g. `getUrl('api.example.com')` when `*.example.com` is registered).
|
|
148
155
|
|
|
149
156
|
## How It Works
|
|
150
157
|
|
|
@@ -170,6 +177,7 @@ Each domain gets isolated server instance that simulates `http.Server`:
|
|
|
170
177
|
- Auto-renewal 45 days before expiration
|
|
171
178
|
- SNI support for multiple domains
|
|
172
179
|
- Custom ports reuse certificates via SNI callback
|
|
180
|
+
- **Wildcard** (`*.example.com`): use folder `www/*.example.com/` or `roster.register('*.example.com', handler)`. Default DNS-01 plugin is local/manual `acme-dns-01-cli`; set `dnsChallenge` only when overriding provider integration.
|
|
173
181
|
|
|
174
182
|
## Common Issues & Solutions
|
|
175
183
|
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it } = require('node:test');
|
|
4
|
+
const assert = require('node:assert');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const http = require('http');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const Roster = require('../index.js');
|
|
10
|
+
const { wildcardRoot, hostMatchesWildcard } = require('../index.js');
|
|
11
|
+
|
|
12
|
+
function closePortServers(roster) {
|
|
13
|
+
if (roster.portServers && typeof roster.portServers === 'object') {
|
|
14
|
+
for (const server of Object.values(roster.portServers)) {
|
|
15
|
+
try {
|
|
16
|
+
server.close();
|
|
17
|
+
} catch (_) {}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function httpGet(host, port, pathname = '/') {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const req = http.get(
|
|
25
|
+
{ host, port, path: pathname, headers: { host: host + (port === 80 ? '' : ':' + port) } },
|
|
26
|
+
(res) => {
|
|
27
|
+
let body = '';
|
|
28
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
29
|
+
res.on('end', () => resolve({ statusCode: res.statusCode, headers: res.headers, body }));
|
|
30
|
+
}
|
|
31
|
+
);
|
|
32
|
+
req.on('error', reject);
|
|
33
|
+
req.setTimeout(2000, () => { req.destroy(); reject(new Error('timeout')); });
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('wildcardRoot', () => {
|
|
38
|
+
it('returns root domain for *.example.com', () => {
|
|
39
|
+
assert.strictEqual(wildcardRoot('*.example.com'), 'example.com');
|
|
40
|
+
});
|
|
41
|
+
it('returns root for *.sub.example.com', () => {
|
|
42
|
+
assert.strictEqual(wildcardRoot('*.sub.example.com'), 'sub.example.com');
|
|
43
|
+
});
|
|
44
|
+
it('returns null for non-wildcard', () => {
|
|
45
|
+
assert.strictEqual(wildcardRoot('example.com'), null);
|
|
46
|
+
assert.strictEqual(wildcardRoot('api.example.com'), null);
|
|
47
|
+
});
|
|
48
|
+
it('returns null for empty or null', () => {
|
|
49
|
+
assert.strictEqual(wildcardRoot(''), null);
|
|
50
|
+
assert.strictEqual(wildcardRoot(null), null);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('hostMatchesWildcard', () => {
|
|
55
|
+
it('matches subdomain to pattern', () => {
|
|
56
|
+
assert.strictEqual(hostMatchesWildcard('api.example.com', '*.example.com'), true);
|
|
57
|
+
assert.strictEqual(hostMatchesWildcard('app.example.com', '*.example.com'), true);
|
|
58
|
+
assert.strictEqual(hostMatchesWildcard('a.example.com', '*.example.com'), true);
|
|
59
|
+
});
|
|
60
|
+
it('does not match apex domain', () => {
|
|
61
|
+
assert.strictEqual(hostMatchesWildcard('example.com', '*.example.com'), false);
|
|
62
|
+
});
|
|
63
|
+
it('does not match other zones', () => {
|
|
64
|
+
assert.strictEqual(hostMatchesWildcard('api.other.com', '*.example.com'), false);
|
|
65
|
+
assert.strictEqual(hostMatchesWildcard('example.com.evil.com', '*.example.com'), false);
|
|
66
|
+
});
|
|
67
|
+
it('returns false for invalid pattern', () => {
|
|
68
|
+
assert.strictEqual(hostMatchesWildcard('api.example.com', 'example.com'), false);
|
|
69
|
+
assert.strictEqual(hostMatchesWildcard('api.example.com', ''), false);
|
|
70
|
+
assert.strictEqual(hostMatchesWildcard('api.example.com', null), false);
|
|
71
|
+
});
|
|
72
|
+
it('matches case-insensitively (Host header may be any case)', () => {
|
|
73
|
+
assert.strictEqual(hostMatchesWildcard('Admin.Tagnu.com', '*.tagnu.com'), true);
|
|
74
|
+
assert.strictEqual(hostMatchesWildcard('API.EXAMPLE.COM', '*.example.com'), true);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('Roster', () => {
|
|
79
|
+
describe('parseDomainWithPort', () => {
|
|
80
|
+
it('parses *.example.com with default port', () => {
|
|
81
|
+
const roster = new Roster({ local: true });
|
|
82
|
+
assert.deepStrictEqual(roster.parseDomainWithPort('*.example.com'), {
|
|
83
|
+
domain: '*.example.com',
|
|
84
|
+
port: 443
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
it('parses *.example.com:8080', () => {
|
|
88
|
+
const roster = new Roster({ local: true });
|
|
89
|
+
assert.deepStrictEqual(roster.parseDomainWithPort('*.example.com:8080'), {
|
|
90
|
+
domain: '*.example.com',
|
|
91
|
+
port: 8080
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
it('parses normal domain with port', () => {
|
|
95
|
+
const roster = new Roster({ local: true });
|
|
96
|
+
assert.deepStrictEqual(roster.parseDomainWithPort('example.com:8443'), {
|
|
97
|
+
domain: 'example.com',
|
|
98
|
+
port: 8443
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('register (wildcard)', () => {
|
|
104
|
+
it('registers *.example.com and resolves handler for subdomain', () => {
|
|
105
|
+
const roster = new Roster({ local: true });
|
|
106
|
+
const handler = () => {};
|
|
107
|
+
roster.register('*.example.com', handler);
|
|
108
|
+
assert.strictEqual(roster.getHandlerForHost('api.example.com'), handler);
|
|
109
|
+
assert.strictEqual(roster.getHandlerForHost('app.example.com'), handler);
|
|
110
|
+
assert.strictEqual(roster.getHandlerForHost('example.com'), null);
|
|
111
|
+
assert.ok(roster.wildcardZones.has('example.com'));
|
|
112
|
+
});
|
|
113
|
+
it('registers *.example.com:8080 with custom port', () => {
|
|
114
|
+
const roster = new Roster({ local: true });
|
|
115
|
+
const handler = () => {};
|
|
116
|
+
roster.register('*.example.com:8080', handler);
|
|
117
|
+
assert.strictEqual(roster.sites['*.example.com:8080'], handler);
|
|
118
|
+
assert.ok(roster.wildcardZones.has('example.com'));
|
|
119
|
+
});
|
|
120
|
+
it('getHandlerAndKeyForHost returns handler and siteKey for wildcard match', () => {
|
|
121
|
+
const roster = new Roster({ local: true });
|
|
122
|
+
const handler = () => {};
|
|
123
|
+
roster.register('*.example.com', handler);
|
|
124
|
+
const resolved = roster.getHandlerAndKeyForHost('api.example.com');
|
|
125
|
+
assert.ok(resolved);
|
|
126
|
+
assert.strictEqual(resolved.handler, handler);
|
|
127
|
+
assert.strictEqual(resolved.siteKey, '*.example.com');
|
|
128
|
+
});
|
|
129
|
+
it('exact match takes precedence over wildcard', () => {
|
|
130
|
+
const roster = new Roster({ local: true });
|
|
131
|
+
const exactHandler = () => {};
|
|
132
|
+
const wildcardHandler = () => {};
|
|
133
|
+
roster.register('api.example.com', exactHandler);
|
|
134
|
+
roster.register('*.example.com', wildcardHandler);
|
|
135
|
+
assert.strictEqual(roster.getHandlerForHost('api.example.com'), exactHandler);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('getHandlerForPortData', () => {
|
|
140
|
+
it('returns exact match when present', () => {
|
|
141
|
+
const roster = new Roster({ local: true });
|
|
142
|
+
const vs = roster.createVirtualServer('example.com');
|
|
143
|
+
const handler = () => {};
|
|
144
|
+
const portData = {
|
|
145
|
+
virtualServers: { 'example.com': vs },
|
|
146
|
+
appHandlers: { 'example.com': handler }
|
|
147
|
+
};
|
|
148
|
+
const resolved = roster.getHandlerForPortData('example.com', portData);
|
|
149
|
+
assert.ok(resolved);
|
|
150
|
+
assert.strictEqual(resolved.virtualServer, vs);
|
|
151
|
+
assert.strictEqual(resolved.appHandler, handler);
|
|
152
|
+
});
|
|
153
|
+
it('returns wildcard match for subdomain', () => {
|
|
154
|
+
const roster = new Roster({ local: true });
|
|
155
|
+
const vs = roster.createVirtualServer('*.example.com');
|
|
156
|
+
const handler = () => {};
|
|
157
|
+
const portData = {
|
|
158
|
+
virtualServers: { '*.example.com': vs },
|
|
159
|
+
appHandlers: { '*.example.com': handler }
|
|
160
|
+
};
|
|
161
|
+
const resolved = roster.getHandlerForPortData('api.example.com', portData);
|
|
162
|
+
assert.ok(resolved);
|
|
163
|
+
assert.strictEqual(resolved.virtualServer, vs);
|
|
164
|
+
assert.strictEqual(resolved.appHandler, handler);
|
|
165
|
+
});
|
|
166
|
+
it('returns null when no match', () => {
|
|
167
|
+
const roster = new Roster({ local: true });
|
|
168
|
+
const portData = { virtualServers: {}, appHandlers: {} };
|
|
169
|
+
assert.strictEqual(roster.getHandlerForPortData('unknown.com', portData), null);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('getUrl (wildcard)', () => {
|
|
174
|
+
it('returns URL for wildcard-matched host in local mode', () => {
|
|
175
|
+
const roster = new Roster({ local: true });
|
|
176
|
+
roster.register('*.example.com', () => {});
|
|
177
|
+
roster.domainPorts = { '*.example.com': 9999 };
|
|
178
|
+
roster.local = true;
|
|
179
|
+
assert.strictEqual(roster.getUrl('api.example.com'), 'http://localhost:9999');
|
|
180
|
+
});
|
|
181
|
+
it('returns https URL for wildcard-matched host in production', () => {
|
|
182
|
+
const roster = new Roster({ local: false });
|
|
183
|
+
roster.register('*.example.com', () => {});
|
|
184
|
+
roster.local = false;
|
|
185
|
+
assert.strictEqual(roster.getUrl('api.example.com'), 'https://api.example.com');
|
|
186
|
+
});
|
|
187
|
+
it('returns null for host that matches no site', () => {
|
|
188
|
+
const roster = new Roster({ local: true });
|
|
189
|
+
assert.strictEqual(roster.getUrl('unknown.com'), null);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('register validation', () => {
|
|
194
|
+
it('throws when domain is missing', () => {
|
|
195
|
+
const roster = new Roster({ local: true });
|
|
196
|
+
assert.throws(() => roster.register('', () => {}), /Domain is required/);
|
|
197
|
+
assert.throws(() => roster.register(null, () => {}), /Domain is required/);
|
|
198
|
+
});
|
|
199
|
+
it('throws when handler is not a function', () => {
|
|
200
|
+
const roster = new Roster({ local: true });
|
|
201
|
+
assert.throws(() => roster.register('*.example.com', {}), /requestHandler must be a function/);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('constructor', () => {
|
|
206
|
+
it('throws when port is 80 and not local', () => {
|
|
207
|
+
assert.throws(() => new Roster({ port: 80, local: false }), /Port 80 is reserved/);
|
|
208
|
+
});
|
|
209
|
+
it('allows port 80 when local is true', () => {
|
|
210
|
+
const roster = new Roster({ port: 80, local: true });
|
|
211
|
+
assert.strictEqual(roster.defaultPort, 80);
|
|
212
|
+
});
|
|
213
|
+
it('sets defaultPort 443 when port not given', () => {
|
|
214
|
+
const roster = new Roster({ local: true });
|
|
215
|
+
assert.strictEqual(roster.defaultPort, 443);
|
|
216
|
+
});
|
|
217
|
+
it('uses acme-dns-01-cli by default (resolved to absolute path for Greenlock)', () => {
|
|
218
|
+
const roster = new Roster({ local: false });
|
|
219
|
+
assert.ok(roster.dnsChallenge);
|
|
220
|
+
assert.strictEqual(typeof roster.dnsChallenge.module, 'string');
|
|
221
|
+
assert.ok(require('path').isAbsolute(roster.dnsChallenge.module));
|
|
222
|
+
assert.ok(roster.dnsChallenge.module.includes('acme-dns-01-cli-wrapper'));
|
|
223
|
+
assert.strictEqual(roster.dnsChallenge.propagationDelay, 120000);
|
|
224
|
+
assert.strictEqual(roster.dnsChallenge.autoContinue, false);
|
|
225
|
+
assert.strictEqual(roster.dnsChallenge.dryRunDelay, 120000);
|
|
226
|
+
});
|
|
227
|
+
it('normalizes explicit acme-dns-01-cli module to wrapper and sets default propagationDelay', () => {
|
|
228
|
+
const roster = new Roster({ local: false, dnsChallenge: { module: 'acme-dns-01-cli' } });
|
|
229
|
+
assert.ok(require('path').isAbsolute(roster.dnsChallenge.module));
|
|
230
|
+
assert.ok(roster.dnsChallenge.module.includes('acme-dns-01-cli-wrapper'));
|
|
231
|
+
assert.strictEqual(roster.dnsChallenge.propagationDelay, 120000);
|
|
232
|
+
assert.strictEqual(roster.dnsChallenge.autoContinue, false);
|
|
233
|
+
assert.strictEqual(roster.dnsChallenge.dryRunDelay, 120000);
|
|
234
|
+
});
|
|
235
|
+
it('keeps explicit non-cli dnsChallenge module as-is', () => {
|
|
236
|
+
const roster = new Roster({ local: false, dnsChallenge: { module: 'acme-dns-01-route53', token: 'x' } });
|
|
237
|
+
assert.strictEqual(roster.dnsChallenge.module, 'acme-dns-01-route53');
|
|
238
|
+
assert.strictEqual(roster.dnsChallenge.token, 'x');
|
|
239
|
+
assert.strictEqual(roster.dnsChallenge.propagationDelay, 120000);
|
|
240
|
+
assert.strictEqual(roster.dnsChallenge.autoContinue, false);
|
|
241
|
+
assert.strictEqual(roster.dnsChallenge.dryRunDelay, 120000);
|
|
242
|
+
});
|
|
243
|
+
it('normalizes explicit acme-dns-01-cli absolute path to wrapper', () => {
|
|
244
|
+
const path = require('path');
|
|
245
|
+
const roster = new Roster({
|
|
246
|
+
local: false,
|
|
247
|
+
dnsChallenge: { module: path.join('/srv/roster/node_modules/acme-dns-01-cli', 'index.js') }
|
|
248
|
+
});
|
|
249
|
+
assert.ok(require('path').isAbsolute(roster.dnsChallenge.module));
|
|
250
|
+
assert.ok(roster.dnsChallenge.module.includes('acme-dns-01-cli-wrapper'));
|
|
251
|
+
assert.strictEqual(roster.dnsChallenge.propagationDelay, 120000);
|
|
252
|
+
assert.strictEqual(roster.dnsChallenge.autoContinue, false);
|
|
253
|
+
assert.strictEqual(roster.dnsChallenge.dryRunDelay, 120000);
|
|
254
|
+
});
|
|
255
|
+
it('allows disabling DNS challenge with dnsChallenge: false', () => {
|
|
256
|
+
const roster = new Roster({ local: false, dnsChallenge: false });
|
|
257
|
+
assert.strictEqual(roster.dnsChallenge, null);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('register (normal domain)', () => {
|
|
262
|
+
it('adds domain and www when domain has fewer than 2 dots', () => {
|
|
263
|
+
const roster = new Roster({ local: true });
|
|
264
|
+
const handler = () => {};
|
|
265
|
+
roster.register('example.com', handler);
|
|
266
|
+
assert.strictEqual(roster.sites['example.com'], handler);
|
|
267
|
+
assert.strictEqual(roster.sites['www.example.com'], handler);
|
|
268
|
+
});
|
|
269
|
+
it('does not add www for multi-label domain', () => {
|
|
270
|
+
const roster = new Roster({ local: true });
|
|
271
|
+
const handler = () => {};
|
|
272
|
+
roster.register('api.example.com', handler);
|
|
273
|
+
assert.strictEqual(roster.sites['api.example.com'], handler);
|
|
274
|
+
assert.strictEqual(roster.sites['www.api.example.com'], undefined);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe('getUrl (exact domain)', () => {
|
|
279
|
+
it('returns http://localhost:PORT in local mode for registered domain', () => {
|
|
280
|
+
const roster = new Roster({ local: true });
|
|
281
|
+
roster.register('exact.local', () => {});
|
|
282
|
+
roster.domainPorts = { 'exact.local': 4567 };
|
|
283
|
+
roster.local = true;
|
|
284
|
+
assert.strictEqual(roster.getUrl('exact.local'), 'http://localhost:4567');
|
|
285
|
+
});
|
|
286
|
+
it('returns https URL in production for registered domain', () => {
|
|
287
|
+
const roster = new Roster({ local: false });
|
|
288
|
+
roster.register('example.com', () => {});
|
|
289
|
+
roster.local = false;
|
|
290
|
+
assert.strictEqual(roster.getUrl('example.com'), 'https://example.com');
|
|
291
|
+
});
|
|
292
|
+
it('strips www and returns canonical URL (same as non-www)', () => {
|
|
293
|
+
const roster = new Roster({ local: false });
|
|
294
|
+
roster.register('example.com', () => {});
|
|
295
|
+
assert.strictEqual(roster.getUrl('www.example.com'), 'https://example.com');
|
|
296
|
+
assert.strictEqual(roster.getUrl('example.com'), 'https://example.com');
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe('handleRequest', () => {
|
|
301
|
+
it('redirects www to non-www with 301', () => {
|
|
302
|
+
const roster = new Roster({ local: true });
|
|
303
|
+
const res = {
|
|
304
|
+
writeHead: (status, headers) => {
|
|
305
|
+
assert.strictEqual(status, 301);
|
|
306
|
+
assert.strictEqual(headers.Location, 'https://example.com/');
|
|
307
|
+
},
|
|
308
|
+
end: () => {}
|
|
309
|
+
};
|
|
310
|
+
roster.handleRequest(
|
|
311
|
+
{ headers: { host: 'www.example.com' }, url: '/' },
|
|
312
|
+
res
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
it('returns 404 when host has no handler', () => {
|
|
316
|
+
const roster = new Roster({ local: true });
|
|
317
|
+
let status;
|
|
318
|
+
const res = {
|
|
319
|
+
writeHead: (s) => { status = s; },
|
|
320
|
+
end: () => {}
|
|
321
|
+
};
|
|
322
|
+
roster.handleRequest(
|
|
323
|
+
{ headers: { host: 'unknown.example.com' }, url: '/' },
|
|
324
|
+
res
|
|
325
|
+
);
|
|
326
|
+
assert.strictEqual(status, 404);
|
|
327
|
+
});
|
|
328
|
+
it('invokes handler for registered host', () => {
|
|
329
|
+
const roster = new Roster({ local: true });
|
|
330
|
+
let called = false;
|
|
331
|
+
roster.register('example.com', (req, res) => {
|
|
332
|
+
called = true;
|
|
333
|
+
res.writeHead(200);
|
|
334
|
+
res.end('ok');
|
|
335
|
+
});
|
|
336
|
+
const res = {
|
|
337
|
+
writeHead: () => {},
|
|
338
|
+
end: () => {}
|
|
339
|
+
};
|
|
340
|
+
roster.handleRequest(
|
|
341
|
+
{ headers: { host: 'example.com' }, url: '/' },
|
|
342
|
+
res
|
|
343
|
+
);
|
|
344
|
+
assert.strictEqual(called, true);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
describe('Roster local mode (local: true)', () => {
|
|
350
|
+
it('starts HTTP server and responds for registered domain', async () => {
|
|
351
|
+
const roster = new Roster({
|
|
352
|
+
local: true,
|
|
353
|
+
minLocalPort: 19090,
|
|
354
|
+
maxLocalPort: 19099,
|
|
355
|
+
hostname: 'localhost'
|
|
356
|
+
});
|
|
357
|
+
const body = 'local-mode-ok';
|
|
358
|
+
roster.register('testlocal.example', (server) => {
|
|
359
|
+
return (req, res) => {
|
|
360
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
361
|
+
res.end(body);
|
|
362
|
+
};
|
|
363
|
+
});
|
|
364
|
+
await roster.start();
|
|
365
|
+
try {
|
|
366
|
+
const port = roster.domainPorts['testlocal.example'];
|
|
367
|
+
assert.ok(typeof port === 'number' && port >= 19090 && port <= 19099);
|
|
368
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
369
|
+
const result = await httpGet('localhost', port, '/');
|
|
370
|
+
assert.strictEqual(result.statusCode, 200);
|
|
371
|
+
assert.strictEqual(result.body, body);
|
|
372
|
+
} finally {
|
|
373
|
+
closePortServers(roster);
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('getUrl returns localhost URL after start', async () => {
|
|
378
|
+
const roster = new Roster({
|
|
379
|
+
local: true,
|
|
380
|
+
minLocalPort: 19100,
|
|
381
|
+
maxLocalPort: 19109
|
|
382
|
+
});
|
|
383
|
+
roster.register('geturltest.example', () => () => {});
|
|
384
|
+
await roster.start();
|
|
385
|
+
try {
|
|
386
|
+
const url = roster.getUrl('geturltest.example');
|
|
387
|
+
assert.ok(url && url.startsWith('http://localhost:'));
|
|
388
|
+
assert.ok(roster.domainPorts['geturltest.example'] !== undefined);
|
|
389
|
+
} finally {
|
|
390
|
+
closePortServers(roster);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
describe('Roster loadSites', () => {
|
|
396
|
+
it('loads site from www directory and registers domain + www', async () => {
|
|
397
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'roster-test-'));
|
|
398
|
+
const wwwPath = path.join(tmpDir, 'www');
|
|
399
|
+
const siteDir = path.join(wwwPath, 'loaded.example');
|
|
400
|
+
fs.mkdirSync(siteDir, { recursive: true });
|
|
401
|
+
fs.writeFileSync(
|
|
402
|
+
path.join(siteDir, 'index.js'),
|
|
403
|
+
'module.exports = () => (req, res) => { res.writeHead(200); res.end("loaded"); };',
|
|
404
|
+
'utf8'
|
|
405
|
+
);
|
|
406
|
+
try {
|
|
407
|
+
const roster = new Roster({ wwwPath, local: true });
|
|
408
|
+
await roster.loadSites();
|
|
409
|
+
assert.ok(roster.sites['loaded.example']);
|
|
410
|
+
assert.ok(roster.sites['www.loaded.example']);
|
|
411
|
+
const handler = roster.sites['loaded.example'];
|
|
412
|
+
assert.strictEqual(typeof handler, 'function');
|
|
413
|
+
} finally {
|
|
414
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('loads wildcard site from www/*.example.com directory', async () => {
|
|
419
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'roster-test-'));
|
|
420
|
+
const wwwPath = path.join(tmpDir, 'www');
|
|
421
|
+
const siteDir = path.join(wwwPath, '*.wildcard.example');
|
|
422
|
+
fs.mkdirSync(siteDir, { recursive: true });
|
|
423
|
+
fs.writeFileSync(
|
|
424
|
+
path.join(siteDir, 'index.js'),
|
|
425
|
+
'module.exports = () => (req, res) => { res.writeHead(200); res.end("wildcard"); };',
|
|
426
|
+
'utf8'
|
|
427
|
+
);
|
|
428
|
+
try {
|
|
429
|
+
const roster = new Roster({ wwwPath, local: true });
|
|
430
|
+
await roster.loadSites();
|
|
431
|
+
assert.ok(roster.sites['*.wildcard.example']);
|
|
432
|
+
assert.ok(roster.wildcardZones.has('wildcard.example'));
|
|
433
|
+
assert.strictEqual(roster.getHandlerForHost('api.wildcard.example'), roster.sites['*.wildcard.example']);
|
|
434
|
+
} finally {
|
|
435
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('does not throw when www path does not exist', async () => {
|
|
440
|
+
const roster = new Roster({
|
|
441
|
+
wwwPath: path.join(os.tmpdir(), 'roster-nonexistent-' + Date.now()),
|
|
442
|
+
local: true
|
|
443
|
+
});
|
|
444
|
+
await assert.doesNotReject(roster.loadSites());
|
|
445
|
+
});
|
|
446
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const legacyCli = require('acme-dns-01-cli');
|
|
4
|
+
|
|
5
|
+
function toPromise(fn, context) {
|
|
6
|
+
if (typeof fn !== 'function') {
|
|
7
|
+
return async function () {
|
|
8
|
+
return null;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return async function (opts) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
let done = false;
|
|
15
|
+
const finish = (err, result) => {
|
|
16
|
+
if (done) return;
|
|
17
|
+
done = true;
|
|
18
|
+
if (err) reject(err);
|
|
19
|
+
else resolve(result);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
// Legacy callback style
|
|
24
|
+
if (fn.length >= 2) {
|
|
25
|
+
fn.call(context, opts, finish);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Promise or sync style
|
|
30
|
+
Promise.resolve(fn.call(context, opts)).then(
|
|
31
|
+
(result) => finish(null, result),
|
|
32
|
+
finish
|
|
33
|
+
);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
finish(err);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports.create = function create(config = {}) {
|
|
42
|
+
const challenger = legacyCli.create(config);
|
|
43
|
+
const propagationDelay = Number.isFinite(config.propagationDelay)
|
|
44
|
+
? config.propagationDelay
|
|
45
|
+
: 120000;
|
|
46
|
+
const envAutoContinue = process.env.ROSTER_DNS_AUTO_CONTINUE;
|
|
47
|
+
const parseAutoContinue = (value, fallback) => {
|
|
48
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
49
|
+
const normalized = String(value).trim().toLowerCase();
|
|
50
|
+
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
|
51
|
+
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
|
52
|
+
return fallback;
|
|
53
|
+
};
|
|
54
|
+
const autoContinue = config.autoContinue !== undefined
|
|
55
|
+
? parseAutoContinue(config.autoContinue, false)
|
|
56
|
+
: parseAutoContinue(envAutoContinue, false);
|
|
57
|
+
const dryRunDelay = Number.isFinite(config.dryRunDelay)
|
|
58
|
+
? config.dryRunDelay
|
|
59
|
+
: Number.isFinite(Number(process.env.ROSTER_DNS_DRYRUN_DELAY_MS))
|
|
60
|
+
? Number(process.env.ROSTER_DNS_DRYRUN_DELAY_MS)
|
|
61
|
+
: propagationDelay;
|
|
62
|
+
|
|
63
|
+
function sleep(ms) {
|
|
64
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const presentedByHost = new Map();
|
|
68
|
+
|
|
69
|
+
async function setChallenge(opts) {
|
|
70
|
+
const isInteractive = Boolean(process.stdin && process.stdin.isTTY);
|
|
71
|
+
if (isInteractive && !autoContinue) {
|
|
72
|
+
return toPromise(challenger.set, challenger)(opts);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ch = opts?.challenge || {};
|
|
76
|
+
console.info('');
|
|
77
|
+
console.info("[ACME dns-01 '" + (ch.altname || opts?.altname || 'unknown') + "' CHALLENGE]");
|
|
78
|
+
console.info("You're about to receive the following DNS query:");
|
|
79
|
+
console.info('');
|
|
80
|
+
const dnsHost = String(ch.dnsHost || '');
|
|
81
|
+
const dnsAuth = ch.dnsAuthorization || opts?.dnsAuthorization || null;
|
|
82
|
+
if (dnsHost && dnsAuth) {
|
|
83
|
+
presentedByHost.set(dnsHost, dnsAuth);
|
|
84
|
+
}
|
|
85
|
+
const isDryRunChallenge = dnsHost.includes('_greenlock-dryrun-');
|
|
86
|
+
const effectiveDelay = isDryRunChallenge
|
|
87
|
+
? Math.max(0, dryRunDelay)
|
|
88
|
+
: propagationDelay;
|
|
89
|
+
|
|
90
|
+
console.info(
|
|
91
|
+
'\tTXT\t' +
|
|
92
|
+
(ch.dnsHost || '_acme-challenge.<domain>') +
|
|
93
|
+
'\t' +
|
|
94
|
+
(ch.dnsAuthorization || '<dns-authorization-token>') +
|
|
95
|
+
'\tTTL 60'
|
|
96
|
+
);
|
|
97
|
+
console.info('');
|
|
98
|
+
console.info(
|
|
99
|
+
'Non-interactive mode (or autoContinue) detected. ' +
|
|
100
|
+
'Set the TXT record now. Continuing automatically in ' +
|
|
101
|
+
effectiveDelay +
|
|
102
|
+
'ms...'
|
|
103
|
+
);
|
|
104
|
+
await sleep(effectiveDelay);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function getChallenge(opts) {
|
|
109
|
+
const ch = opts?.challenge || {};
|
|
110
|
+
const dnsHost = String(ch.dnsHost || opts?.dnsHost || '');
|
|
111
|
+
const dnsAuthorization =
|
|
112
|
+
ch.dnsAuthorization ||
|
|
113
|
+
opts?.dnsAuthorization ||
|
|
114
|
+
(dnsHost ? presentedByHost.get(dnsHost) : null);
|
|
115
|
+
|
|
116
|
+
if (!dnsAuthorization) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
...(typeof ch === 'object' ? ch : {}),
|
|
122
|
+
dnsAuthorization
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const wrapped = {
|
|
127
|
+
propagationDelay,
|
|
128
|
+
set: setChallenge,
|
|
129
|
+
remove: toPromise(challenger.remove, challenger),
|
|
130
|
+
get: getChallenge,
|
|
131
|
+
zones: async (opts) => {
|
|
132
|
+
const dnsHost =
|
|
133
|
+
opts?.dnsHost ||
|
|
134
|
+
opts?.challenge?.dnsHost ||
|
|
135
|
+
opts?.challenge?.altname ||
|
|
136
|
+
opts?.altname;
|
|
137
|
+
|
|
138
|
+
if (!dnsHost || typeof dnsHost !== 'string') {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Best-effort root zone extraction for legacy/manual flow.
|
|
143
|
+
const zone = dnsHost
|
|
144
|
+
.replace(/^_acme-challenge\./, '')
|
|
145
|
+
.replace(/^_greenlock-[^.]+\./, '')
|
|
146
|
+
.replace(/\.$/, '');
|
|
147
|
+
|
|
148
|
+
return zone ? [zone] : [];
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
if (typeof challenger.init === 'function') {
|
|
153
|
+
wrapped.init = toPromise(challenger.init, challenger);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (challenger.options && typeof challenger.options === 'object') {
|
|
157
|
+
wrapped.options = { ...challenger.options };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return wrapped;
|
|
161
|
+
};
|