cloudflare-ddns-sync 4.0.0-beta.2 → 4.0.0-rc.1
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/CHANGELOG.md +13 -0
- package/MIGRATION-V4.md +64 -0
- package/README.md +91 -117
- package/dist/index.d.ts +41 -16
- package/dist/index.js +286 -40
- package/dist/lib/cloudflare-client.d.ts +16 -10
- package/dist/lib/cloudflare-client.js +119 -161
- package/dist/lib/cron.d.ts +1 -1
- package/dist/lib/cron.js +1 -3
- package/dist/lib/ip-utils.d.ts +0 -5
- package/dist/lib/ip-utils.js +0 -26
- package/dist/lib/sync-error.d.ts +12 -0
- package/dist/lib/sync-error.js +10 -0
- package/dist/lib/sync-job.d.ts +44 -0
- package/dist/lib/sync-job.js +190 -0
- package/dist/types/Auth.d.ts +5 -2
- package/dist/types/Callbacks.d.ts +3 -2
- package/dist/types/Options.d.ts +48 -0
- package/dist/types/Record.d.ts +11 -2
- package/dist/types/cloudflare/RecordData.d.ts +3 -1
- package/dist/types/index.d.ts +4 -1
- package/dist/types/index.js +4 -1
- package/logo.png +0 -0
- package/package.json +19 -16
- package/dist/types/CloudflareOptions.d.ts +0 -2
- /package/dist/types/{CloudflareOptions.js → Options.js} +0 -0
package/dist/index.js
CHANGED
|
@@ -1,58 +1,304 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
1
2
|
import CloudflareClient from './lib/cloudflare-client.js';
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
|
|
3
|
+
import IPUtils from './lib/ip-utils.js';
|
|
4
|
+
import { SyncError } from './lib/sync-error.js';
|
|
5
|
+
import { ScheduledSyncJob, WatchSyncJob } from './lib/sync-job.js';
|
|
6
|
+
const defaultWatchIntervalMs = 10_000;
|
|
7
|
+
const maxTimerDelayMs = 2_147_483_647;
|
|
8
|
+
const recordTypes = new Set(['A', 'AAAA', 'CNAME', 'HTTPS', 'TXT', 'LOC', 'NS', 'SPF', 'CERT', 'DNSKEY', 'DS', 'NAPTR', 'SMIMEA', 'SSHFP', 'SVCB', 'TLSA']);
|
|
9
|
+
export class DdnsError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
cause;
|
|
12
|
+
constructor(code, message, cause) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.cause = cause;
|
|
16
|
+
this.name = 'DdnsError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export { SyncError };
|
|
20
|
+
export class Ddns {
|
|
5
21
|
cloudflareClient;
|
|
6
|
-
|
|
7
|
-
|
|
22
|
+
configuredRecords;
|
|
23
|
+
zone;
|
|
24
|
+
resolveIp;
|
|
25
|
+
jobs = new Set();
|
|
26
|
+
closed = false;
|
|
27
|
+
constructor(options = {}) {
|
|
28
|
+
const { records, zone, resolveIp, ...auth } = options;
|
|
29
|
+
this.cloudflareClient = new CloudflareClient(resolveAuth(auth));
|
|
30
|
+
this.zone = normalizeZone(zone);
|
|
31
|
+
this.resolveIp = resolveIp;
|
|
32
|
+
this.configuredRecords = records === undefined ? undefined : normalizeRecords(records, this.zone);
|
|
8
33
|
}
|
|
9
|
-
|
|
10
|
-
|
|
34
|
+
async sync(first, second = {}) {
|
|
35
|
+
this.ensureOpen();
|
|
36
|
+
const { records, options } = getSyncArguments(first, second);
|
|
37
|
+
validateSyncOptions(options);
|
|
38
|
+
const selectedRecords = normalizeSyncRecords(this.resolveRecords(records));
|
|
39
|
+
if (selectedRecords.length === 0) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
return this.cloudflareClient.syncRecords(await this.withContent(selectedRecords, options));
|
|
11
43
|
}
|
|
12
|
-
|
|
13
|
-
|
|
44
|
+
async plan(first, second = {}) {
|
|
45
|
+
this.ensureOpen();
|
|
46
|
+
const { records, options } = getSyncArguments(first, second);
|
|
47
|
+
validateSyncOptions(options);
|
|
48
|
+
const selectedRecords = normalizeSyncRecords(this.resolveRecords(records));
|
|
49
|
+
if (selectedRecords.length === 0) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
return this.cloudflareClient.planRecords(await this.withContent(selectedRecords, options));
|
|
14
53
|
}
|
|
15
|
-
|
|
16
|
-
|
|
54
|
+
schedule(expression, options = {}) {
|
|
55
|
+
this.ensureOpen();
|
|
56
|
+
const job = new ScheduledSyncJob(this, expression, copyJobOptions(options));
|
|
57
|
+
this.jobs.add(job);
|
|
58
|
+
return job;
|
|
17
59
|
}
|
|
18
|
-
|
|
19
|
-
|
|
60
|
+
async watch(options = {}) {
|
|
61
|
+
this.ensureOpen();
|
|
62
|
+
const copiedOptions = copyWatchOptions(options);
|
|
63
|
+
const job = new WatchSyncJob(this, copiedOptions, this.dynamicFamilies(this.resolveRecords(copiedOptions.records), copiedOptions));
|
|
64
|
+
await job.start();
|
|
65
|
+
this.jobs.add(job);
|
|
66
|
+
return job;
|
|
20
67
|
}
|
|
21
|
-
|
|
22
|
-
|
|
68
|
+
async list(filter = {}) {
|
|
69
|
+
this.ensureOpen();
|
|
70
|
+
validateListFilter(filter);
|
|
71
|
+
if ('domains' in filter && filter.domains !== undefined) {
|
|
72
|
+
const domains = toArray(filter.domains);
|
|
73
|
+
validateDomains(domains);
|
|
74
|
+
const grouped = await this.cloudflareClient.getRecordDataForDomains(domains);
|
|
75
|
+
return filter.groupBy === 'domain' ? grouped : Object.values(grouped).flat();
|
|
76
|
+
}
|
|
77
|
+
return this.cloudflareClient.getRecordDataForRecords(this.resolveRecords(filter.records));
|
|
23
78
|
}
|
|
24
|
-
|
|
25
|
-
|
|
79
|
+
async remove(records) {
|
|
80
|
+
this.ensureOpen();
|
|
81
|
+
const selectedRecords = normalizeRecords(records, this.zone);
|
|
82
|
+
await Promise.all(selectedRecords.map((record) => this.cloudflareClient.removeRecordByNameAndType(record.name, record.type)));
|
|
26
83
|
}
|
|
27
|
-
|
|
28
|
-
|
|
84
|
+
async ip(family = 4) {
|
|
85
|
+
this.ensureOpen();
|
|
86
|
+
if (family !== 4 && family !== 6) {
|
|
87
|
+
throw invalidConfig('IP family must be 4 or 6.');
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
return this.resolveIp ? await this.resolveIp(family) : family === 4 ? await IPUtils.getIpv4() : await IPUtils.getIpv6();
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
throw new DdnsError('IP_UNAVAILABLE', `Could not determine the public IPv${family} address.`, error);
|
|
94
|
+
}
|
|
29
95
|
}
|
|
30
|
-
|
|
31
|
-
|
|
96
|
+
async close() {
|
|
97
|
+
if (this.closed) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
this.closed = true;
|
|
101
|
+
await Promise.all([...this.jobs].map(async (job) => job.stop()));
|
|
102
|
+
this.jobs.clear();
|
|
32
103
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
104
|
+
resolveRecords(records) {
|
|
105
|
+
const selectedRecords = records === undefined ? this.configuredRecords : normalizeRecords(records, this.zone);
|
|
106
|
+
if (selectedRecords === undefined) {
|
|
107
|
+
throw invalidConfig('Provide records when creating Ddns or when calling this method.');
|
|
108
|
+
}
|
|
109
|
+
return selectedRecords.map((record) => ({ ...record }));
|
|
38
110
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
111
|
+
ensureOpen() {
|
|
112
|
+
if (this.closed) {
|
|
113
|
+
throw new DdnsError('INVALID_CONFIG', 'This Ddns instance is closed.');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
dynamicFamilies(records, options) {
|
|
117
|
+
const families = new Set();
|
|
118
|
+
for (const record of records) {
|
|
119
|
+
if (record.content) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if ((record.type === undefined || record.type === 'A') && options.ipv4 === undefined) {
|
|
123
|
+
families.add(4);
|
|
124
|
+
}
|
|
125
|
+
if (record.type === 'AAAA' && options.ipv6 === undefined) {
|
|
126
|
+
families.add(6);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return [...families];
|
|
130
|
+
}
|
|
131
|
+
async withContent(records, options) {
|
|
132
|
+
for (const record of records) {
|
|
133
|
+
if (!record.content && record.type !== 'A' && record.type !== 'AAAA') {
|
|
134
|
+
throw invalidConfig(`Record "${record.name}" of type ${record.type} requires content.`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const families = this.dynamicFamilies(records, options);
|
|
138
|
+
const addresses = new Map();
|
|
139
|
+
await Promise.all(families.map(async (family) => {
|
|
140
|
+
addresses.set(family, await this.ip(family));
|
|
141
|
+
}));
|
|
142
|
+
const preparedRecords = records.map((record) => {
|
|
143
|
+
if (record.content) {
|
|
144
|
+
return record;
|
|
145
|
+
}
|
|
146
|
+
if (record.type === 'A') {
|
|
147
|
+
return { ...record, content: options.ipv4 ?? addresses.get(4) };
|
|
148
|
+
}
|
|
149
|
+
return { ...record, content: options.ipv6 ?? addresses.get(6) };
|
|
48
150
|
});
|
|
49
|
-
|
|
151
|
+
for (const record of preparedRecords) {
|
|
152
|
+
validatePreparedRecord(record);
|
|
153
|
+
}
|
|
154
|
+
return preparedRecords;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export function createDdns(options = {}) {
|
|
158
|
+
return new Ddns(options);
|
|
159
|
+
}
|
|
160
|
+
function normalizeRecords(records, zone) {
|
|
161
|
+
return toArray(records).map((input, index) => {
|
|
162
|
+
const record = typeof input === 'string' ? { name: input } : { ...input };
|
|
163
|
+
record.name = normalizeRecordName(record.name, zone);
|
|
164
|
+
validateRecord(record, `records[${index}]`);
|
|
165
|
+
return record;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
function normalizeZone(zone) {
|
|
169
|
+
if (zone === undefined) {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
if (typeof zone !== 'string' || zone.trim().length === 0) {
|
|
173
|
+
throw invalidConfig('zone must be a non-empty domain.');
|
|
174
|
+
}
|
|
175
|
+
return zone.toLowerCase();
|
|
176
|
+
}
|
|
177
|
+
function normalizeRecordName(name, zone) {
|
|
178
|
+
if (!zone) {
|
|
179
|
+
return name;
|
|
180
|
+
}
|
|
181
|
+
if (name === '@') {
|
|
182
|
+
return zone;
|
|
183
|
+
}
|
|
184
|
+
if (!name.includes('.')) {
|
|
185
|
+
return `${name}.${zone}`;
|
|
186
|
+
}
|
|
187
|
+
if (name === zone || name.endsWith(`.${zone}`)) {
|
|
188
|
+
return name;
|
|
189
|
+
}
|
|
190
|
+
throw invalidConfig(`Record "${name}" is outside configured zone "${zone}".`);
|
|
191
|
+
}
|
|
192
|
+
function normalizeSyncRecords(records) {
|
|
193
|
+
return records.map((record) => ({ ...record, type: record.type ?? 'A' }));
|
|
194
|
+
}
|
|
195
|
+
function getSyncArguments(first, second) {
|
|
196
|
+
if (first === undefined) {
|
|
197
|
+
return { options: second };
|
|
198
|
+
}
|
|
199
|
+
if (isSyncOptions(first)) {
|
|
200
|
+
return { options: first };
|
|
201
|
+
}
|
|
202
|
+
return { records: first, options: second };
|
|
203
|
+
}
|
|
204
|
+
function isSyncOptions(value) {
|
|
205
|
+
return !Array.isArray(value) && typeof value === 'object' && !('name' in value) && Object.keys(value).every((key) => key === 'ipv4' || key === 'ipv6');
|
|
206
|
+
}
|
|
207
|
+
function resolveAuth(auth) {
|
|
208
|
+
if (auth.token !== undefined || auth.key !== undefined || auth.email !== undefined) {
|
|
209
|
+
if (auth.token && !auth.key) {
|
|
210
|
+
return { token: auth.token };
|
|
211
|
+
}
|
|
212
|
+
if (auth.key?.startsWith('v1.0-') && !auth.token && !auth.email) {
|
|
213
|
+
return { key: auth.key };
|
|
214
|
+
}
|
|
215
|
+
if (auth.key && auth.email && !auth.token) {
|
|
216
|
+
return { email: auth.email, key: auth.key };
|
|
217
|
+
}
|
|
218
|
+
throw invalidConfig('Provide either a Cloudflare API token, an email and API key, or a User Service key.');
|
|
50
219
|
}
|
|
51
|
-
|
|
52
|
-
|
|
220
|
+
const token = process.env.CLOUDFLARE_API_TOKEN;
|
|
221
|
+
if (!token) {
|
|
222
|
+
throw invalidConfig('Provide a Cloudflare API token or set CLOUDFLARE_API_TOKEN.');
|
|
53
223
|
}
|
|
54
|
-
|
|
55
|
-
|
|
224
|
+
return { token };
|
|
225
|
+
}
|
|
226
|
+
function validateRecord(record, path) {
|
|
227
|
+
if (typeof record.name !== 'string' || record.name.trim().length === 0) {
|
|
228
|
+
throw invalidConfig(`${path}.name must be a non-empty hostname.`);
|
|
229
|
+
}
|
|
230
|
+
if (record.type !== undefined && !recordTypes.has(record.type)) {
|
|
231
|
+
throw invalidConfig(`${path}.type is not supported.`);
|
|
232
|
+
}
|
|
233
|
+
if (record.content !== undefined && (typeof record.content !== 'string' || record.content.trim().length === 0)) {
|
|
234
|
+
throw invalidConfig(`${path}.content must be a non-empty string when provided.`);
|
|
235
|
+
}
|
|
236
|
+
if (record.ttl !== undefined && (!Number.isInteger(record.ttl) || record.ttl < 1)) {
|
|
237
|
+
throw invalidConfig(`${path}.ttl must be a positive integer.`);
|
|
238
|
+
}
|
|
239
|
+
if (record.priority !== undefined && (!Number.isInteger(record.priority) || record.priority < 0)) {
|
|
240
|
+
throw invalidConfig(`${path}.priority must be a non-negative integer.`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function validateSyncOptions(options) {
|
|
244
|
+
if (options.ipv4 !== undefined && isIP(options.ipv4) !== 4) {
|
|
245
|
+
throw invalidConfig('ipv4 must be a valid IPv4 address.');
|
|
246
|
+
}
|
|
247
|
+
if (options.ipv6 !== undefined && isIP(options.ipv6) !== 6) {
|
|
248
|
+
throw invalidConfig('ipv6 must be a valid IPv6 address.');
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function validatePreparedRecord(record) {
|
|
252
|
+
if (!record.content) {
|
|
253
|
+
throw invalidConfig(`Record "${record.name}" is missing content.`);
|
|
254
|
+
}
|
|
255
|
+
if ((!record.type || record.type === 'A') && isIP(record.content) !== 4) {
|
|
256
|
+
throw invalidConfig(`Record "${record.name}" requires an IPv4 address.`);
|
|
257
|
+
}
|
|
258
|
+
if (record.type === 'AAAA' && isIP(record.content) !== 6) {
|
|
259
|
+
throw invalidConfig(`Record "${record.name}" requires an IPv6 address.`);
|
|
56
260
|
}
|
|
57
261
|
}
|
|
262
|
+
function validateDomains(domains) {
|
|
263
|
+
for (const [index, domain] of domains.entries()) {
|
|
264
|
+
if (typeof domain !== 'string' || domain.trim().length === 0) {
|
|
265
|
+
throw invalidConfig(`domains[${index}] must be a non-empty domain.`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function validateListFilter(filter) {
|
|
270
|
+
const hasRecords = 'records' in filter && filter.records !== undefined;
|
|
271
|
+
const hasDomains = 'domains' in filter && filter.domains !== undefined;
|
|
272
|
+
if (hasRecords && hasDomains) {
|
|
273
|
+
throw invalidConfig('Use either records or domains when listing records.');
|
|
274
|
+
}
|
|
275
|
+
if (filter.groupBy !== undefined && !hasDomains) {
|
|
276
|
+
throw invalidConfig('groupBy requires a domains filter.');
|
|
277
|
+
}
|
|
278
|
+
if (filter.groupBy !== undefined && filter.groupBy !== 'domain') {
|
|
279
|
+
throw invalidConfig('groupBy must be "domain".');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function copyJobOptions(options) {
|
|
283
|
+
validateSyncOptions(options);
|
|
284
|
+
return {
|
|
285
|
+
...options,
|
|
286
|
+
records: options.records === undefined ? undefined : normalizeRecords(options.records),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
function copyWatchOptions(options) {
|
|
290
|
+
const copied = copyJobOptions(options);
|
|
291
|
+
const intervalMs = options.intervalMs ?? defaultWatchIntervalMs;
|
|
292
|
+
if (!Number.isInteger(intervalMs) || intervalMs < 1 || intervalMs > maxTimerDelayMs) {
|
|
293
|
+
throw invalidConfig(`intervalMs must be an integer between 1 and ${maxTimerDelayMs}.`);
|
|
294
|
+
}
|
|
295
|
+
return { ...copied, intervalMs };
|
|
296
|
+
}
|
|
297
|
+
function toArray(value) {
|
|
298
|
+
return Array.isArray(value) ? value : [value];
|
|
299
|
+
}
|
|
300
|
+
function invalidConfig(message) {
|
|
301
|
+
return new DdnsError('INVALID_CONFIG', message);
|
|
302
|
+
}
|
|
58
303
|
export * from './types/index.js';
|
|
304
|
+
export default createDdns;
|
|
@@ -1,25 +1,31 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { Record } from '../types/index.js';
|
|
1
|
+
import { Auth, DomainRecordList, Record, RecordData } from '../types/index.js';
|
|
3
2
|
export default class CloudflareClient {
|
|
4
3
|
private cloudflare;
|
|
5
4
|
private zoneMap;
|
|
6
|
-
constructor(
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
constructor(auth: Auth);
|
|
6
|
+
syncRecords(records: Array<Record>): Promise<Array<RecordData>>;
|
|
7
|
+
planRecords(records: Array<Record>): Promise<Array<{
|
|
8
|
+
record: Record;
|
|
9
|
+
action: 'create' | 'update' | 'unchanged';
|
|
10
|
+
before?: RecordData;
|
|
11
|
+
after: Record;
|
|
12
|
+
}>>;
|
|
9
13
|
removeRecordByNameAndType(recordName: string, recordType?: string): Promise<void>;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
getRecordsByDomain(domain: string): Promise<Cloudflare.DNS.Records.DNSRecord[]>;
|
|
14
|
+
getRecordDataForRecords(records: Array<Record>): Promise<Array<RecordData>>;
|
|
15
|
+
getRecordDataForDomains(domains: Array<string>): Promise<DomainRecordList>;
|
|
16
|
+
getRecordDataForDomain(domain: string): Promise<Array<RecordData>>;
|
|
14
17
|
private createRecord;
|
|
15
18
|
private updateRecord;
|
|
19
|
+
private prepareRecord;
|
|
16
20
|
private updateZoneMap;
|
|
17
21
|
private getRecordIdByNameAndType;
|
|
18
22
|
private getZoneIdByRecordName;
|
|
19
23
|
private getRecordByNameAndType;
|
|
20
|
-
private
|
|
24
|
+
private isUnchanged;
|
|
21
25
|
private getRecordIdMapKey;
|
|
26
|
+
private getRecordsByDomain;
|
|
22
27
|
private getZoneIdByDomain;
|
|
23
28
|
private getDomainsFromRecords;
|
|
24
29
|
private getDomainByRecordName;
|
|
30
|
+
private isValidDomain;
|
|
25
31
|
}
|