roster-server 2.2.2 → 2.2.5

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.5",
4
4
  "description": "👾 RosterServer - A domain host router to host multiple HTTPS.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -16,28 +16,28 @@ 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 () => {
28
28
  const calls = [];
29
29
  global.fetch = async (url, options = {}) => {
30
30
  calls.push({ url, options });
31
- if (url.endsWith('/domains/example.com')) {
32
- return { ok: true, status: 200, json: async () => ({}) };
31
+ if (url.endsWith('/domains?page_size=500')) {
32
+ return { ok: true, status: 200, json: async () => ({ data: [{ id: 42, domain: 'example.com' }] }) };
33
33
  }
34
- if (url.endsWith('/domains/example.com/records?page_size=500')) {
34
+ if (url.endsWith('/domains/42/records?page_size=500')) {
35
35
  return { ok: true, status: 200, json: async () => ({ data: [] }) };
36
36
  }
37
- if (url.endsWith('/domains/example.com/records') && options.method === 'POST') {
37
+ if (url.endsWith('/domains/42/records') && options.method === 'POST') {
38
38
  return { ok: true, status: 200, json: async () => ({ id: 321 }) };
39
39
  }
40
- if (url.endsWith('/domains/example.com/records/321') && options.method === 'DELETE') {
40
+ if (url.endsWith('/domains/42/records/321') && options.method === 'DELETE') {
41
41
  return { ok: true, status: 204, json: async () => ({}) };
42
42
  }
43
43
  return { ok: false, status: 404, statusText: 'not mocked', text: async () => 'not mocked' };
@@ -55,26 +55,26 @@ describe('acme-dns-01-cli-wrapper automatic Linode DNS', () => {
55
55
  await challenger.set(opts);
56
56
  await challenger.remove(opts);
57
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'));
58
+ assert.ok(calls.some((c) => c.url.endsWith('/domains/42/records') && c.options.method === 'POST'));
59
+ assert.ok(calls.some((c) => c.url.endsWith('/domains/42/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 });
67
- if (url.endsWith('/domains/example.com')) {
68
- return { ok: true, status: 200, json: async () => ({}) };
67
+ if (url.endsWith('/domains?page_size=500')) {
68
+ return { ok: true, status: 200, json: async () => ({ data: [{ id: 42, domain: 'example.com' }] }) };
69
69
  }
70
- if (url.endsWith('/domains/example.com/records?page_size=500')) {
70
+ if (url.endsWith('/domains/42/records?page_size=500')) {
71
71
  return { ok: true, status: 200, json: async () => ({ data: [{ id: 111, type: 'TXT', name: '_acme-challenge', target: 'test-token' }] }) };
72
72
  }
73
73
  return { ok: true, status: 204, json: async () => ({}) };
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,166 @@ 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?page_size=500')) {
94
+ return { ok: true, status: 200, json: async () => ({ data: [{ id: 99, domain: 'example.com' }] }) };
95
+ }
96
+ if (url.endsWith('/domains/99/records?page_size=500')) {
97
+ return { ok: true, status: 200, json: async () => ({ data: [] }) };
98
+ }
99
+ if (url.endsWith('/domains/99/records') && options.method === 'POST') {
100
+ return { ok: true, status: 200, json: async () => ({ id: 654 }) };
101
+ }
102
+ if (url.endsWith('/domains/99/records/654') && options.method === 'DELETE') {
103
+ return { ok: true, status: 204, json: async () => ({}) };
104
+ }
105
+ return { ok: false, status: 404, statusText: 'not mocked', text: async () => 'not mocked' };
106
+ };
107
+
108
+ const challenger = wrapper.create({
109
+ provider: 'linode',
110
+ linodeApiKey: 'fake-token',
111
+ verifyDnsBeforeContinue: false,
112
+ propagationDelay: 0,
113
+ dryRunDelay: 0
114
+ });
115
+
116
+ const opts = {
117
+ challenge: {
118
+ altname: '*.sub.example.com',
119
+ dnsHost: '_acme-challenge.sub.example.com',
120
+ dnsAuthorization: 'fallback-token'
121
+ }
122
+ };
123
+
124
+ await challenger.set(opts);
125
+ await challenger.remove(opts);
126
+
127
+ const postCall = calls.find((c) => c.url.endsWith('/domains/99/records') && c.options.method === 'POST');
128
+ assert.ok(postCall, 'expected TXT create call on parent zone');
129
+ const payload = JSON.parse(postCall.options.body);
130
+ assert.strictEqual(payload.name, '_acme-challenge.sub');
131
+ });
132
+
133
+ it('falls back to manual when provider mode has no API key (default)', async () => {
134
+ const prevLinode = process.env.LINODE_API_KEY;
135
+ delete process.env.LINODE_API_KEY;
136
+ global.fetch = async () => ({ ok: true, status: 200, json: async () => ({}) });
137
+
138
+ try {
139
+ const challenger = wrapper.create({
140
+ provider: 'linode',
141
+ verifyDnsBeforeContinue: false,
142
+ propagationDelay: 0,
143
+ dryRunDelay: 0
144
+ });
145
+ await challenger.set(buildChallengeOpts());
146
+ } finally {
147
+ if (prevLinode === undefined) delete process.env.LINODE_API_KEY;
148
+ else process.env.LINODE_API_KEY = prevLinode;
149
+ }
150
+ });
151
+
152
+ it('throws when provider mode has no API key and strict mode is enabled', async () => {
153
+ const prevLinode = process.env.LINODE_API_KEY;
154
+ delete process.env.LINODE_API_KEY;
155
+ global.fetch = async () => ({ ok: true, status: 200, json: async () => ({}) });
156
+
157
+ try {
158
+ const challenger = wrapper.create({
159
+ provider: 'linode',
160
+ dnsApiFallbackToManual: false,
161
+ verifyDnsBeforeContinue: false,
162
+ propagationDelay: 0,
163
+ dryRunDelay: 0
164
+ });
165
+ await assert.rejects(
166
+ challenger.set(buildChallengeOpts()),
167
+ /Linode API key not configured/
168
+ );
169
+ } finally {
170
+ if (prevLinode === undefined) delete process.env.LINODE_API_KEY;
171
+ else process.env.LINODE_API_KEY = prevLinode;
172
+ }
173
+ });
174
+
175
+ it('uses configured TXT TTL when creating Linode records', async () => {
176
+ const calls = [];
177
+ global.fetch = async (url, options = {}) => {
178
+ calls.push({ url, options });
179
+ if (url.endsWith('/domains?page_size=500')) {
180
+ return { ok: true, status: 200, json: async () => ({ data: [{ id: 42, domain: 'example.com' }] }) };
181
+ }
182
+ if (url.endsWith('/domains/42/records?page_size=500')) {
183
+ return { ok: true, status: 200, json: async () => ({ data: [] }) };
184
+ }
185
+ if (url.endsWith('/domains/42/records') && options.method === 'POST') {
186
+ return { ok: true, status: 200, json: async () => ({ id: 222 }) };
187
+ }
188
+ return { ok: true, status: 204, json: async () => ({}) };
189
+ };
190
+
191
+ const challenger = wrapper.create({
192
+ provider: 'linode',
193
+ linodeApiKey: 'fake-token',
194
+ txtRecordTtl: 300,
195
+ verifyDnsBeforeContinue: false,
196
+ propagationDelay: 0,
197
+ dryRunDelay: 0
198
+ });
199
+
200
+ await challenger.set(buildChallengeOpts());
201
+ const postCall = calls.find((c) => c.url.endsWith('/domains/42/records') && c.options.method === 'POST');
202
+ assert.ok(postCall, 'expected TXT create call');
203
+ const payload = JSON.parse(postCall.options.body);
204
+ assert.strictEqual(payload.ttl_sec, 300);
205
+ });
206
+
207
+ it('auto-enables Linode provider when API key is present', async () => {
208
+ process.env.LINODE_API_KEY = 'fake-token';
209
+ const calls = [];
210
+ global.fetch = async (url, options = {}) => {
211
+ calls.push({ url, options });
212
+ if (url.endsWith('/domains?page_size=500')) {
213
+ return { ok: true, status: 200, json: async () => ({ data: [{ id: 42, domain: 'example.com' }] }) };
214
+ }
215
+ if (url.endsWith('/domains/42/records?page_size=500')) {
216
+ return { ok: true, status: 200, json: async () => ({ data: [{ id: 111, type: 'TXT', name: '_acme-challenge', target: 'test-token' }] }) };
217
+ }
218
+ return { ok: true, status: 204, json: async () => ({}) };
219
+ };
220
+
221
+ const challenger = wrapper.create({
222
+ verifyDnsBeforeContinue: false,
223
+ propagationDelay: 0,
224
+ dryRunDelay: 0
225
+ });
226
+
227
+ await challenger.set(buildChallengeOpts());
228
+ assert.ok(calls.length >= 2);
229
+ });
230
+
231
+ it('falls back to manual flow when Linode API fails', async () => {
232
+ let fetchCalls = 0;
233
+ global.fetch = async () => {
234
+ fetchCalls += 1;
235
+ return { ok: false, status: 404, statusText: 'Not Found', text: async () => 'not found' };
236
+ };
237
+
238
+ const challenger = wrapper.create({
239
+ provider: 'linode',
240
+ linodeApiKey: 'fake-token',
241
+ dnsApiFallbackToManual: true,
242
+ verifyDnsBeforeContinue: false,
243
+ propagationDelay: 0,
244
+ dryRunDelay: 0
245
+ });
246
+
247
+ await challenger.set(buildChallengeOpts());
248
+ assert.ok(fetchCalls > 0);
249
+ });
88
250
  });
@@ -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;
@@ -184,6 +199,7 @@ module.exports.create = function create(config = {}) {
184
199
  const presentedByHost = new Map();
185
200
  const presentedByAltname = new Map();
186
201
  const linodeTxtRecordsByHost = new Map();
202
+ const linodeZoneCache = new Map();
187
203
 
188
204
  function buildZoneCandidates({ dnsHost, altname }) {
189
205
  const candidates = new Set();
@@ -218,7 +234,7 @@ module.exports.create = function create(config = {}) {
218
234
  async function linodeRequest(pathname, method = 'GET', body) {
219
235
  const apiKey = String(linodeApiKey || '').trim();
220
236
  if (!apiKey) {
221
- throw new Error('Linode API key not configured. Set LINODE_API_KEY (or LINODE_TOKEN/AKAMAI_API_KEY).');
237
+ throw new Error('Linode API key not configured. Set LINODE_API_KEY.');
222
238
  }
223
239
  if (typeof fetch !== 'function') {
224
240
  throw new Error('Global fetch is unavailable in this runtime; cannot call Linode DNS API.');
@@ -245,22 +261,40 @@ module.exports.create = function create(config = {}) {
245
261
  return response.json();
246
262
  }
247
263
 
264
+ async function resolveLinodeZone(zone) {
265
+ const normalizedZone = String(zone || '').trim().toLowerCase();
266
+ if (!normalizedZone) return null;
267
+ if (linodeZoneCache.has(normalizedZone)) return linodeZoneCache.get(normalizedZone);
268
+
269
+ const domainsResult = await linodeRequest('/domains?page_size=500', 'GET');
270
+ const domains = Array.isArray(domainsResult?.data) ? domainsResult.data : [];
271
+ const matched = domains.find((entry) => String(entry?.domain || '').trim().toLowerCase() === normalizedZone);
272
+ if (!matched?.id) return null;
273
+
274
+ const zoneInfo = { id: matched.id, domain: String(matched.domain || normalizedZone) };
275
+ linodeZoneCache.set(normalizedZone, zoneInfo);
276
+ return zoneInfo;
277
+ }
278
+
248
279
  async function linodeUpsertTxtRecord(dnsHost, dnsAuthorization, altname) {
249
280
  const zoneCandidates = buildZoneCandidates({ dnsHost, altname });
250
281
  let lastError = null;
251
282
 
252
283
  for (const zone of zoneCandidates) {
284
+ let zoneInfo = null;
253
285
  try {
254
- await linodeRequest(`/domains/${encodeURIComponent(zone)}`, 'GET');
286
+ zoneInfo = await resolveLinodeZone(zone);
255
287
  } catch (error) {
256
288
  lastError = error;
257
289
  continue;
258
290
  }
291
+ if (!zoneInfo?.id) continue;
259
292
 
260
293
  const recordName = linodeRecordNameForHost(dnsHost, zone);
261
294
  if (!recordName && dnsHost !== zone) continue;
262
295
 
263
- const recordsResult = await linodeRequest(`/domains/${encodeURIComponent(zone)}/records?page_size=500`, 'GET');
296
+ const zoneId = zoneInfo.id;
297
+ const recordsResult = await linodeRequest(`/domains/${zoneId}/records?page_size=500`, 'GET');
264
298
  const existing = Array.isArray(recordsResult?.data) ? recordsResult.data : [];
265
299
  const sameRecord = existing.find((record) =>
266
300
  record?.type === 'TXT'
@@ -269,11 +303,11 @@ module.exports.create = function create(config = {}) {
269
303
  );
270
304
 
271
305
  if (sameRecord && sameRecord.id) {
272
- linodeTxtRecordsByHost.set(dnsHost, { zone, id: sameRecord.id });
273
- return { zone, id: sameRecord.id, reused: true };
306
+ linodeTxtRecordsByHost.set(dnsHost, { zone, zoneId, id: sameRecord.id });
307
+ return { zone, zoneId, id: sameRecord.id, reused: true };
274
308
  }
275
309
 
276
- const created = await linodeRequest(`/domains/${encodeURIComponent(zone)}/records`, 'POST', {
310
+ const created = await linodeRequest(`/domains/${zoneId}/records`, 'POST', {
277
311
  type: 'TXT',
278
312
  name: recordName,
279
313
  target: dnsAuthorization,
@@ -281,8 +315,8 @@ module.exports.create = function create(config = {}) {
281
315
  });
282
316
 
283
317
  if (created?.id) {
284
- linodeTxtRecordsByHost.set(dnsHost, { zone, id: created.id });
285
- return { zone, id: created.id, reused: false };
318
+ linodeTxtRecordsByHost.set(dnsHost, { zone, zoneId, id: created.id });
319
+ return { zone, zoneId, id: created.id, reused: false };
286
320
  }
287
321
  }
288
322
 
@@ -292,9 +326,10 @@ module.exports.create = function create(config = {}) {
292
326
 
293
327
  async function linodeRemoveTxtRecord(dnsHost) {
294
328
  const stored = linodeTxtRecordsByHost.get(dnsHost);
295
- if (!stored?.zone || !stored?.id) return;
296
- await linodeRequest(`/domains/${encodeURIComponent(stored.zone)}/records/${stored.id}`, 'DELETE');
329
+ if (!stored?.zoneId || !stored?.id) return false;
330
+ await linodeRequest(`/domains/${stored.zoneId}/records/${stored.id}`, 'DELETE');
297
331
  linodeTxtRecordsByHost.delete(dnsHost);
332
+ return true;
298
333
  }
299
334
 
300
335
  async function setChallenge(opts) {
@@ -319,8 +354,16 @@ module.exports.create = function create(config = {}) {
319
354
  (result?.zone ? ` (zone ${result.zone})` : '')
320
355
  );
321
356
  } catch (error) {
322
- log.error(`Failed to create Linode DNS TXT for ${dnsHost}: ${error?.message || error}`);
323
- throw error;
357
+ const errorMsg = error?.message || error;
358
+ if (dnsApiFallbackToManual) {
359
+ log.warn(
360
+ `Linode DNS API failed for ${dnsHost}: ${errorMsg}. ` +
361
+ 'Falling back to manual/legacy DNS flow for this challenge.'
362
+ );
363
+ } else {
364
+ log.error(`Failed to create Linode DNS TXT for ${dnsHost}: ${errorMsg}`);
365
+ throw error;
366
+ }
324
367
  }
325
368
  }
326
369
  const isDryRunChallenge = dnsHost.includes('_greenlock-dryrun-');
@@ -404,8 +447,10 @@ module.exports.create = function create(config = {}) {
404
447
 
405
448
  if (isLinodeProvider && dnsHost) {
406
449
  try {
407
- await linodeRemoveTxtRecord(dnsHost);
408
- log.info(`Linode DNS TXT removed for ${dnsHost}`);
450
+ const removed = await linodeRemoveTxtRecord(dnsHost);
451
+ if (removed) {
452
+ log.info(`Linode DNS TXT removed for ${dnsHost}`);
453
+ }
409
454
  return null;
410
455
  } catch (error) {
411
456
  log.warn(`Failed to remove Linode DNS TXT for ${dnsHost}: ${error?.message || error}`);