roster-server 2.0.6 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -9
- package/demo/wildcard-example.js +31 -0
- package/index.js +254 -92
- package/package.json +3 -2
- package/skills/roster-server/SKILL.md +12 -4
- package/test/roster-server.test.js +483 -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`). When wildcard sites are present, Roster creates a separate wildcard certificate (`*.example.com`) that uses `dns-01`, while apex/www stay on the regular certificate flow (typically `http-01`), reducing manual TXT records.
|
|
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 = {};
|
|
@@ -248,26 +303,63 @@ class Roster {
|
|
|
248
303
|
}
|
|
249
304
|
|
|
250
305
|
uniqueDomains.forEach(domain => {
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
306
|
+
const applyRenewAtIfUnchanged = (siteConfig, existingSite) => {
|
|
307
|
+
if (!existingSite || !existingSite.renewAt) return;
|
|
308
|
+
const existingAltnames = Array.isArray(existingSite.altnames)
|
|
309
|
+
? [...existingSite.altnames].sort()
|
|
310
|
+
: [];
|
|
311
|
+
const nextAltnames = Array.isArray(siteConfig.altnames)
|
|
312
|
+
? [...siteConfig.altnames].sort()
|
|
313
|
+
: [];
|
|
314
|
+
const sameAltnames =
|
|
315
|
+
existingAltnames.length === nextAltnames.length &&
|
|
316
|
+
existingAltnames.every((name, idx) => name === nextAltnames[idx]);
|
|
317
|
+
if (sameAltnames) {
|
|
318
|
+
siteConfig.renewAt = existingSite.renewAt;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
255
321
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
322
|
+
// Primary cert for apex/www uses default challenge flow (typically http-01).
|
|
323
|
+
const primaryAltnames = [domain];
|
|
324
|
+
if ((domain.match(/\./g) || []).length < 2) {
|
|
325
|
+
primaryAltnames.push(`www.${domain}`);
|
|
259
326
|
}
|
|
260
|
-
|
|
261
|
-
const siteConfig = {
|
|
327
|
+
const primarySite = {
|
|
262
328
|
subject: domain,
|
|
263
|
-
altnames:
|
|
329
|
+
altnames: primaryAltnames
|
|
264
330
|
};
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
331
|
+
const existingPrimarySite = Array.isArray(existingConfig.sites)
|
|
332
|
+
? existingConfig.sites.find(site => site.subject === domain)
|
|
333
|
+
: null;
|
|
334
|
+
applyRenewAtIfUnchanged(primarySite, existingPrimarySite);
|
|
335
|
+
sitesConfig.push(primarySite);
|
|
336
|
+
|
|
337
|
+
// Wildcard cert is issued separately and uses dns-01 only.
|
|
338
|
+
if (this.wildcardZones.has(domain) && this.dnsChallenge) {
|
|
339
|
+
const wildcardSubject = `*.${domain}`;
|
|
340
|
+
const dns01 = { ...this.dnsChallenge };
|
|
341
|
+
if (dns01.propagationDelay === undefined) {
|
|
342
|
+
dns01.propagationDelay = 120000; // 120s default for manual DNS (acme-dns-01-cli)
|
|
343
|
+
}
|
|
344
|
+
if (dns01.autoContinue === undefined) {
|
|
345
|
+
dns01.autoContinue = false;
|
|
346
|
+
}
|
|
347
|
+
if (dns01.dryRunDelay === undefined) {
|
|
348
|
+
dns01.dryRunDelay = dns01.propagationDelay;
|
|
349
|
+
}
|
|
350
|
+
const wildcardSite = {
|
|
351
|
+
subject: wildcardSubject,
|
|
352
|
+
altnames: [wildcardSubject],
|
|
353
|
+
challenges: {
|
|
354
|
+
'dns-01': dns01
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
const existingWildcardSite = Array.isArray(existingConfig.sites)
|
|
358
|
+
? existingConfig.sites.find(site => site.subject === wildcardSubject)
|
|
359
|
+
: null;
|
|
360
|
+
applyRenewAtIfUnchanged(wildcardSite, existingWildcardSite);
|
|
361
|
+
sitesConfig.push(wildcardSite);
|
|
268
362
|
}
|
|
269
|
-
|
|
270
|
-
sitesConfig.push(siteConfig);
|
|
271
363
|
});
|
|
272
364
|
|
|
273
365
|
const newConfig = {
|
|
@@ -310,6 +402,47 @@ class Roster {
|
|
|
310
402
|
log.info(`📁 config.json generated at ${configPath}`);
|
|
311
403
|
}
|
|
312
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Resolve handler for a host (exact match, then wildcard). Used when port is not in the key.
|
|
407
|
+
*/
|
|
408
|
+
getHandlerForHost(host) {
|
|
409
|
+
const resolved = this.getHandlerAndKeyForHost(host);
|
|
410
|
+
return resolved ? resolved.handler : null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Resolve handler and site key for a host (exact match, then wildcard). Used by getUrl for wildcard lookups.
|
|
415
|
+
*/
|
|
416
|
+
getHandlerAndKeyForHost(host) {
|
|
417
|
+
const siteApp = this.sites[host];
|
|
418
|
+
if (siteApp) return { handler: siteApp, siteKey: host };
|
|
419
|
+
for (const key of Object.keys(this.sites)) {
|
|
420
|
+
if (key.startsWith('*.')) {
|
|
421
|
+
const pattern = key.split(':')[0];
|
|
422
|
+
if (hostMatchesWildcard(host, pattern)) return { handler: this.sites[key], siteKey: key };
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Resolve virtualServer and appHandler for a host from portData (exact then wildcard).
|
|
430
|
+
*/
|
|
431
|
+
getHandlerForPortData(host, portData) {
|
|
432
|
+
const virtualServer = portData.virtualServers[host];
|
|
433
|
+
const appHandler = portData.appHandlers[host];
|
|
434
|
+
if (virtualServer && appHandler !== undefined) return { virtualServer, appHandler };
|
|
435
|
+
for (const key of Object.keys(portData.appHandlers)) {
|
|
436
|
+
if (key.startsWith('*.') && hostMatchesWildcard(host, key)) {
|
|
437
|
+
return {
|
|
438
|
+
virtualServer: portData.virtualServers[key],
|
|
439
|
+
appHandler: portData.appHandlers[key]
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
313
446
|
handleRequest(req, res) {
|
|
314
447
|
const host = req.headers.host || '';
|
|
315
448
|
|
|
@@ -320,7 +453,8 @@ class Roster {
|
|
|
320
453
|
return;
|
|
321
454
|
}
|
|
322
455
|
|
|
323
|
-
const
|
|
456
|
+
const hostWithoutPort = host.split(':')[0];
|
|
457
|
+
const siteApp = this.getHandlerForHost(hostWithoutPort);
|
|
324
458
|
if (siteApp) {
|
|
325
459
|
siteApp(req, res);
|
|
326
460
|
} else {
|
|
@@ -339,6 +473,16 @@ class Roster {
|
|
|
339
473
|
|
|
340
474
|
const { domain, port } = this.parseDomainWithPort(domainString);
|
|
341
475
|
|
|
476
|
+
if (domain.startsWith('*.')) {
|
|
477
|
+
const domainKey = port === this.defaultPort ? domain : `${domain}:${port}`;
|
|
478
|
+
this.domains.push(domain);
|
|
479
|
+
this.sites[domainKey] = requestHandler;
|
|
480
|
+
const root = wildcardRoot(domain);
|
|
481
|
+
if (root) this.wildcardZones.add(root);
|
|
482
|
+
log.info(`(✔) Registered wildcard site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
|
|
483
|
+
return this;
|
|
484
|
+
}
|
|
485
|
+
|
|
342
486
|
const domainEntries = [domain];
|
|
343
487
|
if ((domain.match(/\./g) || []).length < 2) {
|
|
344
488
|
domainEntries.push(`www.${domain}`);
|
|
@@ -346,7 +490,6 @@ class Roster {
|
|
|
346
490
|
|
|
347
491
|
this.domains.push(...domainEntries);
|
|
348
492
|
domainEntries.forEach(d => {
|
|
349
|
-
// Store with port information
|
|
350
493
|
const domainKey = port === this.defaultPort ? d : `${d}:${port}`;
|
|
351
494
|
this.sites[domainKey] = requestHandler;
|
|
352
495
|
});
|
|
@@ -370,31 +513,25 @@ class Roster {
|
|
|
370
513
|
|
|
371
514
|
/**
|
|
372
515
|
* 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
|
|
516
|
+
* @param {string} domain - The domain name (or subdomain that matches a wildcard site)
|
|
517
|
+
* @returns {string|null} The URL if domain is registered (exact or wildcard), null otherwise
|
|
375
518
|
*/
|
|
376
519
|
getUrl(domain) {
|
|
377
|
-
// Remove www prefix if present
|
|
378
520
|
const cleanDomain = domain.startsWith('www.') ? domain.slice(4) : domain;
|
|
379
521
|
|
|
380
|
-
|
|
381
|
-
const
|
|
382
|
-
if (!
|
|
383
|
-
return null;
|
|
384
|
-
}
|
|
522
|
+
const exactMatch = this.sites[cleanDomain] || this.sites[`www.${cleanDomain}`];
|
|
523
|
+
const resolved = exactMatch ? { handler: exactMatch, siteKey: cleanDomain } : this.getHandlerAndKeyForHost(cleanDomain);
|
|
524
|
+
if (!resolved) return null;
|
|
385
525
|
|
|
386
|
-
// Return URL based on environment
|
|
387
526
|
if (this.local) {
|
|
388
|
-
|
|
389
|
-
if (this.domainPorts && this.domainPorts[
|
|
390
|
-
return `http://localhost:${this.domainPorts[
|
|
527
|
+
const pattern = resolved.siteKey.split(':')[0];
|
|
528
|
+
if (this.domainPorts && this.domainPorts[pattern] !== undefined) {
|
|
529
|
+
return `http://localhost:${this.domainPorts[pattern]}`;
|
|
391
530
|
}
|
|
392
531
|
return null;
|
|
393
|
-
} else {
|
|
394
|
-
// Production mode: return HTTPS URL
|
|
395
|
-
const port = this.defaultPort === 443 ? '' : `:${this.defaultPort}`;
|
|
396
|
-
return `https://${cleanDomain}${port}`;
|
|
397
532
|
}
|
|
533
|
+
const port = this.defaultPort === 443 ? '' : `:${this.defaultPort}`;
|
|
534
|
+
return `https://${cleanDomain}${port}`;
|
|
398
535
|
}
|
|
399
536
|
|
|
400
537
|
createVirtualServer(domain) {
|
|
@@ -517,7 +654,18 @@ class Roster {
|
|
|
517
654
|
configDir: this.greenlockStorePath,
|
|
518
655
|
maintainerEmail: this.email,
|
|
519
656
|
cluster: this.cluster,
|
|
520
|
-
staging: this.staging
|
|
657
|
+
staging: this.staging,
|
|
658
|
+
notify: (event, details) => {
|
|
659
|
+
const msg = typeof details === 'string' ? details : (details?.message ?? JSON.stringify(details));
|
|
660
|
+
// Suppress known benign warnings from ACME when using acme-dns-01-cli
|
|
661
|
+
if (event === 'warning' && typeof msg === 'string') {
|
|
662
|
+
if (/acme-dns-01-cli.*(incorrect function signatures|deprecated use of callbacks)/i.test(msg)) return;
|
|
663
|
+
if (/dns-01 challenge plugin should have zones/i.test(msg)) return;
|
|
664
|
+
}
|
|
665
|
+
if (event === 'error') log.error(msg);
|
|
666
|
+
else if (event === 'warning') log.warn(msg);
|
|
667
|
+
else log.info(msg);
|
|
668
|
+
}
|
|
521
669
|
});
|
|
522
670
|
|
|
523
671
|
return greenlock.ready(glx => {
|
|
@@ -535,15 +683,15 @@ class Roster {
|
|
|
535
683
|
};
|
|
536
684
|
}
|
|
537
685
|
|
|
538
|
-
// Create completely isolated virtual server
|
|
539
686
|
const virtualServer = this.createVirtualServer(domain);
|
|
540
687
|
sitesByPort[port].virtualServers[domain] = virtualServer;
|
|
541
688
|
this.domainServers[domain] = virtualServer;
|
|
542
689
|
|
|
543
|
-
// Initialize app with virtual server
|
|
544
690
|
const appHandler = siteApp(virtualServer);
|
|
545
691
|
sitesByPort[port].appHandlers[domain] = appHandler;
|
|
546
|
-
|
|
692
|
+
if (!domain.startsWith('*.')) {
|
|
693
|
+
sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
|
|
694
|
+
}
|
|
547
695
|
}
|
|
548
696
|
}
|
|
549
697
|
|
|
@@ -552,27 +700,27 @@ class Roster {
|
|
|
552
700
|
return (req, res) => {
|
|
553
701
|
const host = req.headers.host || '';
|
|
554
702
|
|
|
555
|
-
|
|
556
|
-
const hostWithoutPort = host.split(':')[0];
|
|
703
|
+
const hostWithoutPort = host.split(':')[0].toLowerCase();
|
|
557
704
|
const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
|
|
558
705
|
|
|
559
|
-
// Handle www redirects
|
|
560
706
|
if (hostWithoutPort.startsWith('www.')) {
|
|
561
707
|
res.writeHead(301, { Location: `https://${domain}${req.url}` });
|
|
562
708
|
res.end();
|
|
563
709
|
return;
|
|
564
710
|
}
|
|
565
711
|
|
|
566
|
-
const
|
|
567
|
-
|
|
712
|
+
const resolved = this.getHandlerForPortData(domain, portData);
|
|
713
|
+
if (!resolved) {
|
|
714
|
+
res.writeHead(404);
|
|
715
|
+
res.end('Site not found');
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const { virtualServer, appHandler } = resolved;
|
|
568
719
|
|
|
569
720
|
if (virtualServer && virtualServer.requestListeners.length > 0) {
|
|
570
|
-
// Set fallback handler on virtual server for non-Socket.IO requests
|
|
571
721
|
virtualServer.fallbackHandler = appHandler;
|
|
572
|
-
// App registered listeners on virtual server - use them
|
|
573
722
|
virtualServer.processRequest(req, res);
|
|
574
723
|
} else if (appHandler) {
|
|
575
|
-
// App returned a handler function - use it
|
|
576
724
|
appHandler(req, res);
|
|
577
725
|
} else {
|
|
578
726
|
res.writeHead(404);
|
|
@@ -585,19 +733,16 @@ class Roster {
|
|
|
585
733
|
log.info('HTTP server listening on port 80');
|
|
586
734
|
});
|
|
587
735
|
|
|
588
|
-
// Create upgrade handler for WebSocket connections
|
|
589
736
|
const createUpgradeHandler = (portData) => {
|
|
590
737
|
return (req, socket, head) => {
|
|
591
738
|
const host = req.headers.host || '';
|
|
592
|
-
const hostWithoutPort = host.split(':')[0];
|
|
739
|
+
const hostWithoutPort = host.split(':')[0].toLowerCase();
|
|
593
740
|
const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
|
|
594
741
|
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
virtualServer.processUpgrade(req, socket, head);
|
|
742
|
+
const resolved = this.getHandlerForPortData(domain, portData);
|
|
743
|
+
if (resolved && resolved.virtualServer) {
|
|
744
|
+
resolved.virtualServer.processUpgrade(req, socket, head);
|
|
599
745
|
} else {
|
|
600
|
-
// No virtual server found, destroy the socket
|
|
601
746
|
socket.destroy();
|
|
602
747
|
}
|
|
603
748
|
};
|
|
@@ -608,6 +753,29 @@ class Roster {
|
|
|
608
753
|
const portNum = parseInt(port);
|
|
609
754
|
const dispatcher = createDispatcher(portData);
|
|
610
755
|
const upgradeHandler = createUpgradeHandler(portData);
|
|
756
|
+
const greenlockStorePath = this.greenlockStorePath;
|
|
757
|
+
const loadCert = (subjectDir) => {
|
|
758
|
+
const certPath = path.join(greenlockStorePath, 'live', subjectDir);
|
|
759
|
+
const keyPath = path.join(certPath, 'privkey.pem');
|
|
760
|
+
const certFilePath = path.join(certPath, 'cert.pem');
|
|
761
|
+
const chainPath = path.join(certPath, 'chain.pem');
|
|
762
|
+
if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
|
|
763
|
+
return {
|
|
764
|
+
key: fs.readFileSync(keyPath, 'utf8'),
|
|
765
|
+
cert: fs.readFileSync(certFilePath, 'utf8') + fs.readFileSync(chainPath, 'utf8')
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
return null;
|
|
769
|
+
};
|
|
770
|
+
const zoneSubjectForHost = (servername) => {
|
|
771
|
+
const labels = String(servername || '').split('.').filter(Boolean);
|
|
772
|
+
if (labels.length < 3) return null;
|
|
773
|
+
return labels.slice(1).join('.');
|
|
774
|
+
};
|
|
775
|
+
const resolvePemsForServername = (servername) => {
|
|
776
|
+
if (!servername) return null;
|
|
777
|
+
return loadCert(servername) || loadCert(zoneSubjectForHost(servername));
|
|
778
|
+
};
|
|
611
779
|
|
|
612
780
|
if (portNum === this.defaultPort) {
|
|
613
781
|
// Bun has known gaps around SNICallback compatibility.
|
|
@@ -618,23 +786,27 @@ class Roster {
|
|
|
618
786
|
|
|
619
787
|
if (isBunRuntime) {
|
|
620
788
|
const primaryDomain = Object.keys(portData.virtualServers)[0];
|
|
621
|
-
|
|
622
|
-
const
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
|
|
627
|
-
const key = fs.readFileSync(keyPath, 'utf8');
|
|
628
|
-
const cert = fs.readFileSync(certFilePath, 'utf8');
|
|
629
|
-
const chain = fs.readFileSync(chainPath, 'utf8');
|
|
789
|
+
// Greenlock stores certs by subject (e.g. tagnu.com), not by wildcard (*.tagnu.com)
|
|
790
|
+
const certSubject = primaryDomain.startsWith('*.') ? wildcardRoot(primaryDomain) : primaryDomain;
|
|
791
|
+
const defaultPems = resolvePemsForServername(certSubject);
|
|
792
|
+
|
|
793
|
+
if (defaultPems) {
|
|
630
794
|
httpsServer = https.createServer({
|
|
631
795
|
...tlsOpts,
|
|
632
|
-
key,
|
|
633
|
-
cert: cert
|
|
796
|
+
key: defaultPems.key,
|
|
797
|
+
cert: defaultPems.cert,
|
|
798
|
+
SNICallback: (servername, callback) => {
|
|
799
|
+
try {
|
|
800
|
+
const pems = resolvePemsForServername(servername) || defaultPems;
|
|
801
|
+
callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
|
|
802
|
+
} catch (error) {
|
|
803
|
+
callback(error);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
634
806
|
}, dispatcher);
|
|
635
|
-
log.warn(`⚠️ Bun runtime detected: using
|
|
807
|
+
log.warn(`⚠️ Bun runtime detected: using file-based TLS with SNI for ${primaryDomain} on port ${portNum}`);
|
|
636
808
|
} else {
|
|
637
|
-
log.warn(`⚠️ Bun runtime detected but cert files missing for ${primaryDomain}; falling back to Greenlock HTTPS server`);
|
|
809
|
+
log.warn(`⚠️ Bun runtime detected but cert files missing for ${certSubject} (${primaryDomain}); falling back to Greenlock HTTPS server`);
|
|
638
810
|
httpsServer = glx.httpsServer(tlsOpts, dispatcher);
|
|
639
811
|
}
|
|
640
812
|
} else {
|
|
@@ -654,25 +826,13 @@ class Roster {
|
|
|
654
826
|
const httpsOptions = {
|
|
655
827
|
minVersion: this.tlsMinVersion,
|
|
656
828
|
maxVersion: this.tlsMaxVersion,
|
|
657
|
-
|
|
658
|
-
SNICallback: (domain, callback) => {
|
|
829
|
+
SNICallback: (servername, callback) => {
|
|
659
830
|
try {
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
const chainPath = path.join(certPath, 'chain.pem');
|
|
664
|
-
|
|
665
|
-
if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
|
|
666
|
-
const key = fs.readFileSync(keyPath, 'utf8');
|
|
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
|
-
}));
|
|
831
|
+
const pems = resolvePemsForServername(servername);
|
|
832
|
+
if (pems) {
|
|
833
|
+
callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
|
|
674
834
|
} else {
|
|
675
|
-
callback(new Error(`No certificate files available for ${
|
|
835
|
+
callback(new Error(`No certificate files available for ${servername}`));
|
|
676
836
|
}
|
|
677
837
|
} catch (error) {
|
|
678
838
|
callback(error);
|
|
@@ -711,4 +871,6 @@ class Roster {
|
|
|
711
871
|
}
|
|
712
872
|
}
|
|
713
873
|
|
|
714
|
-
module.exports = Roster;
|
|
874
|
+
module.exports = Roster;
|
|
875
|
+
module.exports.wildcardRoot = wildcardRoot;
|
|
876
|
+
module.exports.hostMatchesWildcard = hostMatchesWildcard;
|