12e073e8cf
mostly because code is being autogenerated by all the AI stuff using this prefix. it's also used in the stack trace.
294 lines
13 KiB
JavaScript
294 lines
13 KiB
JavaScript
'use strict';
|
|
|
|
exports = module.exports = {
|
|
removePrivateFields,
|
|
injectPrivateFields,
|
|
upsert,
|
|
get,
|
|
del,
|
|
wait,
|
|
verifyDomainConfig
|
|
};
|
|
|
|
const assert = require('node:assert'),
|
|
BoxError = require('../boxerror.js'),
|
|
constants = require('../constants.js'),
|
|
debug = require('debug')('box:dns/cloudflare'),
|
|
dig = require('../dig.js'),
|
|
dns = require('../dns.js'),
|
|
safe = require('safetydance'),
|
|
superagent = require('@cloudron/superagent'),
|
|
waitForDns = require('./waitfordns.js'),
|
|
_ = require('../underscore.js');
|
|
|
|
// we are using latest v4 stable API https://api.cloudflare.com/#getting-started-endpoints
|
|
const CLOUDFLARE_ENDPOINT = 'https://api.cloudflare.com/client/v4';
|
|
|
|
function removePrivateFields(domainObject) {
|
|
domainObject.config.token = constants.SECRET_PLACEHOLDER;
|
|
return domainObject;
|
|
}
|
|
|
|
function injectPrivateFields(newConfig, currentConfig) {
|
|
if (newConfig.token === constants.SECRET_PLACEHOLDER) newConfig.token = currentConfig.token;
|
|
}
|
|
|
|
function translateResponseError(response) {
|
|
assert.strictEqual(typeof response, 'object');
|
|
|
|
if (response.status === 404) return new BoxError(BoxError.NOT_FOUND, `[${response.status}] ${response.text}`);
|
|
if (response.status === 422) return new BoxError(BoxError.BAD_FIELD, response.body.message);
|
|
if (response.status === 400 || response.status === 401 || response.status === 403) {
|
|
let message = 'Unknown error';
|
|
if (typeof response.body.error === 'string') {
|
|
message = `[${response.status}] ${response.body.error}`;
|
|
} else if (Array.isArray(response.body.errors) && response.body.errors.length > 0) {
|
|
const error = response.body.errors[0];
|
|
message = `[${response.status}] ${error.message} code:${error.code}`;
|
|
}
|
|
return new BoxError(BoxError.ACCESS_DENIED, message);
|
|
}
|
|
|
|
return new BoxError(BoxError.EXTERNAL_ERROR, `${response.status} ${response.text}`);
|
|
}
|
|
|
|
function createRequest(method, url, domainConfig) {
|
|
assert.strictEqual(typeof method, 'string');
|
|
assert.strictEqual(typeof url, 'string');
|
|
assert.strictEqual(typeof domainConfig, 'object');
|
|
|
|
const request = superagent.request(method, url).timeout(30 * 1000).ok(() => true);
|
|
|
|
if (domainConfig.tokenType === 'GlobalApiKey') {
|
|
request.set('X-Auth-Key', domainConfig.token).set('X-Auth-Email', domainConfig.email);
|
|
} else {
|
|
request.set('Authorization', 'Bearer ' + domainConfig.token);
|
|
}
|
|
|
|
return request;
|
|
}
|
|
|
|
async function getZoneByName(domainConfig, zoneName) {
|
|
assert.strictEqual(typeof domainConfig, 'object');
|
|
assert.strictEqual(typeof zoneName, 'string');
|
|
|
|
const [error, response] = await safe(createRequest('GET', `${CLOUDFLARE_ENDPOINT}/zones?name=${zoneName}&status=active`, domainConfig));
|
|
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
|
|
if (response.status !== 200 || response.body.success !== true) throw translateResponseError(response);
|
|
if (!response.body.result || !response.body.result.length) throw new BoxError(BoxError.NOT_FOUND, `${response.status} ${response.text}`);
|
|
|
|
// check 'id' and 'name_servers' exist in the response
|
|
const zone = response.body.result[0];
|
|
const zoneId = safe.query(zone, 'id');
|
|
if (typeof zoneId !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, `No zone id in response: ${response.status} ${response.text}`);
|
|
const name_servers = safe.query(zone, 'name_servers');
|
|
if (!Array.isArray(name_servers)) throw new BoxError(BoxError.EXTERNAL_ERROR, `name_servers is not an array: ${response.status} ${response.text}`);
|
|
|
|
return zone;
|
|
}
|
|
|
|
// gets records filtered by zone, type and fqdn
|
|
async function getDnsRecords(domainConfig, zoneId, fqdn, type) {
|
|
assert.strictEqual(typeof domainConfig, 'object');
|
|
assert.strictEqual(typeof zoneId, 'string');
|
|
assert.strictEqual(typeof fqdn, 'string');
|
|
assert.strictEqual(typeof type, 'string');
|
|
|
|
const [error, response] = await safe(createRequest('GET', `${CLOUDFLARE_ENDPOINT}/zones/${zoneId}/dns_records`, domainConfig)
|
|
.query({ type: type, name: fqdn }));
|
|
|
|
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
|
|
if (response.status !== 200 || response.body.success !== true) throw translateResponseError(response);
|
|
|
|
const result = response.body.result;
|
|
if (result === null) return []; // sometime about now, cloudflare API has started returning null instead of empty array
|
|
if (!Array.isArray(result)) throw new BoxError(BoxError.EXTERNAL_ERROR, `result is not an array when getting records: ${response.status} ${response.text}`);
|
|
return result;
|
|
}
|
|
|
|
async function upsert(domainObject, location, type, values) {
|
|
assert.strictEqual(typeof domainObject, 'object');
|
|
assert.strictEqual(typeof location, 'string');
|
|
assert.strictEqual(typeof type, 'string');
|
|
assert(Array.isArray(values));
|
|
|
|
const domainConfig = domainObject.config,
|
|
zoneName = domainObject.zoneName,
|
|
fqdn = dns.fqdn(location, domainObject.domain);
|
|
|
|
debug('upsert: %s for zone %s of type %s with values %j', fqdn, zoneName, type, values);
|
|
|
|
const zone = await getZoneByName(domainConfig, zoneName);
|
|
const zoneId = zone.id;
|
|
|
|
const records = await getDnsRecords(domainConfig, zoneId, fqdn, type);
|
|
|
|
let i = 0; // // used to track available records to update instead of create
|
|
|
|
for (let value of values) {
|
|
let priority = null;
|
|
|
|
if (type === 'MX') {
|
|
priority = parseInt(value.split(' ')[0], 10);
|
|
value = value.split(' ')[1];
|
|
}
|
|
|
|
const data = {
|
|
type: type,
|
|
name: fqdn,
|
|
content: value,
|
|
priority: priority,
|
|
proxied: false,
|
|
ttl: 120 // 1 means "automatic" (meaning 300ms) and 120 is the lowest supported
|
|
};
|
|
|
|
if (i >= records.length) { // create a new record
|
|
if (type === 'A' || type === 'AAAA' || type === 'CNAME') {
|
|
data.proxied = !!domainConfig.defaultProxyStatus; // note that cloudflare will error if proxied is set for wrong record type or IP. only set at install time
|
|
}
|
|
|
|
debug(`upsert: Adding new record fqdn: ${fqdn}, zoneName: ${zoneName} proxied: ${data.proxied}`);
|
|
|
|
const [error, response] = await safe(createRequest('POST', `${CLOUDFLARE_ENDPOINT}/zones/${zoneId}/dns_records`, domainConfig)
|
|
.send(data));
|
|
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
|
|
if (response.status !== 200 || response.body.success !== true) throw translateResponseError(response);
|
|
} else { // replace existing record
|
|
data.proxied = records[i].proxied; // preserve proxied parameter
|
|
|
|
debug(`upsert: Updating existing record fqdn: ${fqdn}, zoneName: ${zoneName} proxied: ${data.proxied}`);
|
|
|
|
const [error, response] = await safe(createRequest('PUT', `${CLOUDFLARE_ENDPOINT}/zones/${zoneId}/dns_records/${records[i].id}`, domainConfig)
|
|
.send(data));
|
|
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
|
|
if (response.status !== 200 || response.body.success !== true) throw translateResponseError(response);
|
|
++i; // increment, as we have consumed the record
|
|
}
|
|
}
|
|
|
|
for (let j = values.length + 1; j < records.length; j++) {
|
|
|
|
const [error] = await safe(createRequest('DELETE', `${CLOUDFLARE_ENDPOINT}/zones/${zoneId}/dns_records/${records[j].id}`, domainConfig));
|
|
if (error) debug(`upsert: error removing record ${records[j].id}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async function get(domainObject, location, type) {
|
|
assert.strictEqual(typeof domainObject, 'object');
|
|
assert.strictEqual(typeof location, 'string');
|
|
assert.strictEqual(typeof type, 'string');
|
|
|
|
const domainConfig = domainObject.config,
|
|
zoneName = domainObject.zoneName,
|
|
fqdn = dns.fqdn(location, domainObject.domain);
|
|
|
|
const zone = await getZoneByName(domainConfig, zoneName);
|
|
const result = await getDnsRecords(domainConfig, zone.id, fqdn, type);
|
|
const tmp = result.map(function (record) { return record.content; });
|
|
return tmp;
|
|
}
|
|
|
|
async function del(domainObject, location, type, values) {
|
|
assert.strictEqual(typeof domainObject, 'object');
|
|
assert.strictEqual(typeof location, 'string');
|
|
assert.strictEqual(typeof type, 'string');
|
|
assert(Array.isArray(values));
|
|
|
|
const domainConfig = domainObject.config,
|
|
zoneName = domainObject.zoneName,
|
|
fqdn = dns.fqdn(location, domainObject.domain);
|
|
|
|
const zone = await getZoneByName(domainConfig, zoneName);
|
|
|
|
const result = await getDnsRecords(domainConfig, zone.id, fqdn, type);
|
|
if (result.length === 0) return;
|
|
|
|
const tmp = result.filter(function (record) { return values.some(function (value) { return value === record.content; }); });
|
|
debug('del: %j', tmp);
|
|
|
|
if (tmp.length === 0) return;
|
|
|
|
for (const r of tmp) {
|
|
const [error, response] = await safe(createRequest('DELETE', `${CLOUDFLARE_ENDPOINT}/zones/${zone.id}/dns_records/${r.id}`, domainConfig));
|
|
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
|
|
if (response.status !== 200 || response.body.success !== true) throw translateResponseError(response);
|
|
}
|
|
}
|
|
|
|
async function wait(domainObject, subdomain, type, value, options) {
|
|
assert.strictEqual(typeof domainObject, 'object');
|
|
assert.strictEqual(typeof subdomain, 'string');
|
|
assert.strictEqual(typeof type, 'string');
|
|
assert.strictEqual(typeof value, 'string');
|
|
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
|
|
|
|
const domainConfig = domainObject.config,
|
|
zoneName = domainObject.zoneName,
|
|
fqdn = dns.fqdn(subdomain, domainObject.domain);
|
|
|
|
debug('wait: %s for zone %s of type %s', fqdn, zoneName, type);
|
|
|
|
const zone = await getZoneByName(domainConfig, zoneName);
|
|
const zoneId = zone.id;
|
|
|
|
const dnsRecords = await getDnsRecords(domainConfig, zoneId, fqdn, type);
|
|
if (dnsRecords.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'Domain not found');
|
|
|
|
if (!dnsRecords[0].proxied) return await waitForDns(fqdn, domainObject.zoneName, type, value, options);
|
|
|
|
debug('wait: skipping wait of proxied domain');
|
|
|
|
// maybe we can check for dns to be cloudflare IPs? https://api.cloudflare.com/#cloudflare-ips-cloudflare-ip-details
|
|
}
|
|
|
|
async function verifyDomainConfig(domainObject) {
|
|
assert.strictEqual(typeof domainObject, 'object');
|
|
|
|
const domainConfig = domainObject.config,
|
|
zoneName = domainObject.zoneName;
|
|
|
|
// token can be api token or global api key
|
|
if (!domainConfig.token || typeof domainConfig.token !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'token must be a non-empty string');
|
|
if (domainConfig.tokenType !== 'GlobalApiKey' && domainConfig.tokenType !== 'ApiToken') throw new BoxError(BoxError.BAD_FIELD, 'tokenType is required');
|
|
if ('customNameservers' in domainConfig && typeof domainConfig.customNameservers !== 'boolean') throw new BoxError(BoxError.BAD_FIELD, 'customNameservers must be a boolean');
|
|
|
|
if (domainConfig.tokenType === 'GlobalApiKey') {
|
|
if (typeof domainConfig.email !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'email must be a non-empty string');
|
|
}
|
|
|
|
if (typeof domainConfig.defaultProxyStatus !== 'boolean') throw new BoxError(BoxError.BAD_FIELD, 'defaultProxyStatus must be a boolean');
|
|
|
|
const ip = '127.0.0.1';
|
|
|
|
const sanitizedConfig = {
|
|
token: domainConfig.token,
|
|
tokenType: domainConfig.tokenType,
|
|
email: domainConfig.email || null,
|
|
defaultProxyStatus: domainConfig.defaultProxyStatus,
|
|
customNameservers: !!domainConfig.customNameservers
|
|
};
|
|
|
|
if (constants.TEST) return sanitizedConfig; // this shouldn't be here
|
|
|
|
const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 }));
|
|
if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain');
|
|
if (error || !nameservers) throw new BoxError(BoxError.BAD_FIELD, error ? error.message : 'Unable to get nameservers');
|
|
|
|
const zone = await getZoneByName(domainConfig, zoneName);
|
|
|
|
if (!_.isEqual(zone.name_servers.sort(), nameservers.sort())) {
|
|
debug('verifyDomainConfig: %j and %j do not match', nameservers, zone.name_servers);
|
|
if (!domainConfig.customNameservers) throw new BoxError(BoxError.BAD_FIELD, 'Domain nameservers are not set to Cloudflare');
|
|
}
|
|
|
|
const location = 'cloudrontestdns';
|
|
|
|
await upsert(domainObject, location, 'A', [ ip ]);
|
|
debug('verifyDomainConfig: Test A record added');
|
|
|
|
await del(domainObject, location, 'A', [ ip ]);
|
|
debug('verifyDomainConfig: Test A record removed again');
|
|
|
|
return sanitizedConfig;
|
|
}
|