roster-server 2.2.1 → 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
@@ -62,7 +62,19 @@ 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 DNS, set:
68
+
69
+ ```bash
70
+ export ROSTER_DNS_PROVIDER=linode
71
+ export LINODE_API_KEY=...
72
+ ```
73
+
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.
76
+
77
+ Override with a custom plugin:
66
78
 
67
79
  ```javascript
68
80
  import Roster from 'roster-server';
@@ -210,7 +222,7 @@ When creating a new `RosterServer` instance, you can pass the following options:
210
222
  - `email` (string): Your email for Let's Encrypt notifications.
211
223
  - `wwwPath` (string): Path to your `www` directory containing your sites.
212
224
  - `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.
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.
214
226
  - `staging` (boolean): Set to `true` to use Let's Encrypt's staging environment (for testing).
215
227
  - `local` (boolean): Set to `true` to run in local development mode.
216
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.1",
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": {
@@ -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,253 @@
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 originalLinodeApiKey = process.env.LINODE_API_KEY;
20
+
21
+ afterEach(() => {
22
+ global.fetch = originalFetch;
23
+ if (originalLinodeApiKey === undefined) delete process.env.LINODE_API_KEY;
24
+ else process.env.LINODE_API_KEY = originalLinodeApiKey;
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('uses LINODE_API_KEY from environment', async () => {
63
+ process.env.LINODE_API_KEY = 'fake-linode-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: 'linode',
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
+
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
+ });
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
@@ -106,6 +120,21 @@ module.exports.create = function create(config = {}) {
106
120
  resolver.setServers([server]);
107
121
  return { server, resolver };
108
122
  });
123
+ const normalizeProvider = (value) => String(value || '').trim().toLowerCase();
124
+ const configuredProvider = normalizeProvider(
125
+ config.provider
126
+ || process.env.ROSTER_DNS_PROVIDER
127
+ || (config.linodeApiKey || process.env.LINODE_API_KEY ? 'linode' : '')
128
+ );
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);
133
+ const linodeApiKey = config.linodeApiKey
134
+ || process.env.LINODE_API_KEY
135
+ || '';
136
+ const linodeApiBase = String(config.linodeApiBase || process.env.LINODE_API_BASE_URL || 'https://api.linode.com/v4').replace(/\/+$/, '');
137
+ const txtRecordTtl = Number.isFinite(config.txtRecordTtl) ? Math.max(30, Number(config.txtRecordTtl)) : 60;
109
138
 
110
139
  function sleep(ms) {
111
140
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -169,6 +198,120 @@ module.exports.create = function create(config = {}) {
169
198
 
170
199
  const presentedByHost = new Map();
171
200
  const presentedByAltname = new Map();
201
+ const linodeTxtRecordsByHost = new Map();
202
+
203
+ function buildZoneCandidates({ dnsHost, altname }) {
204
+ const candidates = new Set();
205
+ const add = (value) => {
206
+ const normalized = String(value || '').replace(/\.$/, '').toLowerCase();
207
+ if (!normalized) return;
208
+ const labels = normalized.split('.').filter(Boolean);
209
+ for (let i = 0; i <= labels.length - 2; i += 1) {
210
+ candidates.add(labels.slice(i).join('.'));
211
+ }
212
+ };
213
+
214
+ if (dnsHost) {
215
+ const normalizedDnsHost = String(dnsHost).replace(/^_acme-challenge\./, '').replace(/^_greenlock-[^.]+\./, '');
216
+ add(normalizedDnsHost);
217
+ }
218
+ if (altname) {
219
+ add(String(altname).replace(/^\*\./, ''));
220
+ }
221
+ return Array.from(candidates);
222
+ }
223
+
224
+ function linodeRecordNameForHost(dnsHost, zone) {
225
+ const host = String(dnsHost || '').replace(/\.$/, '').toLowerCase();
226
+ const normalizedZone = String(zone || '').replace(/\.$/, '').toLowerCase();
227
+ if (!host || !normalizedZone) return '';
228
+ if (host === normalizedZone) return '';
229
+ if (!host.endsWith(`.${normalizedZone}`)) return '';
230
+ return host.slice(0, host.length - normalizedZone.length - 1);
231
+ }
232
+
233
+ async function linodeRequest(pathname, method = 'GET', body) {
234
+ const apiKey = String(linodeApiKey || '').trim();
235
+ if (!apiKey) {
236
+ throw new Error('Linode API key not configured. Set LINODE_API_KEY.');
237
+ }
238
+ if (typeof fetch !== 'function') {
239
+ throw new Error('Global fetch is unavailable in this runtime; cannot call Linode DNS API.');
240
+ }
241
+ const response = await fetch(`${linodeApiBase}${pathname}`, {
242
+ method,
243
+ headers: {
244
+ Authorization: `Bearer ${apiKey}`,
245
+ 'Content-Type': 'application/json'
246
+ },
247
+ ...(body ? { body: JSON.stringify(body) } : {})
248
+ });
249
+
250
+ if (!response.ok) {
251
+ let details = '';
252
+ try {
253
+ details = await response.text();
254
+ } catch {
255
+ details = '';
256
+ }
257
+ throw new Error(`Linode API ${method} ${pathname} failed (${response.status}): ${details || response.statusText}`);
258
+ }
259
+ if (response.status === 204) return null;
260
+ return response.json();
261
+ }
262
+
263
+ async function linodeUpsertTxtRecord(dnsHost, dnsAuthorization, altname) {
264
+ const zoneCandidates = buildZoneCandidates({ dnsHost, altname });
265
+ let lastError = null;
266
+
267
+ for (const zone of zoneCandidates) {
268
+ try {
269
+ await linodeRequest(`/domains/${encodeURIComponent(zone)}`, 'GET');
270
+ } catch (error) {
271
+ lastError = error;
272
+ continue;
273
+ }
274
+
275
+ const recordName = linodeRecordNameForHost(dnsHost, zone);
276
+ if (!recordName && dnsHost !== zone) continue;
277
+
278
+ const recordsResult = await linodeRequest(`/domains/${encodeURIComponent(zone)}/records?page_size=500`, 'GET');
279
+ const existing = Array.isArray(recordsResult?.data) ? recordsResult.data : [];
280
+ const sameRecord = existing.find((record) =>
281
+ record?.type === 'TXT'
282
+ && String(record?.name || '') === String(recordName || '')
283
+ && String(record?.target || '') === String(dnsAuthorization || '')
284
+ );
285
+
286
+ if (sameRecord && sameRecord.id) {
287
+ linodeTxtRecordsByHost.set(dnsHost, { zone, id: sameRecord.id });
288
+ return { zone, id: sameRecord.id, reused: true };
289
+ }
290
+
291
+ const created = await linodeRequest(`/domains/${encodeURIComponent(zone)}/records`, 'POST', {
292
+ type: 'TXT',
293
+ name: recordName,
294
+ target: dnsAuthorization,
295
+ ttl_sec: txtRecordTtl
296
+ });
297
+
298
+ if (created?.id) {
299
+ linodeTxtRecordsByHost.set(dnsHost, { zone, id: created.id });
300
+ return { zone, id: created.id, reused: false };
301
+ }
302
+ }
303
+
304
+ if (lastError) throw lastError;
305
+ throw new Error(`Unable to map ${dnsHost} to a Linode DNS zone`);
306
+ }
307
+
308
+ async function linodeRemoveTxtRecord(dnsHost) {
309
+ const stored = linodeTxtRecordsByHost.get(dnsHost);
310
+ if (!stored?.zone || !stored?.id) return false;
311
+ await linodeRequest(`/domains/${encodeURIComponent(stored.zone)}/records/${stored.id}`, 'DELETE');
312
+ linodeTxtRecordsByHost.delete(dnsHost);
313
+ return true;
314
+ }
172
315
 
173
316
  async function setChallenge(opts) {
174
317
  const ch = opts?.challenge || {};
@@ -184,6 +327,26 @@ module.exports.create = function create(config = {}) {
184
327
  if (altname && dnsAuth) {
185
328
  presentedByAltname.set(altname, { dnsHost, dnsAuthorization: dnsAuth });
186
329
  }
330
+ if (isLinodeProvider && dnsHost && dnsAuth) {
331
+ try {
332
+ const result = await linodeUpsertTxtRecord(dnsHost, dnsAuth, altname);
333
+ log.info(
334
+ `Linode DNS TXT ${result?.reused ? 'reused' : 'created'} for ${dnsHost}` +
335
+ (result?.zone ? ` (zone ${result.zone})` : '')
336
+ );
337
+ } catch (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
+ }
348
+ }
349
+ }
187
350
  const isDryRunChallenge = dnsHost.includes('_greenlock-dryrun-');
188
351
  const effectiveDelay = isDryRunChallenge
189
352
  ? Math.max(0, dryRunDelay)
@@ -218,8 +381,10 @@ module.exports.create = function create(config = {}) {
218
381
  }
219
382
 
220
383
  log.info(
221
- 'Non-interactive mode (or autoContinue) detected. ' +
222
- 'Set the TXT record now. Continuing automatically in ' +
384
+ (isLinodeProvider
385
+ ? 'Automatic DNS provider mode detected.'
386
+ : 'Non-interactive mode (or autoContinue) detected. Set the TXT record now.') +
387
+ ' Continuing automatically in ' +
223
388
  effectiveDelay +
224
389
  'ms...'
225
390
  );
@@ -254,10 +419,34 @@ module.exports.create = function create(config = {}) {
254
419
  };
255
420
  }
256
421
 
422
+ async function removeChallenge(opts) {
423
+ const ch = opts?.challenge || {};
424
+ const altname = String(ch.altname || opts?.altname || '');
425
+ const wildcardZone = altname.startsWith('*.') ? altname.slice(2) : '';
426
+ const dnsHostFromAltname = wildcardZone ? `_acme-challenge.${wildcardZone}` : '';
427
+ const dnsHost = String(ch.dnsHost || opts?.dnsHost || dnsHostFromAltname || '');
428
+
429
+ if (isLinodeProvider && dnsHost) {
430
+ try {
431
+ const removed = await linodeRemoveTxtRecord(dnsHost);
432
+ if (removed) {
433
+ log.info(`Linode DNS TXT removed for ${dnsHost}`);
434
+ }
435
+ return null;
436
+ } catch (error) {
437
+ log.warn(`Failed to remove Linode DNS TXT for ${dnsHost}: ${error?.message || error}`);
438
+ return null;
439
+ }
440
+ }
441
+
442
+ const legacyRemove = toPromise(challenger.remove, challenger);
443
+ return legacyRemove(opts);
444
+ }
445
+
257
446
  const wrapped = {
258
447
  propagationDelay,
259
448
  set: setChallenge,
260
- remove: toPromise(challenger.remove, challenger),
449
+ remove: removeChallenge,
261
450
  get: getChallenge,
262
451
  zones: async (opts) => {
263
452
  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
- }