roster-server 2.0.4 → 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.
@@ -40,8 +40,10 @@ project/
40
40
  ├── www/
41
41
  │ ├── example.com/
42
42
  │ │ └── index.js # Handler for example.com
43
- └── api.example.com/
44
- └── index.js # Handler for subdomain
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
+ };