roster-server 2.4.2 → 2.4.6
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/.greenlockrc +1 -0
- package/README.md +49 -1
- package/demo/https-cluster-configurable.js +95 -0
- package/docs/decisions/roster-autocert-defaults.md +20 -0
- package/index.js +215 -66
- package/package.json +1 -1
- package/skills/roster-server/SKILL.md +38 -1
- package/test/roster-server.test.js +86 -0
- package/demo/cluster-sticky-https-sni.js +0 -90
- package/demo/cluster-sticky-worker.js +0 -60
package/.greenlockrc
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"configDir":"/Users/martinclasen/Stuff/greenlock.d"}
|
package/README.md
CHANGED
|
@@ -247,6 +247,8 @@ When creating a new `RosterServer` instance, you can pass the following options:
|
|
|
247
247
|
- `greenlockStorePath` (string): Directory for Greenlock configuration.
|
|
248
248
|
- `dnsChallenge` (object|false): Optional override for wildcard DNS-01 challenge config. Default is `acme-dns-01-cli` wrapper with `propagationDelay: 120000`, `autoContinue: false`, and `dryRunDelay: 120000`. Manual mode still works, but you can enable automatic Linode DNS API mode by setting `ROSTER_DNS_PROVIDER=linode` and `LINODE_API_KEY`. In automatic mode, Roster creates/removes TXT records itself and still polls public resolvers every 15s before continuing. Set `false` to disable DNS challenge. You can pass `{ module: '...', propagationDelay: 180000 }` to tune DNS wait time (ms). 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.
|
|
249
249
|
- `staging` (boolean): Set to `true` to use Let's Encrypt's staging environment (for testing).
|
|
250
|
+
- `autoCertificates` (boolean): Enables automatic certificate issuance and renewal in production lifecycle. **Default: `true`**. Set to `false` only if certificates are managed externally.
|
|
251
|
+
- `certificateRenewIntervalMs` (number): Renewal check interval when `autoCertificates` is enabled (minimum 60s, default 12h).
|
|
250
252
|
- `local` (boolean): Set to `true` to run in local development mode.
|
|
251
253
|
- `minLocalPort` (number): Minimum port for local mode (default: 4000).
|
|
252
254
|
- `maxLocalPort` (number): Maximum port for local mode (default: 9999).
|
|
@@ -384,6 +386,36 @@ process.on('message', (msg, connection) => {
|
|
|
384
386
|
});
|
|
385
387
|
```
|
|
386
388
|
|
|
389
|
+
### Production Pattern: Single Certificate Manager + Workers
|
|
390
|
+
|
|
391
|
+
For robust ACME behavior with cluster runtimes, run a single certificate manager process (primary) and keep workers in serving-only mode. This avoids challenge race conditions while keeping certificate lifecycle automatic.
|
|
392
|
+
|
|
393
|
+
```javascript
|
|
394
|
+
// primary
|
|
395
|
+
const certManager = new Roster({
|
|
396
|
+
email: 'admin@example.com',
|
|
397
|
+
greenlockStorePath: '/srv/greenlock.d',
|
|
398
|
+
wwwPath: '/srv/www'
|
|
399
|
+
});
|
|
400
|
+
certManager.register('example.com', () => (req, res) => res.end('manager'));
|
|
401
|
+
await certManager.start(); // enables ACME challenge lifecycle
|
|
402
|
+
await certManager.ensureCertificate('example.com');
|
|
403
|
+
|
|
404
|
+
// worker
|
|
405
|
+
const workerRoster = new Roster({
|
|
406
|
+
email: 'admin@example.com',
|
|
407
|
+
greenlockStorePath: '/srv/greenlock.d',
|
|
408
|
+
wwwPath: '/srv/www',
|
|
409
|
+
autoCertificates: false
|
|
410
|
+
});
|
|
411
|
+
workerRoster.register('example.com', () => (req, res) => res.end('worker'));
|
|
412
|
+
await workerRoster.init();
|
|
413
|
+
const server = await workerRoster.createServingHttpsServer({ servername: 'example.com' });
|
|
414
|
+
server.listen(4336);
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Reference implementation: `demo/https-cluster-configurable.js`.
|
|
418
|
+
|
|
387
419
|
### API Reference
|
|
388
420
|
|
|
389
421
|
#### `roster.init()` → `Promise<Roster>`
|
|
@@ -400,7 +432,23 @@ Returns the WebSocket upgrade dispatcher for a given port. Routes upgrades to th
|
|
|
400
432
|
|
|
401
433
|
#### `roster.sniCallback()` → `(servername, callback) => void`
|
|
402
434
|
|
|
403
|
-
Returns a TLS SNI callback
|
|
435
|
+
Returns a TLS SNI callback. It resolves certificates from `greenlockStorePath` and, when `autoCertificates` is enabled (default), can issue missing certificates automatically. Not available in local mode.
|
|
436
|
+
|
|
437
|
+
#### `roster.ensureCertificate(servername)` → `Promise<{ key, cert }>`
|
|
438
|
+
|
|
439
|
+
Ensures a certificate exists for `servername`. With `autoCertificates` enabled (default), it issues missing certificates automatically and returns PEMs.
|
|
440
|
+
|
|
441
|
+
#### `roster.loadCertificate(servername)` → `{ key, cert }`
|
|
442
|
+
|
|
443
|
+
Loads an existing certificate from `greenlockStorePath` without issuing new certificates. Useful for serving-only workers.
|
|
444
|
+
|
|
445
|
+
#### `roster.createManagedHttpsServer({ servername, port?, ensureCertificate?, tlsOptions? })` → `Promise<https.Server>`
|
|
446
|
+
|
|
447
|
+
Creates an HTTPS server prewired with default cert, SNI callback, and request/upgrade routing. By default it ensures certificate issuance before returning.
|
|
448
|
+
|
|
449
|
+
#### `roster.createServingHttpsServer({ servername, port?, tlsOptions? })` → `Promise<https.Server>`
|
|
450
|
+
|
|
451
|
+
Convenience alias for serving-only workers. Equivalent to `createManagedHttpsServer(..., ensureCertificate: false)`.
|
|
404
452
|
|
|
405
453
|
#### `roster.attach(server, { port }?)` → `Roster`
|
|
406
454
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const cluster = require('cluster');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const Roster = require('../index.js');
|
|
5
|
+
|
|
6
|
+
// Change these values to your target domain and HTTPS port.
|
|
7
|
+
const CONFIG = {
|
|
8
|
+
domain: 'example.com',
|
|
9
|
+
httpsPort: 4336,
|
|
10
|
+
workers: Math.max(1, Math.min(2, os.cpus().length)),
|
|
11
|
+
certificateManagerHttpsPort: 0, // 0 = ephemeral, manager does not serve public traffic
|
|
12
|
+
wwwPath: path.join(__dirname, 'www'),
|
|
13
|
+
greenlockStorePath: path.join(__dirname, '..', 'greenlock.d'),
|
|
14
|
+
email: 'mclasen@example.com',
|
|
15
|
+
staging: false
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function createRoster({ isCertificateManager }) {
|
|
19
|
+
const roster = new Roster({
|
|
20
|
+
local: false,
|
|
21
|
+
email: CONFIG.email,
|
|
22
|
+
staging: CONFIG.staging,
|
|
23
|
+
wwwPath: CONFIG.wwwPath,
|
|
24
|
+
greenlockStorePath: CONFIG.greenlockStorePath,
|
|
25
|
+
autoCertificates: isCertificateManager,
|
|
26
|
+
// Certificate manager can bind an ephemeral HTTPS port; workers serve real traffic.
|
|
27
|
+
port: isCertificateManager ? CONFIG.certificateManagerHttpsPort : 443
|
|
28
|
+
});
|
|
29
|
+
return roster;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function startWorker() {
|
|
33
|
+
const roster = createRoster({ isCertificateManager: false });
|
|
34
|
+
|
|
35
|
+
// Domain is configured from CONFIG (single source of truth).
|
|
36
|
+
roster.register(CONFIG.domain, () => {
|
|
37
|
+
return (req, res) => {
|
|
38
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
39
|
+
res.end(`HTTPS cluster response from worker ${process.pid} for ${CONFIG.domain}`);
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await roster.init();
|
|
44
|
+
const server = await roster.createServingHttpsServer({
|
|
45
|
+
servername: CONFIG.domain
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
server.listen(CONFIG.httpsPort, () => {
|
|
49
|
+
console.log(`[worker ${process.pid}] listening on https://0.0.0.0:${CONFIG.httpsPort} for domain ${CONFIG.domain}`);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function startPrimary() {
|
|
54
|
+
console.log(`\n[primary ${process.pid}] starting ${CONFIG.workers} workers`);
|
|
55
|
+
console.log(`domain=${CONFIG.domain} port=${CONFIG.httpsPort}`);
|
|
56
|
+
console.log(`wwwPath=${CONFIG.wwwPath}`);
|
|
57
|
+
console.log(`greenlockStorePath=${CONFIG.greenlockStorePath}\n`);
|
|
58
|
+
console.log('[primary] cert lifecycle managed by roster-server\n');
|
|
59
|
+
|
|
60
|
+
// Primary is the single certificate manager to avoid ACME race conditions.
|
|
61
|
+
// It starts Roster standalone lifecycle so ACME http-01 challenge server (:80) is active.
|
|
62
|
+
const certificateManager = createRoster({ isCertificateManager: true });
|
|
63
|
+
certificateManager.register(CONFIG.domain, () => {
|
|
64
|
+
return (req, res) => {
|
|
65
|
+
res.writeHead(200);
|
|
66
|
+
res.end('certificate-manager');
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
await certificateManager.start();
|
|
70
|
+
await certificateManager.ensureCertificate(CONFIG.domain);
|
|
71
|
+
const subject = CONFIG.domain;
|
|
72
|
+
console.log(`[primary] certificate ready for ${CONFIG.domain} (subject=${subject})\n`);
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < CONFIG.workers; i++) {
|
|
75
|
+
cluster.fork();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
cluster.on('exit', (worker) => {
|
|
79
|
+
console.log(`[primary] worker ${worker.process.pid} exited, restarting...`);
|
|
80
|
+
cluster.fork();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (cluster.isPrimary) {
|
|
85
|
+
startPrimary().catch((err) => {
|
|
86
|
+
console.error(`[primary ${process.pid}] startup failed`, err);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
});
|
|
89
|
+
} else {
|
|
90
|
+
startWorker().catch((err) => {
|
|
91
|
+
console.error(`[worker ${process.pid}] startup failed`, err);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: eyap8usul0
|
|
3
|
+
type: decision
|
|
4
|
+
title: 'Decision update: autoCertificates default true'
|
|
5
|
+
created: '2026-03-20 23:53:16'
|
|
6
|
+
---
|
|
7
|
+
# Decision update: autoCertificates default true
|
|
8
|
+
|
|
9
|
+
**What changed**: `autoCertificates` now defaults to `true` instead of `false`.
|
|
10
|
+
|
|
11
|
+
**Why**: User expectation and product value are that SSL lifecycle should be automatic and robust by default in RosterServer.
|
|
12
|
+
|
|
13
|
+
**Implementation**:
|
|
14
|
+
- Constructor default changed in `index.js` (`parseBooleanFlag(options.autoCertificates, true)`).
|
|
15
|
+
- Added constructor tests for default true and explicit opt-out (`autoCertificates: false`).
|
|
16
|
+
- Updated docs (`README.md`, `skills/roster-server/SKILL.md`) to reflect default-on behavior.
|
|
17
|
+
|
|
18
|
+
**Guardrails**:
|
|
19
|
+
- Users can still opt out with `autoCertificates: false` when certs are externally managed.
|
|
20
|
+
- `ensureCertificate()` keeps explicit error message when autoCertificates is disabled.
|
package/index.js
CHANGED
|
@@ -268,6 +268,12 @@ class Roster {
|
|
|
268
268
|
}
|
|
269
269
|
|
|
270
270
|
this.skipLocalCheck = parseBooleanFlag(options.skipLocalCheck, true);
|
|
271
|
+
this.autoCertificates = parseBooleanFlag(options.autoCertificates, true);
|
|
272
|
+
this.certificateRenewIntervalMs = Number.isFinite(Number(options.certificateRenewIntervalMs))
|
|
273
|
+
? Math.max(60000, Number(options.certificateRenewIntervalMs))
|
|
274
|
+
: 12 * 60 * 60 * 1000;
|
|
275
|
+
this._greenlockRuntime = null;
|
|
276
|
+
this._certificateRenewTimer = null;
|
|
271
277
|
|
|
272
278
|
const port = options.port === undefined ? 443 : options.port;
|
|
273
279
|
if (port === 80 && !this.local) {
|
|
@@ -775,28 +781,193 @@ class Roster {
|
|
|
775
781
|
|
|
776
782
|
_initSniResolver() {
|
|
777
783
|
this._sniCallback = (servername, callback) => {
|
|
784
|
+
const normalizedServername = this._normalizeHostInput(servername).trim().toLowerCase();
|
|
778
785
|
try {
|
|
779
|
-
const pems = this._resolvePemsForServername(
|
|
786
|
+
const pems = this._resolvePemsForServername(normalizedServername);
|
|
780
787
|
if (pems) {
|
|
781
788
|
callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
|
|
782
|
-
|
|
783
|
-
callback(new Error(`No certificate files available for ${servername}`));
|
|
789
|
+
return;
|
|
784
790
|
}
|
|
785
791
|
} catch (error) {
|
|
786
792
|
callback(error);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Cluster-friendly automatic issuance path (no internal listen lifecycle).
|
|
797
|
+
if (!this._greenlockRuntime || !normalizedServername) {
|
|
798
|
+
callback(new Error(`No certificate files available for ${servername}`));
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
this._greenlockRuntime.get({ servername: normalizedServername })
|
|
803
|
+
.then(() => {
|
|
804
|
+
const issued = this._resolvePemsForServername(normalizedServername);
|
|
805
|
+
if (issued) {
|
|
806
|
+
callback(null, tls.createSecureContext({ key: issued.key, cert: issued.cert }));
|
|
807
|
+
} else {
|
|
808
|
+
callback(new Error(`No certificate files available for ${servername}`));
|
|
809
|
+
}
|
|
810
|
+
})
|
|
811
|
+
.catch((error) => {
|
|
812
|
+
callback(error);
|
|
813
|
+
});
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
_buildGreenlockOptions() {
|
|
818
|
+
return {
|
|
819
|
+
packageRoot: __dirname,
|
|
820
|
+
configDir: this.greenlockStorePath,
|
|
821
|
+
maintainerEmail: this.email,
|
|
822
|
+
cluster: this.cluster,
|
|
823
|
+
staging: this.staging,
|
|
824
|
+
skipDryRun: this.skipLocalCheck,
|
|
825
|
+
skipChallengeTest: this.skipLocalCheck,
|
|
826
|
+
notify: (event, details) => {
|
|
827
|
+
const eventDomain = (() => {
|
|
828
|
+
if (!details || typeof details !== 'object') return null;
|
|
829
|
+
|
|
830
|
+
const directKeys = ['subject', 'servername', 'domain', 'hostname', 'host'];
|
|
831
|
+
for (const key of directKeys) {
|
|
832
|
+
if (typeof details[key] === 'string' && details[key].trim()) {
|
|
833
|
+
return details[key].trim().toLowerCase();
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (Array.isArray(details.altnames) && details.altnames.length > 0) {
|
|
838
|
+
const alt = details.altnames.find(name => typeof name === 'string' && name.trim());
|
|
839
|
+
if (alt) return alt.trim().toLowerCase();
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (Array.isArray(details.domains) && details.domains.length > 0) {
|
|
843
|
+
const domain = details.domains.find(name => typeof name === 'string' && name.trim());
|
|
844
|
+
if (domain) return domain.trim().toLowerCase();
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
if (details.identifier && typeof details.identifier.value === 'string' && details.identifier.value.trim()) {
|
|
848
|
+
return details.identifier.value.trim().toLowerCase();
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
return null;
|
|
852
|
+
})();
|
|
853
|
+
|
|
854
|
+
let msg;
|
|
855
|
+
if (typeof details === 'string') {
|
|
856
|
+
msg = details;
|
|
857
|
+
} else if (details instanceof Error) {
|
|
858
|
+
msg = details.stack || details.message;
|
|
859
|
+
} else if (details && typeof details === 'object' && typeof details.message === 'string') {
|
|
860
|
+
msg = details.message;
|
|
861
|
+
} else {
|
|
862
|
+
try {
|
|
863
|
+
msg = JSON.stringify(details);
|
|
864
|
+
} catch {
|
|
865
|
+
msg = String(details);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
if (!msg || msg === 'undefined') msg = `[${event}] (no details)`;
|
|
869
|
+
if (eventDomain && !msg.includes(`[${eventDomain}]`)) {
|
|
870
|
+
msg = `[${eventDomain}] ${msg}`;
|
|
871
|
+
}
|
|
872
|
+
if (event === 'warning' && typeof msg === 'string') {
|
|
873
|
+
if (/acme-dns-01-cli.*(incorrect function signatures|deprecated use of callbacks)/i.test(msg)) return;
|
|
874
|
+
if (/dns-01 challenge plugin should have zones/i.test(msg)) return;
|
|
875
|
+
}
|
|
876
|
+
if (event === 'error') log.error(msg);
|
|
877
|
+
else if (event === 'warning') log.warn(msg);
|
|
878
|
+
else log.info(msg);
|
|
787
879
|
}
|
|
788
880
|
};
|
|
789
881
|
}
|
|
790
882
|
|
|
883
|
+
_getManagedCertificateSubjects() {
|
|
884
|
+
const uniqueDomains = new Set();
|
|
885
|
+
this.domains.forEach((domain) => {
|
|
886
|
+
const root = domain.startsWith('*.') ? wildcardRoot(domain) : domain.replace(/^www\./, '');
|
|
887
|
+
if (root) uniqueDomains.add(root);
|
|
888
|
+
});
|
|
889
|
+
const subjects = [];
|
|
890
|
+
uniqueDomains.forEach((domain) => {
|
|
891
|
+
subjects.push(domain);
|
|
892
|
+
const includeWildcard = this.wildcardZones.has(domain) && this.dnsChallenge && !this.combineWildcardCerts;
|
|
893
|
+
if (includeWildcard) subjects.push(`*.${domain}`);
|
|
894
|
+
});
|
|
895
|
+
return [...new Set(subjects)];
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
_startCertificateRenewLoop() {
|
|
899
|
+
if (!this._greenlockRuntime || this._certificateRenewTimer) return;
|
|
900
|
+
const subjects = this._getManagedCertificateSubjects();
|
|
901
|
+
if (subjects.length === 0) return;
|
|
902
|
+
this._certificateRenewTimer = setInterval(() => {
|
|
903
|
+
subjects.forEach((subject) => {
|
|
904
|
+
this._greenlockRuntime.get({ servername: subject }).catch((error) => {
|
|
905
|
+
log.warn(`⚠️ Certificate renew check failed for ${subject}: ${error?.message || error}`);
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
}, this.certificateRenewIntervalMs);
|
|
909
|
+
if (typeof this._certificateRenewTimer.unref === 'function') {
|
|
910
|
+
this._certificateRenewTimer.unref();
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
async ensureCertificate(servername) {
|
|
915
|
+
if (this.local) {
|
|
916
|
+
throw new Error('ensureCertificate() is not available in local mode');
|
|
917
|
+
}
|
|
918
|
+
if (!this._initialized) {
|
|
919
|
+
throw new Error('Call init() before ensureCertificate()');
|
|
920
|
+
}
|
|
921
|
+
const normalizedServername = this._normalizeHostInput(servername).trim().toLowerCase();
|
|
922
|
+
if (!normalizedServername) {
|
|
923
|
+
throw new Error('servername is required');
|
|
924
|
+
}
|
|
925
|
+
let pems = this._resolvePemsForServername(normalizedServername);
|
|
926
|
+
if (pems) return pems;
|
|
927
|
+
if (!this._greenlockRuntime) {
|
|
928
|
+
throw new Error('autoCertificates is disabled; enable { autoCertificates: true } to issue certificates automatically');
|
|
929
|
+
}
|
|
930
|
+
await this._greenlockRuntime.get({ servername: normalizedServername });
|
|
931
|
+
pems = this._resolvePemsForServername(normalizedServername);
|
|
932
|
+
if (!pems) {
|
|
933
|
+
throw new Error(`Certificate issuance completed but no PEM files were found for ${normalizedServername}`);
|
|
934
|
+
}
|
|
935
|
+
return pems;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
loadCertificate(servername) {
|
|
939
|
+
if (this.local) {
|
|
940
|
+
throw new Error('loadCertificate() is not available in local mode');
|
|
941
|
+
}
|
|
942
|
+
if (!this._initialized) {
|
|
943
|
+
throw new Error('Call init() before loadCertificate()');
|
|
944
|
+
}
|
|
945
|
+
const normalizedServername = this._normalizeHostInput(servername).trim().toLowerCase();
|
|
946
|
+
if (!normalizedServername) {
|
|
947
|
+
throw new Error('servername is required');
|
|
948
|
+
}
|
|
949
|
+
const pems = this._resolvePemsForServername(normalizedServername);
|
|
950
|
+
if (!pems) {
|
|
951
|
+
throw new Error(`No certificate files available for ${normalizedServername}`);
|
|
952
|
+
}
|
|
953
|
+
return pems;
|
|
954
|
+
}
|
|
955
|
+
|
|
791
956
|
async init() {
|
|
792
957
|
if (this._initialized) return this;
|
|
793
958
|
await this.loadSites();
|
|
794
959
|
if (!this.local) {
|
|
795
960
|
this.generateConfigJson();
|
|
961
|
+
if (this.autoCertificates) {
|
|
962
|
+
this._greenlockRuntime = GreenlockShim.create(this._buildGreenlockOptions());
|
|
963
|
+
}
|
|
796
964
|
}
|
|
797
965
|
this._initSiteHandlers();
|
|
798
966
|
if (!this.local) {
|
|
799
967
|
this._initSniResolver();
|
|
968
|
+
if (this.autoCertificates) {
|
|
969
|
+
this._startCertificateRenewLoop();
|
|
970
|
+
}
|
|
800
971
|
}
|
|
801
972
|
this._initialized = true;
|
|
802
973
|
return this;
|
|
@@ -838,6 +1009,46 @@ class Roster {
|
|
|
838
1009
|
return this;
|
|
839
1010
|
}
|
|
840
1011
|
|
|
1012
|
+
async createManagedHttpsServer(options = {}) {
|
|
1013
|
+
if (this.local) throw new Error('createManagedHttpsServer() is not available in local mode');
|
|
1014
|
+
if (!this._initialized) throw new Error('Call init() before createManagedHttpsServer()');
|
|
1015
|
+
|
|
1016
|
+
const {
|
|
1017
|
+
servername,
|
|
1018
|
+
port,
|
|
1019
|
+
ensureCertificate = true,
|
|
1020
|
+
tlsOptions = {}
|
|
1021
|
+
} = options;
|
|
1022
|
+
|
|
1023
|
+
const normalizedServername = this._normalizeHostInput(servername).trim().toLowerCase();
|
|
1024
|
+
if (!normalizedServername) {
|
|
1025
|
+
throw new Error('servername is required');
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const pems = ensureCertificate
|
|
1029
|
+
? await this.ensureCertificate(normalizedServername)
|
|
1030
|
+
: this.loadCertificate(normalizedServername);
|
|
1031
|
+
|
|
1032
|
+
const server = https.createServer({
|
|
1033
|
+
minVersion: this.tlsMinVersion,
|
|
1034
|
+
maxVersion: this.tlsMaxVersion,
|
|
1035
|
+
...tlsOptions,
|
|
1036
|
+
key: pems.key,
|
|
1037
|
+
cert: pems.cert,
|
|
1038
|
+
SNICallback: this.sniCallback()
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
this.attach(server, { port });
|
|
1042
|
+
return server;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
async createServingHttpsServer(options = {}) {
|
|
1046
|
+
return this.createManagedHttpsServer({
|
|
1047
|
+
...options,
|
|
1048
|
+
ensureCertificate: false
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
|
|
841
1052
|
startLocalMode() {
|
|
842
1053
|
this.domainPorts = {};
|
|
843
1054
|
|
|
@@ -891,69 +1102,7 @@ class Roster {
|
|
|
891
1102
|
return this.startLocalMode();
|
|
892
1103
|
}
|
|
893
1104
|
|
|
894
|
-
const greenlockOptions =
|
|
895
|
-
packageRoot: __dirname,
|
|
896
|
-
configDir: this.greenlockStorePath,
|
|
897
|
-
maintainerEmail: this.email,
|
|
898
|
-
cluster: this.cluster,
|
|
899
|
-
staging: this.staging,
|
|
900
|
-
skipDryRun: this.skipLocalCheck,
|
|
901
|
-
skipChallengeTest: this.skipLocalCheck,
|
|
902
|
-
notify: (event, details) => {
|
|
903
|
-
const eventDomain = (() => {
|
|
904
|
-
if (!details || typeof details !== 'object') return null;
|
|
905
|
-
|
|
906
|
-
const directKeys = ['subject', 'servername', 'domain', 'hostname', 'host'];
|
|
907
|
-
for (const key of directKeys) {
|
|
908
|
-
if (typeof details[key] === 'string' && details[key].trim()) {
|
|
909
|
-
return details[key].trim().toLowerCase();
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
if (Array.isArray(details.altnames) && details.altnames.length > 0) {
|
|
914
|
-
const alt = details.altnames.find(name => typeof name === 'string' && name.trim());
|
|
915
|
-
if (alt) return alt.trim().toLowerCase();
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
if (Array.isArray(details.domains) && details.domains.length > 0) {
|
|
919
|
-
const domain = details.domains.find(name => typeof name === 'string' && name.trim());
|
|
920
|
-
if (domain) return domain.trim().toLowerCase();
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
if (details.identifier && typeof details.identifier.value === 'string' && details.identifier.value.trim()) {
|
|
924
|
-
return details.identifier.value.trim().toLowerCase();
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
return null;
|
|
928
|
-
})();
|
|
929
|
-
|
|
930
|
-
let msg;
|
|
931
|
-
if (typeof details === 'string') {
|
|
932
|
-
msg = details;
|
|
933
|
-
} else if (details instanceof Error) {
|
|
934
|
-
msg = details.stack || details.message;
|
|
935
|
-
} else if (details && typeof details === 'object' && typeof details.message === 'string') {
|
|
936
|
-
msg = details.message;
|
|
937
|
-
} else {
|
|
938
|
-
try {
|
|
939
|
-
msg = JSON.stringify(details);
|
|
940
|
-
} catch {
|
|
941
|
-
msg = String(details);
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
if (!msg || msg === 'undefined') msg = `[${event}] (no details)`;
|
|
945
|
-
if (eventDomain && !msg.includes(`[${eventDomain}]`)) {
|
|
946
|
-
msg = `[${eventDomain}] ${msg}`;
|
|
947
|
-
}
|
|
948
|
-
if (event === 'warning' && typeof msg === 'string') {
|
|
949
|
-
if (/acme-dns-01-cli.*(incorrect function signatures|deprecated use of callbacks)/i.test(msg)) return;
|
|
950
|
-
if (/dns-01 challenge plugin should have zones/i.test(msg)) return;
|
|
951
|
-
}
|
|
952
|
-
if (event === 'error') log.error(msg);
|
|
953
|
-
else if (event === 'warning') log.warn(msg);
|
|
954
|
-
else log.info(msg);
|
|
955
|
-
}
|
|
956
|
-
};
|
|
1105
|
+
const greenlockOptions = this._buildGreenlockOptions();
|
|
957
1106
|
const greenlockRuntime = GreenlockShim.create(greenlockOptions);
|
|
958
1107
|
const greenlock = Greenlock.init({
|
|
959
1108
|
...greenlockOptions,
|
package/package.json
CHANGED
|
@@ -147,6 +147,31 @@ process.on('message', (msg, connection) => {
|
|
|
147
147
|
});
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
+
### Pattern 7: Cluster Production (single cert manager + workers)
|
|
151
|
+
```javascript
|
|
152
|
+
// PRIMARY: certificate manager (single process)
|
|
153
|
+
const manager = new Roster({
|
|
154
|
+
email: 'admin@example.com',
|
|
155
|
+
greenlockStorePath: '/srv/greenlock.d',
|
|
156
|
+
wwwPath: '/srv/www'
|
|
157
|
+
});
|
|
158
|
+
manager.register('example.com', () => (req, res) => res.end('manager'));
|
|
159
|
+
await manager.start();
|
|
160
|
+
await manager.ensureCertificate('example.com');
|
|
161
|
+
|
|
162
|
+
// WORKER: serving-only process
|
|
163
|
+
const worker = new Roster({
|
|
164
|
+
email: 'admin@example.com',
|
|
165
|
+
greenlockStorePath: '/srv/greenlock.d',
|
|
166
|
+
wwwPath: '/srv/www',
|
|
167
|
+
autoCertificates: false
|
|
168
|
+
});
|
|
169
|
+
worker.register('example.com', () => (req, res) => res.end('worker'));
|
|
170
|
+
await worker.init();
|
|
171
|
+
const httpsServer = await worker.createServingHttpsServer({ servername: 'example.com' });
|
|
172
|
+
httpsServer.listen(4336);
|
|
173
|
+
```
|
|
174
|
+
|
|
150
175
|
## Key Configuration Options
|
|
151
176
|
|
|
152
177
|
```javascript
|
|
@@ -189,7 +214,19 @@ Returns `(req, res) => void` dispatcher for a port (defaults to `defaultPort`).
|
|
|
189
214
|
Returns `(req, socket, head) => void` for WebSocket upgrade routing. Requires `init()` first.
|
|
190
215
|
|
|
191
216
|
### `roster.sniCallback()`
|
|
192
|
-
Returns `(servername, callback) => void` TLS SNI callback that resolves certs from `greenlockStorePath`. Production mode only. Requires `init()` first.
|
|
217
|
+
Returns `(servername, callback) => void` TLS SNI callback that resolves certs from `greenlockStorePath`. With `autoCertificates` enabled (default), it can issue missing certs automatically. Production mode only. Requires `init()` first.
|
|
218
|
+
|
|
219
|
+
### `roster.ensureCertificate(servername)`
|
|
220
|
+
Forces certificate availability for a domain and returns `{ key, cert }`. With `autoCertificates` enabled (default), it issues certs automatically when missing.
|
|
221
|
+
|
|
222
|
+
### `roster.loadCertificate(servername)`
|
|
223
|
+
Loads existing `{ key, cert }` from `greenlockStorePath` without issuing new certificates.
|
|
224
|
+
|
|
225
|
+
### `roster.createManagedHttpsServer(options)`
|
|
226
|
+
Creates a pre-wired `https.Server` with default cert, SNI callback, and attached request/upgrade handlers.
|
|
227
|
+
|
|
228
|
+
### `roster.createServingHttpsServer(options)`
|
|
229
|
+
Serving-only helper for worker processes. Same as `createManagedHttpsServer(..., ensureCertificate: false)`.
|
|
193
230
|
|
|
194
231
|
### `roster.attach(server, { port }?)`
|
|
195
232
|
Convenience: wires `requestHandler` + `upgradeHandler` onto an external `http.Server` or `https.Server`. Returns `this`. Requires `init()` first.
|
|
@@ -318,6 +318,14 @@ describe('Roster', () => {
|
|
|
318
318
|
const roster = new Roster({ local: false, combineWildcardCerts: true });
|
|
319
319
|
assert.strictEqual(roster.combineWildcardCerts, true);
|
|
320
320
|
});
|
|
321
|
+
it('defaults autoCertificates to true', () => {
|
|
322
|
+
const roster = new Roster({ local: false });
|
|
323
|
+
assert.strictEqual(roster.autoCertificates, true);
|
|
324
|
+
});
|
|
325
|
+
it('allows disabling autoCertificates explicitly', () => {
|
|
326
|
+
const roster = new Roster({ local: false, autoCertificates: false });
|
|
327
|
+
assert.strictEqual(roster.autoCertificates, false);
|
|
328
|
+
});
|
|
321
329
|
});
|
|
322
330
|
|
|
323
331
|
describe('register (normal domain)', () => {
|
|
@@ -901,6 +909,84 @@ describe('Roster sniCallback()', () => {
|
|
|
901
909
|
});
|
|
902
910
|
});
|
|
903
911
|
|
|
912
|
+
describe('Roster ensureCertificate()', () => {
|
|
913
|
+
it('throws if called before init()', async () => {
|
|
914
|
+
const roster = new Roster({ local: false, autoCertificates: true });
|
|
915
|
+
await assert.rejects(() => roster.ensureCertificate('example.com'), /Call init\(\) before ensureCertificate/);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it('throws in local mode', async () => {
|
|
919
|
+
const roster = new Roster({ local: true, autoCertificates: true });
|
|
920
|
+
roster.register('local-cert.example', () => () => {});
|
|
921
|
+
await roster.init();
|
|
922
|
+
await assert.rejects(() => roster.ensureCertificate('local-cert.example'), /not available in local mode/);
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
it('throws when autoCertificates is disabled and cert is missing', async () => {
|
|
926
|
+
const roster = new Roster({ local: false, autoCertificates: false });
|
|
927
|
+
roster.register('missing-cert.example', () => () => {});
|
|
928
|
+
await roster.init();
|
|
929
|
+
await assert.rejects(
|
|
930
|
+
() => roster.ensureCertificate('missing-cert.example'),
|
|
931
|
+
/autoCertificates is disabled/
|
|
932
|
+
);
|
|
933
|
+
});
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
describe('Roster loadCertificate()', () => {
|
|
937
|
+
it('throws if called before init()', () => {
|
|
938
|
+
const roster = new Roster({ local: false });
|
|
939
|
+
assert.throws(() => roster.loadCertificate('example.com'), /Call init\(\) before loadCertificate/);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it('throws in local mode', async () => {
|
|
943
|
+
const roster = new Roster({ local: true });
|
|
944
|
+
roster.register('local-load.example', () => () => {});
|
|
945
|
+
await roster.init();
|
|
946
|
+
assert.throws(() => roster.loadCertificate('local-load.example'), /not available in local mode/);
|
|
947
|
+
});
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
describe('Roster createManagedHttpsServer()', () => {
|
|
951
|
+
it('throws if called before init()', async () => {
|
|
952
|
+
const roster = new Roster({ local: false });
|
|
953
|
+
await assert.rejects(
|
|
954
|
+
() => roster.createManagedHttpsServer({ servername: 'example.com' }),
|
|
955
|
+
/Call init\(\) before createManagedHttpsServer/
|
|
956
|
+
);
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
it('throws in local mode', async () => {
|
|
960
|
+
const roster = new Roster({ local: true });
|
|
961
|
+
roster.register('local-managed.example', () => () => {});
|
|
962
|
+
await roster.init();
|
|
963
|
+
await assert.rejects(
|
|
964
|
+
() => roster.createManagedHttpsServer({ servername: 'local-managed.example' }),
|
|
965
|
+
/not available in local mode/
|
|
966
|
+
);
|
|
967
|
+
});
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
describe('Roster createServingHttpsServer()', () => {
|
|
971
|
+
it('throws if called before init()', async () => {
|
|
972
|
+
const roster = new Roster({ local: false });
|
|
973
|
+
await assert.rejects(
|
|
974
|
+
() => roster.createServingHttpsServer({ servername: 'example.com' }),
|
|
975
|
+
/Call init\(\) before createManagedHttpsServer/
|
|
976
|
+
);
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
it('throws in local mode', async () => {
|
|
980
|
+
const roster = new Roster({ local: true });
|
|
981
|
+
roster.register('local-serving.example', () => () => {});
|
|
982
|
+
await roster.init();
|
|
983
|
+
await assert.rejects(
|
|
984
|
+
() => roster.createServingHttpsServer({ servername: 'local-serving.example' }),
|
|
985
|
+
/not available in local mode/
|
|
986
|
+
);
|
|
987
|
+
});
|
|
988
|
+
});
|
|
989
|
+
|
|
904
990
|
describe('Roster attach()', () => {
|
|
905
991
|
it('throws if called before init()', () => {
|
|
906
992
|
const roster = new Roster({ local: true });
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
const https = require('https');
|
|
2
|
-
const fs = require('fs');
|
|
3
|
-
const path = require('path');
|
|
4
|
-
const Roster = require('../index.js');
|
|
5
|
-
|
|
6
|
-
function hasCertificateFor(storePath, subject) {
|
|
7
|
-
const base = path.join(storePath, 'live', subject);
|
|
8
|
-
return (
|
|
9
|
-
fs.existsSync(path.join(base, 'privkey.pem')) &&
|
|
10
|
-
fs.existsSync(path.join(base, 'cert.pem')) &&
|
|
11
|
-
fs.existsSync(path.join(base, 'chain.pem'))
|
|
12
|
-
);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
async function main() {
|
|
16
|
-
// In this demo, certs are expected to already exist in greenlock.d/live/<subject>.
|
|
17
|
-
// init()+attach() do routing only; no ACME issuance/listen lifecycle is started.
|
|
18
|
-
const greenlockStorePath = process.env.GREENLOCK_STORE_PATH || path.join(__dirname, '..', 'greenlock.d');
|
|
19
|
-
const listenPort = Number(process.env.DEMO_HTTPS_PORT || 19643);
|
|
20
|
-
|
|
21
|
-
const roster = new Roster({
|
|
22
|
-
local: false,
|
|
23
|
-
email: process.env.ADMIN_EMAIL || 'admin@example.com',
|
|
24
|
-
greenlockStorePath
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
roster.register('example.com', () => {
|
|
28
|
-
return (req, res) => {
|
|
29
|
-
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
30
|
-
res.end('hello from external HTTPS worker (default port routes)');
|
|
31
|
-
};
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
roster.register('api.example.com:9443', () => {
|
|
35
|
-
return (req, res) => {
|
|
36
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
37
|
-
res.end(JSON.stringify({ ok: true, source: 'api.example.com:9443 route table' }));
|
|
38
|
-
};
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
await roster.init();
|
|
42
|
-
|
|
43
|
-
const missing = ['example.com', '*.example.com'].filter((subject) => !hasCertificateFor(greenlockStorePath, subject));
|
|
44
|
-
if (missing.length > 0) {
|
|
45
|
-
console.log('\n⚠️ Missing certificate files for:');
|
|
46
|
-
missing.forEach((subject) => console.log(` - ${subject}`));
|
|
47
|
-
console.log('\nExpected files:');
|
|
48
|
-
console.log(' greenlock.d/live/<subject>/privkey.pem');
|
|
49
|
-
console.log(' greenlock.d/live/<subject>/cert.pem');
|
|
50
|
-
console.log(' greenlock.d/live/<subject>/chain.pem');
|
|
51
|
-
console.log('\nTip: run regular roster.start() once (or your cert manager) to issue certs first.\n');
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const server = https.createServer({
|
|
55
|
-
SNICallback: roster.sniCallback(),
|
|
56
|
-
minVersion: 'TLSv1.2',
|
|
57
|
-
maxVersion: 'TLSv1.3'
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
// Attach default routing table (defaultPort = 443 routes)
|
|
61
|
-
roster.attach(server);
|
|
62
|
-
|
|
63
|
-
await new Promise((resolve, reject) => {
|
|
64
|
-
server.listen(listenPort, '0.0.0.0', resolve);
|
|
65
|
-
server.on('error', reject);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
console.log('\n✅ HTTPS cluster-friendly demo running');
|
|
69
|
-
console.log(`Listening on :${listenPort} with external server ownership`);
|
|
70
|
-
console.log('Roster only provides SNI + vhost routing (no internal listen/bootstrap).\n');
|
|
71
|
-
|
|
72
|
-
console.log('Try:');
|
|
73
|
-
console.log(`curl -k --resolve example.com:${listenPort}:127.0.0.1 https://example.com:${listenPort}/`);
|
|
74
|
-
console.log(`curl -k --resolve www.example.com:${listenPort}:127.0.0.1 https://www.example.com:${listenPort}/foo`);
|
|
75
|
-
console.log('\nFor custom route table port=9443, attach another server:');
|
|
76
|
-
console.log(' const srv9443 = https.createServer({ SNICallback: roster.sniCallback() });');
|
|
77
|
-
console.log(' roster.attach(srv9443, { port: 9443 });');
|
|
78
|
-
console.log(' srv9443.listen(19644);');
|
|
79
|
-
console.log(` curl -k --resolve api.example.com:19644:127.0.0.1 https://api.example.com:19644/`);
|
|
80
|
-
|
|
81
|
-
// Sticky-session runtime pattern:
|
|
82
|
-
// process.on('message', (msg, socket) => {
|
|
83
|
-
// if (msg === 'sticky-session:connection') server.emit('connection', socket);
|
|
84
|
-
// });
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
main().catch((err) => {
|
|
88
|
-
console.error('Demo failed:', err);
|
|
89
|
-
process.exit(1);
|
|
90
|
-
});
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
const http = require('http');
|
|
2
|
-
const Roster = require('../index.js');
|
|
3
|
-
|
|
4
|
-
async function main() {
|
|
5
|
-
const roster = new Roster({
|
|
6
|
-
local: true,
|
|
7
|
-
minLocalPort: 19500,
|
|
8
|
-
maxLocalPort: 19550
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
roster.register('example.com', () => {
|
|
12
|
-
return (req, res) => {
|
|
13
|
-
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
14
|
-
res.end('[example.com] hello from external worker wiring');
|
|
15
|
-
};
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
roster.register('api.example.com:9000', () => {
|
|
19
|
-
return (req, res) => {
|
|
20
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
21
|
-
res.end(JSON.stringify({ ok: true, source: 'api.example.com:9000' }));
|
|
22
|
-
};
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
// init() prepares virtual-host routing, but does not create/listen sockets.
|
|
26
|
-
await roster.init();
|
|
27
|
-
|
|
28
|
-
// This server represents your external runtime-owned worker server.
|
|
29
|
-
const server443 = http.createServer();
|
|
30
|
-
roster.attach(server443); // defaultPort routes (443)
|
|
31
|
-
|
|
32
|
-
const server9000 = http.createServer();
|
|
33
|
-
roster.attach(server9000, { port: 9000 }); // custom port routes
|
|
34
|
-
|
|
35
|
-
await new Promise((resolve, reject) => {
|
|
36
|
-
server443.listen(19501, 'localhost', resolve);
|
|
37
|
-
server443.on('error', reject);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
await new Promise((resolve, reject) => {
|
|
41
|
-
server9000.listen(19502, 'localhost', resolve);
|
|
42
|
-
server9000.on('error', reject);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
console.log('\n✅ Cluster-friendly worker demo running');
|
|
46
|
-
console.log('Roster did not bind ports itself. External servers own listen().\n');
|
|
47
|
-
console.log('Try requests with Host headers:');
|
|
48
|
-
console.log('curl -H "Host: example.com" http://localhost:19501/');
|
|
49
|
-
console.log('curl -H "Host: api.example.com" http://localhost:19502/\n');
|
|
50
|
-
|
|
51
|
-
// Sticky-session runtimes normally pass accepted sockets into the worker:
|
|
52
|
-
// process.on('message', (msg, socket) => {
|
|
53
|
-
// if (msg === 'sticky-session:connection') server443.emit('connection', socket);
|
|
54
|
-
// });
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
main().catch((err) => {
|
|
58
|
-
console.error('Demo failed:', err);
|
|
59
|
-
process.exit(1);
|
|
60
|
-
});
|