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 +4 -3
- package/package.json +1 -1
- package/test/acme-dns-01-cli-wrapper.test.js +171 -6
- package/vendor/acme-dns-01-cli-wrapper.js +36 -10
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
|
|
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=...
|
|
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
|
|
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
|
@@ -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
|
|
19
|
+
const originalLinodeApiKey = process.env.LINODE_API_KEY;
|
|
20
20
|
|
|
21
21
|
afterEach(() => {
|
|
22
22
|
global.fetch = originalFetch;
|
|
23
|
-
if (
|
|
24
|
-
else process.env.
|
|
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('
|
|
63
|
-
process.env.
|
|
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: '
|
|
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
|
|
127
|
+
|| (config.linodeApiKey || process.env.LINODE_API_KEY ? 'linode' : '')
|
|
114
128
|
);
|
|
115
|
-
const isLinodeProvider =
|
|
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
|
|
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
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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}`);
|