roster-server 2.2.2 → 2.2.4

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
@@ -64,14 +64,15 @@ You can serve all subdomains of a domain with a single handler in three ways:
64
64
 
65
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
66
 
67
- For fully automatic TXT records with Linode/Akamai DNS Manager, set:
67
+ For fully automatic TXT records with Linode DNS, set:
68
68
 
69
69
  ```bash
70
70
  export ROSTER_DNS_PROVIDER=linode
71
- export LINODE_API_KEY=... # or LINODE_TOKEN / AKAMAI_API_KEY
71
+ export LINODE_API_KEY=...
72
72
  ```
73
73
 
74
74
  Then Roster creates/removes `_acme-challenge` TXT records automatically via `api.linode.com`.
75
+ If `LINODE_API_KEY` is present, this mode auto-enables by default for wildcard DNS-01.
75
76
 
76
77
  Override with a custom plugin:
77
78
 
@@ -221,7 +222,7 @@ When creating a new `RosterServer` instance, you can pass the following options:
221
222
  - `email` (string): Your email for Let's Encrypt notifications.
222
223
  - `wwwPath` (string): Path to your `www` directory containing your sites.
223
224
  - `greenlockStorePath` (string): Directory for Greenlock configuration.
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.
225
+ - `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.
225
226
  - `staging` (boolean): Set to `true` to use Let's Encrypt's staging environment (for testing).
226
227
  - `local` (boolean): Set to `true` to run in local development mode.
227
228
  - `minLocalPort` (number): Minimum port for local mode (default: 4000).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roster-server",
3
- "version": "2.2.2",
3
+ "version": "2.2.4",
4
4
  "description": "👾 RosterServer - A domain host router to host multiple HTTPS.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -16,12 +16,12 @@ function buildChallengeOpts() {
16
16
 
17
17
  describe('acme-dns-01-cli-wrapper automatic Linode DNS', () => {
18
18
  const originalFetch = global.fetch;
19
- const originalAkamaiApiKey = process.env.AKAMAI_API_KEY;
19
+ const originalLinodeApiKey = process.env.LINODE_API_KEY;
20
20
 
21
21
  afterEach(() => {
22
22
  global.fetch = originalFetch;
23
- if (originalAkamaiApiKey === undefined) delete process.env.AKAMAI_API_KEY;
24
- else process.env.AKAMAI_API_KEY = originalAkamaiApiKey;
23
+ if (originalLinodeApiKey === undefined) delete process.env.LINODE_API_KEY;
24
+ else process.env.LINODE_API_KEY = originalLinodeApiKey;
25
25
  });
26
26
 
27
27
  it('creates and removes TXT records via Linode API', async () => {
@@ -59,8 +59,8 @@ describe('acme-dns-01-cli-wrapper automatic Linode DNS', () => {
59
59
  assert.ok(calls.some((c) => c.url.endsWith('/domains/example.com/records/321') && c.options.method === 'DELETE'));
60
60
  });
61
61
 
62
- it('accepts AKAMAI_API_KEY as Linode token alias', async () => {
63
- process.env.AKAMAI_API_KEY = 'fake-ak-key';
62
+ it('uses LINODE_API_KEY from environment', async () => {
63
+ process.env.LINODE_API_KEY = 'fake-linode-key';
64
64
  const calls = [];
65
65
  global.fetch = async (url, options = {}) => {
66
66
  calls.push({ url, options });
@@ -74,7 +74,7 @@ describe('acme-dns-01-cli-wrapper automatic Linode DNS', () => {
74
74
  };
75
75
 
76
76
  const challenger = wrapper.create({
77
- provider: 'akamai',
77
+ provider: 'linode',
78
78
  verifyDnsBeforeContinue: false,
79
79
  propagationDelay: 0,
80
80
  dryRunDelay: 0
@@ -85,4 +85,169 @@ describe('acme-dns-01-cli-wrapper automatic Linode DNS', () => {
85
85
  const authHeader = calls[0].options?.headers?.Authorization || '';
86
86
  assert.ok(authHeader.startsWith('Bearer '));
87
87
  });
88
+
89
+ it('falls back to parent zone when exact zone does not exist', async () => {
90
+ const calls = [];
91
+ global.fetch = async (url, options = {}) => {
92
+ calls.push({ url, options });
93
+ if (url.endsWith('/domains/sub.example.com')) {
94
+ return { ok: false, status: 404, statusText: 'Not Found', text: async () => 'not found' };
95
+ }
96
+ if (url.endsWith('/domains/example.com')) {
97
+ return { ok: true, status: 200, json: async () => ({}) };
98
+ }
99
+ if (url.endsWith('/domains/example.com/records?page_size=500')) {
100
+ return { ok: true, status: 200, json: async () => ({ data: [] }) };
101
+ }
102
+ if (url.endsWith('/domains/example.com/records') && options.method === 'POST') {
103
+ return { ok: true, status: 200, json: async () => ({ id: 654 }) };
104
+ }
105
+ if (url.endsWith('/domains/example.com/records/654') && options.method === 'DELETE') {
106
+ return { ok: true, status: 204, json: async () => ({}) };
107
+ }
108
+ return { ok: false, status: 404, statusText: 'not mocked', text: async () => 'not mocked' };
109
+ };
110
+
111
+ const challenger = wrapper.create({
112
+ provider: 'linode',
113
+ linodeApiKey: 'fake-token',
114
+ verifyDnsBeforeContinue: false,
115
+ propagationDelay: 0,
116
+ dryRunDelay: 0
117
+ });
118
+
119
+ const opts = {
120
+ challenge: {
121
+ altname: '*.sub.example.com',
122
+ dnsHost: '_acme-challenge.sub.example.com',
123
+ dnsAuthorization: 'fallback-token'
124
+ }
125
+ };
126
+
127
+ await challenger.set(opts);
128
+ await challenger.remove(opts);
129
+
130
+ const postCall = calls.find((c) => c.url.endsWith('/domains/example.com/records') && c.options.method === 'POST');
131
+ assert.ok(postCall, 'expected TXT create call on parent zone');
132
+ const payload = JSON.parse(postCall.options.body);
133
+ assert.strictEqual(payload.name, '_acme-challenge.sub');
134
+ });
135
+
136
+ it('falls back to manual when provider mode has no API key (default)', async () => {
137
+ const prevLinode = process.env.LINODE_API_KEY;
138
+ delete process.env.LINODE_API_KEY;
139
+ global.fetch = async () => ({ ok: true, status: 200, json: async () => ({}) });
140
+
141
+ try {
142
+ const challenger = wrapper.create({
143
+ provider: 'linode',
144
+ verifyDnsBeforeContinue: false,
145
+ propagationDelay: 0,
146
+ dryRunDelay: 0
147
+ });
148
+ await challenger.set(buildChallengeOpts());
149
+ } finally {
150
+ if (prevLinode === undefined) delete process.env.LINODE_API_KEY;
151
+ else process.env.LINODE_API_KEY = prevLinode;
152
+ }
153
+ });
154
+
155
+ it('throws when provider mode has no API key and strict mode is enabled', async () => {
156
+ const prevLinode = process.env.LINODE_API_KEY;
157
+ delete process.env.LINODE_API_KEY;
158
+ global.fetch = async () => ({ ok: true, status: 200, json: async () => ({}) });
159
+
160
+ try {
161
+ const challenger = wrapper.create({
162
+ provider: 'linode',
163
+ dnsApiFallbackToManual: false,
164
+ verifyDnsBeforeContinue: false,
165
+ propagationDelay: 0,
166
+ dryRunDelay: 0
167
+ });
168
+ await assert.rejects(
169
+ challenger.set(buildChallengeOpts()),
170
+ /Linode API key not configured/
171
+ );
172
+ } finally {
173
+ if (prevLinode === undefined) delete process.env.LINODE_API_KEY;
174
+ else process.env.LINODE_API_KEY = prevLinode;
175
+ }
176
+ });
177
+
178
+ it('uses configured TXT TTL when creating Linode records', async () => {
179
+ const calls = [];
180
+ global.fetch = async (url, options = {}) => {
181
+ calls.push({ url, options });
182
+ if (url.endsWith('/domains/example.com')) {
183
+ return { ok: true, status: 200, json: async () => ({}) };
184
+ }
185
+ if (url.endsWith('/domains/example.com/records?page_size=500')) {
186
+ return { ok: true, status: 200, json: async () => ({ data: [] }) };
187
+ }
188
+ if (url.endsWith('/domains/example.com/records') && options.method === 'POST') {
189
+ return { ok: true, status: 200, json: async () => ({ id: 222 }) };
190
+ }
191
+ return { ok: true, status: 204, json: async () => ({}) };
192
+ };
193
+
194
+ const challenger = wrapper.create({
195
+ provider: 'linode',
196
+ linodeApiKey: 'fake-token',
197
+ txtRecordTtl: 300,
198
+ verifyDnsBeforeContinue: false,
199
+ propagationDelay: 0,
200
+ dryRunDelay: 0
201
+ });
202
+
203
+ await challenger.set(buildChallengeOpts());
204
+ const postCall = calls.find((c) => c.url.endsWith('/domains/example.com/records') && c.options.method === 'POST');
205
+ assert.ok(postCall, 'expected TXT create call');
206
+ const payload = JSON.parse(postCall.options.body);
207
+ assert.strictEqual(payload.ttl_sec, 300);
208
+ });
209
+
210
+ it('auto-enables Linode provider when API key is present', async () => {
211
+ process.env.LINODE_API_KEY = 'fake-token';
212
+ const calls = [];
213
+ global.fetch = async (url, options = {}) => {
214
+ calls.push({ url, options });
215
+ if (url.endsWith('/domains/example.com')) {
216
+ return { ok: true, status: 200, json: async () => ({}) };
217
+ }
218
+ if (url.endsWith('/domains/example.com/records?page_size=500')) {
219
+ return { ok: true, status: 200, json: async () => ({ data: [{ id: 111, type: 'TXT', name: '_acme-challenge', target: 'test-token' }] }) };
220
+ }
221
+ return { ok: true, status: 204, json: async () => ({}) };
222
+ };
223
+
224
+ const challenger = wrapper.create({
225
+ verifyDnsBeforeContinue: false,
226
+ propagationDelay: 0,
227
+ dryRunDelay: 0
228
+ });
229
+
230
+ await challenger.set(buildChallengeOpts());
231
+ assert.ok(calls.length >= 2);
232
+ });
233
+
234
+ it('falls back to manual flow when Linode API fails', async () => {
235
+ let fetchCalls = 0;
236
+ global.fetch = async () => {
237
+ fetchCalls += 1;
238
+ return { ok: false, status: 404, statusText: 'Not Found', text: async () => 'not found' };
239
+ };
240
+
241
+ const challenger = wrapper.create({
242
+ provider: 'linode',
243
+ linodeApiKey: 'fake-token',
244
+ dnsApiFallbackToManual: true,
245
+ verifyDnsBeforeContinue: false,
246
+ propagationDelay: 0,
247
+ dryRunDelay: 0
248
+ });
249
+
250
+ await challenger.set(buildChallengeOpts());
251
+ assert.ok(fetchCalls > 0);
252
+ });
88
253
  });
@@ -3,6 +3,19 @@
3
3
  const legacyCli = require('acme-dns-01-cli');
4
4
  const log = require('lemonlog')('acme-dns-01');
5
5
  const dns = require('node:dns').promises;
6
+ let envFileLoadAttempted = false;
7
+
8
+ function loadEnvFileSafely() {
9
+ if (envFileLoadAttempted) return;
10
+ envFileLoadAttempted = true;
11
+ try {
12
+ if (typeof process.loadEnvFile === 'function') {
13
+ process.loadEnvFile();
14
+ }
15
+ } catch {
16
+ // Ignore missing .env or unsupported runtime behavior.
17
+ }
18
+ }
6
19
 
7
20
  function toPromise(fn, context) {
8
21
  if (typeof fn !== 'function') {
@@ -41,6 +54,7 @@ function toPromise(fn, context) {
41
54
  }
42
55
 
43
56
  module.exports.create = function create(config = {}) {
57
+ loadEnvFileSafely();
44
58
  const challenger = legacyCli.create(config);
45
59
  const propagationDelay = Number.isFinite(config.propagationDelay)
46
60
  ? config.propagationDelay
@@ -110,13 +124,14 @@ module.exports.create = function create(config = {}) {
110
124
  const configuredProvider = normalizeProvider(
111
125
  config.provider
112
126
  || process.env.ROSTER_DNS_PROVIDER
113
- || (config.linodeApiKey || process.env.LINODE_API_KEY || process.env.LINODE_TOKEN || process.env.AKAMAI_API_KEY ? 'linode' : '')
127
+ || (config.linodeApiKey || process.env.LINODE_API_KEY ? 'linode' : '')
114
128
  );
115
- const isLinodeProvider = ['linode', 'akamai', 'linode-akamai', 'akamai-linode'].includes(configuredProvider);
129
+ const isLinodeProvider = configuredProvider === 'linode';
130
+ const dnsApiFallbackToManual = config.dnsApiFallbackToManual !== undefined
131
+ ? parseBool(config.dnsApiFallbackToManual, true)
132
+ : parseBool(process.env.ROSTER_DNS_API_FALLBACK_TO_MANUAL, true);
116
133
  const linodeApiKey = config.linodeApiKey
117
134
  || process.env.LINODE_API_KEY
118
- || process.env.LINODE_TOKEN
119
- || process.env.AKAMAI_API_KEY
120
135
  || '';
121
136
  const linodeApiBase = String(config.linodeApiBase || process.env.LINODE_API_BASE_URL || 'https://api.linode.com/v4').replace(/\/+$/, '');
122
137
  const txtRecordTtl = Number.isFinite(config.txtRecordTtl) ? Math.max(30, Number(config.txtRecordTtl)) : 60;
@@ -218,7 +233,7 @@ module.exports.create = function create(config = {}) {
218
233
  async function linodeRequest(pathname, method = 'GET', body) {
219
234
  const apiKey = String(linodeApiKey || '').trim();
220
235
  if (!apiKey) {
221
- throw new Error('Linode API key not configured. Set LINODE_API_KEY (or LINODE_TOKEN/AKAMAI_API_KEY).');
236
+ throw new Error('Linode API key not configured. Set LINODE_API_KEY.');
222
237
  }
223
238
  if (typeof fetch !== 'function') {
224
239
  throw new Error('Global fetch is unavailable in this runtime; cannot call Linode DNS API.');
@@ -292,9 +307,10 @@ module.exports.create = function create(config = {}) {
292
307
 
293
308
  async function linodeRemoveTxtRecord(dnsHost) {
294
309
  const stored = linodeTxtRecordsByHost.get(dnsHost);
295
- if (!stored?.zone || !stored?.id) return;
310
+ if (!stored?.zone || !stored?.id) return false;
296
311
  await linodeRequest(`/domains/${encodeURIComponent(stored.zone)}/records/${stored.id}`, 'DELETE');
297
312
  linodeTxtRecordsByHost.delete(dnsHost);
313
+ return true;
298
314
  }
299
315
 
300
316
  async function setChallenge(opts) {
@@ -319,8 +335,16 @@ module.exports.create = function create(config = {}) {
319
335
  (result?.zone ? ` (zone ${result.zone})` : '')
320
336
  );
321
337
  } catch (error) {
322
- log.error(`Failed to create Linode DNS TXT for ${dnsHost}: ${error?.message || error}`);
323
- throw error;
338
+ const errorMsg = error?.message || error;
339
+ if (dnsApiFallbackToManual) {
340
+ log.warn(
341
+ `Linode DNS API failed for ${dnsHost}: ${errorMsg}. ` +
342
+ 'Falling back to manual/legacy DNS flow for this challenge.'
343
+ );
344
+ } else {
345
+ log.error(`Failed to create Linode DNS TXT for ${dnsHost}: ${errorMsg}`);
346
+ throw error;
347
+ }
324
348
  }
325
349
  }
326
350
  const isDryRunChallenge = dnsHost.includes('_greenlock-dryrun-');
@@ -404,8 +428,10 @@ module.exports.create = function create(config = {}) {
404
428
 
405
429
  if (isLinodeProvider && dnsHost) {
406
430
  try {
407
- await linodeRemoveTxtRecord(dnsHost);
408
- log.info(`Linode DNS TXT removed for ${dnsHost}`);
431
+ const removed = await linodeRemoveTxtRecord(dnsHost);
432
+ if (removed) {
433
+ log.info(`Linode DNS TXT removed for ${dnsHost}`);
434
+ }
409
435
  return null;
410
436
  } catch (error) {
411
437
  log.warn(`Failed to remove Linode DNS TXT for ${dnsHost}: ${error?.message || error}`);