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.
- package/README.md +57 -122
- package/demo/wildcard-example.js +31 -0
- package/index.js +292 -155
- package/package.json +4 -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
|
@@ -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
|
+
};
|