roster-server 2.2.0 → 2.2.2

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 CHANGED
@@ -62,7 +62,18 @@ You can serve all subdomains of a domain with a single handler in three ways:
62
62
  2. **Register (default port)**: `roster.register('*.example.com', handler)` for the default HTTPS port.
63
63
  3. **Register (custom port)**: `roster.register('*.example.com:8080', handler)` for a specific port.
64
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:
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).
66
+
67
+ For fully automatic TXT records with Linode/Akamai DNS Manager, set:
68
+
69
+ ```bash
70
+ export ROSTER_DNS_PROVIDER=linode
71
+ export LINODE_API_KEY=... # or LINODE_TOKEN / AKAMAI_API_KEY
72
+ ```
73
+
74
+ Then Roster creates/removes `_acme-challenge` TXT records automatically via `api.linode.com`.
75
+
76
+ Override with a custom plugin:
66
77
 
67
78
  ```javascript
68
79
  import Roster from 'roster-server';
@@ -210,7 +221,7 @@ When creating a new `RosterServer` instance, you can pass the following options:
210
221
  - `email` (string): Your email for Let's Encrypt notifications.
211
222
  - `wwwPath` (string): Path to your `www` directory containing your sites.
212
223
  - `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.
224
+ - `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 DNS API mode for Linode/Akamai by setting `ROSTER_DNS_PROVIDER=linode` (or `akamai`) and `LINODE_API_KEY` (aliases: `LINODE_TOKEN`, `AKAMAI_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.
214
225
  - `staging` (boolean): Set to `true` to use Let's Encrypt's staging environment (for testing).
215
226
  - `local` (boolean): Set to `true` to run in local development mode.
216
227
  - `minLocalPort` (number): Minimum port for local mode (default: 4000).
package/index.js CHANGED
@@ -3,6 +3,7 @@ const path = require('path');
3
3
  const http = require('http');
4
4
  const https = require('https');
5
5
  const tls = require('tls');
6
+ const crypto = require('crypto');
6
7
  const { EventEmitter } = require('events');
7
8
  const Greenlock = require('./vendor/greenlock-express/greenlock-express.js');
8
9
  const GreenlockShim = require('./vendor/greenlock-express/greenlock-shim.js');
@@ -89,6 +90,16 @@ function buildCertLookupCandidates(servername) {
89
90
  return candidates;
90
91
  }
91
92
 
93
+ function certCoversName(certPem, name) {
94
+ try {
95
+ const x509 = new crypto.X509Certificate(certPem);
96
+ const san = (x509.subjectAltName || '').toLowerCase();
97
+ return san.split(',').some(entry => entry.trim() === `dns:${name.toLowerCase()}`);
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
92
103
  function parseBooleanFlag(value, fallback = false) {
93
104
  if (value === undefined || value === null || value === '') return fallback;
94
105
  const normalized = String(value).trim().toLowerCase();
@@ -909,10 +920,22 @@ class Roster {
909
920
  };
910
921
  const ensureBunDefaultPems = async (primaryDomain) => {
911
922
  let pems = await issueAndReloadPemsForServername(primaryDomain);
923
+
924
+ const needsWildcard = this.combineWildcardCerts
925
+ && this.wildcardZones.has(primaryDomain)
926
+ && this.dnsChallenge;
927
+
928
+ if (pems && needsWildcard && !certCoversName(pems.cert, `*.${primaryDomain}`)) {
929
+ log.warn(`⚠️ Existing cert for ${primaryDomain} lacks *.${primaryDomain} SAN — clearing stale cert for combined re-issuance`);
930
+ const certDir = path.join(greenlockStorePath, 'live', primaryDomain);
931
+ try { fs.rmSync(certDir, { recursive: true, force: true }); } catch {}
932
+ pems = null;
933
+ }
934
+
912
935
  if (pems) return pems;
913
936
 
914
937
  const certSubject = primaryDomain.startsWith('*.') ? wildcardRoot(primaryDomain) : primaryDomain;
915
- log.warn(`⚠️ Bun runtime detected and cert files missing for ${primaryDomain}; requesting certificate via Greenlock before HTTPS bind`);
938
+ log.warn(`⚠️ Bun: requesting ${needsWildcard ? 'combined wildcard' : ''} certificate for ${certSubject} via Greenlock before HTTPS bind`);
916
939
  try {
917
940
  await greenlockRuntime.get({ servername: certSubject });
918
941
  } catch (error) {
@@ -1074,4 +1097,5 @@ module.exports.isBunRuntime = isBunRuntime;
1074
1097
  module.exports.wildcardRoot = wildcardRoot;
1075
1098
  module.exports.hostMatchesWildcard = hostMatchesWildcard;
1076
1099
  module.exports.wildcardSubjectForHost = wildcardSubjectForHost;
1077
- module.exports.buildCertLookupCandidates = buildCertLookupCandidates;
1100
+ module.exports.buildCertLookupCandidates = buildCertLookupCandidates;
1101
+ module.exports.certCoversName = certCoversName;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roster-server",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "description": "👾 RosterServer - A domain host router to host multiple HTTPS.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,3 @@
1
+ # Lessons Learned
2
+
3
+ - When wildcard TLS must run under Bun, do not rely on manual DNS instructions; default to API-driven DNS-01 TXT creation/removal (Linode/Akamai) with propagation polling, then fall back to manual mode only when no provider token is configured.
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ const { describe, it, afterEach } = require('node:test');
4
+ const assert = require('node:assert');
5
+ const wrapper = require('../vendor/acme-dns-01-cli-wrapper.js');
6
+
7
+ function buildChallengeOpts() {
8
+ return {
9
+ challenge: {
10
+ altname: '*.example.com',
11
+ dnsHost: '_acme-challenge.example.com',
12
+ dnsAuthorization: 'test-token'
13
+ }
14
+ };
15
+ }
16
+
17
+ describe('acme-dns-01-cli-wrapper automatic Linode DNS', () => {
18
+ const originalFetch = global.fetch;
19
+ const originalAkamaiApiKey = process.env.AKAMAI_API_KEY;
20
+
21
+ afterEach(() => {
22
+ global.fetch = originalFetch;
23
+ if (originalAkamaiApiKey === undefined) delete process.env.AKAMAI_API_KEY;
24
+ else process.env.AKAMAI_API_KEY = originalAkamaiApiKey;
25
+ });
26
+
27
+ it('creates and removes TXT records via Linode API', async () => {
28
+ const calls = [];
29
+ global.fetch = async (url, options = {}) => {
30
+ calls.push({ url, options });
31
+ if (url.endsWith('/domains/example.com')) {
32
+ return { ok: true, status: 200, json: async () => ({}) };
33
+ }
34
+ if (url.endsWith('/domains/example.com/records?page_size=500')) {
35
+ return { ok: true, status: 200, json: async () => ({ data: [] }) };
36
+ }
37
+ if (url.endsWith('/domains/example.com/records') && options.method === 'POST') {
38
+ return { ok: true, status: 200, json: async () => ({ id: 321 }) };
39
+ }
40
+ if (url.endsWith('/domains/example.com/records/321') && options.method === 'DELETE') {
41
+ return { ok: true, status: 204, json: async () => ({}) };
42
+ }
43
+ return { ok: false, status: 404, statusText: 'not mocked', text: async () => 'not mocked' };
44
+ };
45
+
46
+ const challenger = wrapper.create({
47
+ provider: 'linode',
48
+ linodeApiKey: 'fake-token',
49
+ verifyDnsBeforeContinue: false,
50
+ propagationDelay: 0,
51
+ dryRunDelay: 0
52
+ });
53
+
54
+ const opts = buildChallengeOpts();
55
+ await challenger.set(opts);
56
+ await challenger.remove(opts);
57
+
58
+ assert.ok(calls.some((c) => c.url.endsWith('/domains/example.com/records') && c.options.method === 'POST'));
59
+ assert.ok(calls.some((c) => c.url.endsWith('/domains/example.com/records/321') && c.options.method === 'DELETE'));
60
+ });
61
+
62
+ it('accepts AKAMAI_API_KEY as Linode token alias', async () => {
63
+ process.env.AKAMAI_API_KEY = 'fake-ak-key';
64
+ const calls = [];
65
+ global.fetch = async (url, options = {}) => {
66
+ calls.push({ url, options });
67
+ if (url.endsWith('/domains/example.com')) {
68
+ return { ok: true, status: 200, json: async () => ({}) };
69
+ }
70
+ if (url.endsWith('/domains/example.com/records?page_size=500')) {
71
+ return { ok: true, status: 200, json: async () => ({ data: [{ id: 111, type: 'TXT', name: '_acme-challenge', target: 'test-token' }] }) };
72
+ }
73
+ return { ok: true, status: 204, json: async () => ({}) };
74
+ };
75
+
76
+ const challenger = wrapper.create({
77
+ provider: 'akamai',
78
+ verifyDnsBeforeContinue: false,
79
+ propagationDelay: 0,
80
+ dryRunDelay: 0
81
+ });
82
+
83
+ await challenger.set(buildChallengeOpts());
84
+ assert.ok(calls.length >= 2);
85
+ const authHeader = calls[0].options?.headers?.Authorization || '';
86
+ assert.ok(authHeader.startsWith('Bearer '));
87
+ });
88
+ });
@@ -106,6 +106,20 @@ module.exports.create = function create(config = {}) {
106
106
  resolver.setServers([server]);
107
107
  return { server, resolver };
108
108
  });
109
+ const normalizeProvider = (value) => String(value || '').trim().toLowerCase();
110
+ const configuredProvider = normalizeProvider(
111
+ config.provider
112
+ || process.env.ROSTER_DNS_PROVIDER
113
+ || (config.linodeApiKey || process.env.LINODE_API_KEY || process.env.LINODE_TOKEN || process.env.AKAMAI_API_KEY ? 'linode' : '')
114
+ );
115
+ const isLinodeProvider = ['linode', 'akamai', 'linode-akamai', 'akamai-linode'].includes(configuredProvider);
116
+ const linodeApiKey = config.linodeApiKey
117
+ || process.env.LINODE_API_KEY
118
+ || process.env.LINODE_TOKEN
119
+ || process.env.AKAMAI_API_KEY
120
+ || '';
121
+ const linodeApiBase = String(config.linodeApiBase || process.env.LINODE_API_BASE_URL || 'https://api.linode.com/v4').replace(/\/+$/, '');
122
+ const txtRecordTtl = Number.isFinite(config.txtRecordTtl) ? Math.max(30, Number(config.txtRecordTtl)) : 60;
109
123
 
110
124
  function sleep(ms) {
111
125
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -169,6 +183,119 @@ module.exports.create = function create(config = {}) {
169
183
 
170
184
  const presentedByHost = new Map();
171
185
  const presentedByAltname = new Map();
186
+ const linodeTxtRecordsByHost = new Map();
187
+
188
+ function buildZoneCandidates({ dnsHost, altname }) {
189
+ const candidates = new Set();
190
+ const add = (value) => {
191
+ const normalized = String(value || '').replace(/\.$/, '').toLowerCase();
192
+ if (!normalized) return;
193
+ const labels = normalized.split('.').filter(Boolean);
194
+ for (let i = 0; i <= labels.length - 2; i += 1) {
195
+ candidates.add(labels.slice(i).join('.'));
196
+ }
197
+ };
198
+
199
+ if (dnsHost) {
200
+ const normalizedDnsHost = String(dnsHost).replace(/^_acme-challenge\./, '').replace(/^_greenlock-[^.]+\./, '');
201
+ add(normalizedDnsHost);
202
+ }
203
+ if (altname) {
204
+ add(String(altname).replace(/^\*\./, ''));
205
+ }
206
+ return Array.from(candidates);
207
+ }
208
+
209
+ function linodeRecordNameForHost(dnsHost, zone) {
210
+ const host = String(dnsHost || '').replace(/\.$/, '').toLowerCase();
211
+ const normalizedZone = String(zone || '').replace(/\.$/, '').toLowerCase();
212
+ if (!host || !normalizedZone) return '';
213
+ if (host === normalizedZone) return '';
214
+ if (!host.endsWith(`.${normalizedZone}`)) return '';
215
+ return host.slice(0, host.length - normalizedZone.length - 1);
216
+ }
217
+
218
+ async function linodeRequest(pathname, method = 'GET', body) {
219
+ const apiKey = String(linodeApiKey || '').trim();
220
+ if (!apiKey) {
221
+ throw new Error('Linode API key not configured. Set LINODE_API_KEY (or LINODE_TOKEN/AKAMAI_API_KEY).');
222
+ }
223
+ if (typeof fetch !== 'function') {
224
+ throw new Error('Global fetch is unavailable in this runtime; cannot call Linode DNS API.');
225
+ }
226
+ const response = await fetch(`${linodeApiBase}${pathname}`, {
227
+ method,
228
+ headers: {
229
+ Authorization: `Bearer ${apiKey}`,
230
+ 'Content-Type': 'application/json'
231
+ },
232
+ ...(body ? { body: JSON.stringify(body) } : {})
233
+ });
234
+
235
+ if (!response.ok) {
236
+ let details = '';
237
+ try {
238
+ details = await response.text();
239
+ } catch {
240
+ details = '';
241
+ }
242
+ throw new Error(`Linode API ${method} ${pathname} failed (${response.status}): ${details || response.statusText}`);
243
+ }
244
+ if (response.status === 204) return null;
245
+ return response.json();
246
+ }
247
+
248
+ async function linodeUpsertTxtRecord(dnsHost, dnsAuthorization, altname) {
249
+ const zoneCandidates = buildZoneCandidates({ dnsHost, altname });
250
+ let lastError = null;
251
+
252
+ for (const zone of zoneCandidates) {
253
+ try {
254
+ await linodeRequest(`/domains/${encodeURIComponent(zone)}`, 'GET');
255
+ } catch (error) {
256
+ lastError = error;
257
+ continue;
258
+ }
259
+
260
+ const recordName = linodeRecordNameForHost(dnsHost, zone);
261
+ if (!recordName && dnsHost !== zone) continue;
262
+
263
+ const recordsResult = await linodeRequest(`/domains/${encodeURIComponent(zone)}/records?page_size=500`, 'GET');
264
+ const existing = Array.isArray(recordsResult?.data) ? recordsResult.data : [];
265
+ const sameRecord = existing.find((record) =>
266
+ record?.type === 'TXT'
267
+ && String(record?.name || '') === String(recordName || '')
268
+ && String(record?.target || '') === String(dnsAuthorization || '')
269
+ );
270
+
271
+ if (sameRecord && sameRecord.id) {
272
+ linodeTxtRecordsByHost.set(dnsHost, { zone, id: sameRecord.id });
273
+ return { zone, id: sameRecord.id, reused: true };
274
+ }
275
+
276
+ const created = await linodeRequest(`/domains/${encodeURIComponent(zone)}/records`, 'POST', {
277
+ type: 'TXT',
278
+ name: recordName,
279
+ target: dnsAuthorization,
280
+ ttl_sec: txtRecordTtl
281
+ });
282
+
283
+ if (created?.id) {
284
+ linodeTxtRecordsByHost.set(dnsHost, { zone, id: created.id });
285
+ return { zone, id: created.id, reused: false };
286
+ }
287
+ }
288
+
289
+ if (lastError) throw lastError;
290
+ throw new Error(`Unable to map ${dnsHost} to a Linode DNS zone`);
291
+ }
292
+
293
+ async function linodeRemoveTxtRecord(dnsHost) {
294
+ const stored = linodeTxtRecordsByHost.get(dnsHost);
295
+ if (!stored?.zone || !stored?.id) return;
296
+ await linodeRequest(`/domains/${encodeURIComponent(stored.zone)}/records/${stored.id}`, 'DELETE');
297
+ linodeTxtRecordsByHost.delete(dnsHost);
298
+ }
172
299
 
173
300
  async function setChallenge(opts) {
174
301
  const ch = opts?.challenge || {};
@@ -184,6 +311,18 @@ module.exports.create = function create(config = {}) {
184
311
  if (altname && dnsAuth) {
185
312
  presentedByAltname.set(altname, { dnsHost, dnsAuthorization: dnsAuth });
186
313
  }
314
+ if (isLinodeProvider && dnsHost && dnsAuth) {
315
+ try {
316
+ const result = await linodeUpsertTxtRecord(dnsHost, dnsAuth, altname);
317
+ log.info(
318
+ `Linode DNS TXT ${result?.reused ? 'reused' : 'created'} for ${dnsHost}` +
319
+ (result?.zone ? ` (zone ${result.zone})` : '')
320
+ );
321
+ } catch (error) {
322
+ log.error(`Failed to create Linode DNS TXT for ${dnsHost}: ${error?.message || error}`);
323
+ throw error;
324
+ }
325
+ }
187
326
  const isDryRunChallenge = dnsHost.includes('_greenlock-dryrun-');
188
327
  const effectiveDelay = isDryRunChallenge
189
328
  ? Math.max(0, dryRunDelay)
@@ -218,8 +357,10 @@ module.exports.create = function create(config = {}) {
218
357
  }
219
358
 
220
359
  log.info(
221
- 'Non-interactive mode (or autoContinue) detected. ' +
222
- 'Set the TXT record now. Continuing automatically in ' +
360
+ (isLinodeProvider
361
+ ? 'Automatic DNS provider mode detected.'
362
+ : 'Non-interactive mode (or autoContinue) detected. Set the TXT record now.') +
363
+ ' Continuing automatically in ' +
223
364
  effectiveDelay +
224
365
  'ms...'
225
366
  );
@@ -254,10 +395,32 @@ module.exports.create = function create(config = {}) {
254
395
  };
255
396
  }
256
397
 
398
+ async function removeChallenge(opts) {
399
+ const ch = opts?.challenge || {};
400
+ const altname = String(ch.altname || opts?.altname || '');
401
+ const wildcardZone = altname.startsWith('*.') ? altname.slice(2) : '';
402
+ const dnsHostFromAltname = wildcardZone ? `_acme-challenge.${wildcardZone}` : '';
403
+ const dnsHost = String(ch.dnsHost || opts?.dnsHost || dnsHostFromAltname || '');
404
+
405
+ if (isLinodeProvider && dnsHost) {
406
+ try {
407
+ await linodeRemoveTxtRecord(dnsHost);
408
+ log.info(`Linode DNS TXT removed for ${dnsHost}`);
409
+ return null;
410
+ } catch (error) {
411
+ log.warn(`Failed to remove Linode DNS TXT for ${dnsHost}: ${error?.message || error}`);
412
+ return null;
413
+ }
414
+ }
415
+
416
+ const legacyRemove = toPromise(challenger.remove, challenger);
417
+ return legacyRemove(opts);
418
+ }
419
+
257
420
  const wrapped = {
258
421
  propagationDelay,
259
422
  set: setChallenge,
260
- remove: toPromise(challenger.remove, challenger),
423
+ remove: removeChallenge,
261
424
  get: getChallenge,
262
425
  zones: async (opts) => {
263
426
  const dnsHost =
@@ -1,11 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(node:*)",
5
- "Bash(timeout:*)",
6
- "Bash(rm:*)",
7
- "Bash(git checkout:*)"
8
- ],
9
- "deny": []
10
- }
11
- }