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/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 Cron from './lib/cron.js';
3
- import ipUtils from './lib/ip-utils.js';
4
- export default class CloudflareDDNSSync {
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
- constructor(auth) {
7
- this.cloudflareClient = new CloudflareClient(auth);
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
- getIp() {
10
- return ipUtils.getIpv4();
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
- getIpv6() {
13
- return ipUtils.getIpv6();
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
- getRecordDataForDomain(domain) {
16
- return this.cloudflareClient.getRecordsByDomain(domain);
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
- getRecordDataForDomains(domains) {
19
- return this.cloudflareClient.getRecordsByDomains(domains);
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
- getRecordDataForRecord(record) {
22
- return this.cloudflareClient.getRecordDataForRecord(record);
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
- getRecordDataForRecords(records) {
25
- return this.cloudflareClient.getRecordDataForRecords(records);
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
- removeRecord(recordName, recordType) {
28
- return this.cloudflareClient.removeRecordByNameAndType(recordName, recordType);
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
- stopSyncOnIpChange(changeListenerId) {
31
- ipUtils.removeIpChangeListener(changeListenerId);
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
- syncByCronTime(cronExpression, records, callback, ip) {
34
- return Cron.createCronJob(cronExpression, async () => {
35
- const result = await this.syncRecords(records, ip);
36
- callback(result);
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
- async syncOnIpChange(records, callback) {
40
- const changeListenerId = await ipUtils.addIpChangeListener(async (ip) => {
41
- const result = await this.syncRecords(records, ip);
42
- callback(result);
43
- });
44
- // Sync records to make sure the current ip is already set.
45
- const currentIp = await ipUtils.getIpv4();
46
- this.syncRecords(records, currentIp).then((syncedRecords) => {
47
- callback(syncedRecords);
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
- return changeListenerId;
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
- syncRecord(record, ip) {
52
- return this.cloudflareClient.syncRecord(record, ip);
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
- syncRecords(records, ip) {
55
- return this.cloudflareClient.syncRecords(records, ip);
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 Cloudflare, { ClientOptions as CloudflareOptions } from 'cloudflare';
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(cloudflareOptions: CloudflareOptions);
7
- syncRecord(record: Record, ip?: string): Promise<Cloudflare.DNS.Records.DNSRecord>;
8
- syncRecords(records: Array<Record>, ip?: string): Promise<Cloudflare.DNS.Records.DNSRecord[]>;
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
- getRecordDataForRecord(record: Record): Promise<Cloudflare.DNS.Records.DNSRecord>;
11
- getRecordDataForRecords(records: Array<Record>): Promise<any[]>;
12
- getRecordsByDomains(domains: Array<string>): Promise<{}>;
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 getRecordIdsForRecords;
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
  }