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 +14 -2
- package/package.json +1 -1
- package/tasks/lessons.md +3 -0
- package/test/acme-dns-01-cli-wrapper.test.js +253 -0
- package/vendor/acme-dns-01-cli-wrapper.js +192 -3
- package/.claude/settings.local.json +0 -11
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).
|
|
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
|
|
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
package/tasks/lessons.md
ADDED
|
@@ -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
|
-
|
|
222
|
-
|
|
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:
|
|
449
|
+
remove: removeChallenge,
|
|
261
450
|
get: getChallenge,
|
|
262
451
|
zones: async (opts) => {
|
|
263
452
|
const dnsHost =
|