12e073e8cf
mostly because code is being autogenerated by all the AI stuff using this prefix. it's also used in the stack trace.
383 lines
16 KiB
JavaScript
383 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
module.exports = exports = {
|
|
fqdn,
|
|
getName,
|
|
|
|
getDnsRecords,
|
|
upsertDnsRecords,
|
|
removeDnsRecords,
|
|
|
|
waitForDnsRecord,
|
|
waitForLocations,
|
|
|
|
validateHostname,
|
|
|
|
makeWildcard,
|
|
|
|
registerLocations,
|
|
unregisterLocations,
|
|
|
|
checkDnsRecords,
|
|
syncDnsRecords,
|
|
startSyncDnsRecords
|
|
};
|
|
|
|
const apps = require('./apps.js'),
|
|
assert = require('node:assert'),
|
|
BoxError = require('./boxerror.js'),
|
|
constants = require('./constants.js'),
|
|
dashboard = require('./dashboard.js'),
|
|
debug = require('debug')('box:dns'),
|
|
domains = require('./domains.js'),
|
|
ipaddr = require('./ipaddr.js'),
|
|
mail = require('./mail.js'),
|
|
mailServer = require('./mailserver.js'),
|
|
network = require('./network.js'),
|
|
promiseRetry = require('./promise-retry.js'),
|
|
safe = require('safetydance'),
|
|
tasks = require('./tasks.js'),
|
|
tld = require('tldjs');
|
|
|
|
// choose which subdomain backend we use for test purpose we use route53
|
|
function api(provider) {
|
|
assert.strictEqual(typeof provider, 'string');
|
|
|
|
switch (provider) {
|
|
case 'bunny': return require('./dns/bunny.js');
|
|
case 'cloudflare': return require('./dns/cloudflare.js');
|
|
case 'desec': return require('./dns/desec.js');
|
|
case 'dnsimple': return require('./dns/dnsimple.js');
|
|
case 'route53': return require('./dns/route53.js');
|
|
case 'gcdns': return require('./dns/gcdns.js');
|
|
case 'digitalocean': return require('./dns/digitalocean.js');
|
|
case 'gandi': return require('./dns/gandi.js');
|
|
case 'godaddy': return require('./dns/godaddy.js');
|
|
case 'inwx': return require('./dns/inwx.js');
|
|
case 'linode': return require('./dns/linode.js');
|
|
case 'vultr': return require('./dns/vultr.js');
|
|
case 'namecom': return require('./dns/namecom.js');
|
|
case 'namecheap': return require('./dns/namecheap.js');
|
|
case 'netcup': return require('./dns/netcup.js');
|
|
case 'hetzner': return require('./dns/hetzner.js');
|
|
case 'noop': return require('./dns/noop.js');
|
|
case 'manual': return require('./dns/manual.js');
|
|
case 'ovh': return require('./dns/ovh.js');
|
|
case 'porkbun': return require('./dns/porkbun.js');
|
|
case 'wildcard': return require('./dns/wildcard.js');
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
function fqdn(subdomain, domain) {
|
|
assert.strictEqual(typeof subdomain, 'string');
|
|
assert.strictEqual(typeof domain, 'string');
|
|
|
|
return subdomain + (subdomain ? '.' : '') + domain;
|
|
}
|
|
|
|
// Hostname validation comes from RFC 1123 (section 2.1)
|
|
// Domain name validation comes from RFC 2181 (Name syntax)
|
|
// https://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names
|
|
// We are validating the validity of the location-fqdn as host name (and not dns name)
|
|
function validateHostname(subdomain, domain) {
|
|
assert.strictEqual(typeof subdomain, 'string');
|
|
assert.strictEqual(typeof domain, 'string');
|
|
|
|
const hostname = fqdn(subdomain, domain);
|
|
|
|
// workaround https://github.com/oncletom/tld.js/issues/73
|
|
const tmp = hostname.replace('_', '-');
|
|
if (!tld.isValid(tmp)) return new BoxError(BoxError.BAD_FIELD, 'Hostname is not a valid domain name');
|
|
|
|
if (hostname.length > 253) return new BoxError(BoxError.BAD_FIELD, 'Hostname length exceeds 253 characters');
|
|
|
|
if (subdomain) {
|
|
// label validation
|
|
if (subdomain.split('.').some(function (p) { return p.length > 63 || p.length < 1; })) return new BoxError(BoxError.BAD_FIELD, 'Invalid subdomain length');
|
|
if (subdomain.match(/^[A-Za-z0-9-.]+$/) === null) return new BoxError(BoxError.BAD_FIELD, 'Subdomain can only contain alphanumeric, hyphen and dot');
|
|
if (/^[-.]/.test(subdomain)) return new BoxError(BoxError.BAD_FIELD, 'Subdomain cannot start or end with hyphen or dot');
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// returns the 'name' that needs to be inserted into zone
|
|
// eslint-disable-next-line no-unused-vars
|
|
function getName(domain, subdomain, type) {
|
|
const part = domain.domain.slice(0, -domain.zoneName.length - 1);
|
|
|
|
if (subdomain === '') return part;
|
|
|
|
return part ? `${subdomain}.${part}` : subdomain;
|
|
}
|
|
|
|
async function getDnsRecords(subdomain, domain, type) {
|
|
assert.strictEqual(typeof subdomain, 'string');
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof type, 'string');
|
|
|
|
const domainObject = await domains.get(domain);
|
|
return await api(domainObject.provider).get(domainObject, subdomain, type);
|
|
}
|
|
|
|
async function checkDnsRecords(subdomain, domain) {
|
|
assert.strictEqual(typeof subdomain, 'string');
|
|
assert.strictEqual(typeof domain, 'string');
|
|
|
|
const cnameRecords = await getDnsRecords(subdomain, domain, 'CNAME');
|
|
if (cnameRecords.length !== 0) return { needsOverwrite: true };
|
|
|
|
const ipv4 = await network.getIPv4();
|
|
if (ipv4) {
|
|
const ipv4Records = await getDnsRecords(subdomain, domain, 'A');
|
|
|
|
// if empty OR exactly one record with the ip, we don't need to overwrite
|
|
if (ipv4Records.length !== 0 && (ipv4Records.length !== 1 || ipv4Records[0] !== ipv4)) return { needsOverwrite: true };
|
|
}
|
|
|
|
const ipv6 = await network.getIPv6();
|
|
if (ipv6) {
|
|
const ipv6Records = await getDnsRecords(subdomain, domain, 'AAAA');
|
|
|
|
// if empty OR exactly one record with the ip, we don't need to overwrite
|
|
if (ipv6Records.length !== 0 && (ipv6Records.length !== 1 || !ipaddr.isEqual(ipv6Records[0], ipv6))) return { needsOverwrite: true };
|
|
}
|
|
|
|
return { needsOverwrite: false }; // one record exists and in sync
|
|
}
|
|
|
|
// note: for TXT records the values must be quoted
|
|
async function upsertDnsRecords(subdomain, domain, type, values) {
|
|
assert.strictEqual(typeof subdomain, 'string');
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof type, 'string');
|
|
assert(Array.isArray(values));
|
|
|
|
debug(`upsertDnsRecords: subdomain:${subdomain} domain:${domain} type:${type} values:${JSON.stringify(values)}`);
|
|
|
|
const domainObject = await domains.get(domain);
|
|
await api(domainObject.provider).upsert(domainObject, subdomain, type, values);
|
|
}
|
|
|
|
async function removeDnsRecords(subdomain, domain, type, values) {
|
|
assert.strictEqual(typeof subdomain, 'string');
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof type, 'string');
|
|
assert(Array.isArray(values));
|
|
|
|
debug(`removeDnsRecords: subdomain:${subdomain} domain:${domain} type:${type} values:${JSON.stringify(values)}`);
|
|
|
|
const domainObject = await domains.get(domain);
|
|
const [error] = await safe(api(domainObject.provider).del(domainObject, subdomain, type, values));
|
|
if (error && error.reason !== BoxError.NOT_FOUND) throw error; // this is never returned afaict
|
|
}
|
|
|
|
async function waitForDnsRecord(subdomain, domain, type, value, options) {
|
|
assert.strictEqual(typeof subdomain, 'string');
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert(type === 'A' || type === 'AAAA' || type === 'TXT');
|
|
assert.strictEqual(typeof value, 'string');
|
|
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
|
|
|
|
const domainObject = await domains.get(domain);
|
|
|
|
// linode DNS takes ~15mins
|
|
if (!options.interval) options.interval = domainObject.provider === 'linode' ? 20000 : 5000;
|
|
|
|
await api(domainObject.provider).wait(domainObject, subdomain, type, value, options);
|
|
}
|
|
|
|
async function waitForLocations(locations, progressCallback) {
|
|
assert(Array.isArray(locations));
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
if (constants.TEST) return;
|
|
|
|
const ipv4 = await network.getIPv4();
|
|
const ipv6 = await network.getIPv6();
|
|
|
|
for (const location of locations) {
|
|
const { subdomain, domain } = location;
|
|
progressCallback({ message: `Waiting for propagation of ${fqdn(subdomain, domain)}` });
|
|
|
|
if (ipv4) {
|
|
const [error] = await safe(waitForDnsRecord(subdomain, domain, 'A', ipv4, { times: 240 }));
|
|
if (error) throw new BoxError(BoxError.DNS_ERROR, `DNS A Record of ${fqdn(subdomain, domain)} is not synced yet: ${error.message}`);
|
|
}
|
|
|
|
if (ipv6) {
|
|
const [error] = await safe(waitForDnsRecord(subdomain, domain, 'AAAA', ipv6, { times: 240 }));
|
|
if (error) throw new BoxError(BoxError.DNS_ERROR, `DNS AAAA Record of ${fqdn(subdomain, domain)} is not synced yet: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function makeWildcard(fqdn) {
|
|
assert.strictEqual(typeof fqdn, 'string');
|
|
|
|
// if the fqdn is like *.example.com, this function will do nothing
|
|
const parts = fqdn.split('.');
|
|
parts[0] = '*';
|
|
return parts.join('.');
|
|
}
|
|
|
|
async function registerLocation(location, options, recordType, recordValue) {
|
|
assert.strictEqual(typeof location, 'object');
|
|
assert.strictEqual(typeof options, 'object');
|
|
assert.strictEqual(typeof recordType, 'string');
|
|
assert.strictEqual(typeof recordValue, 'string');
|
|
|
|
const overwriteDns = options.overwriteDns || false;
|
|
|
|
// get the current record before updating it
|
|
const [getError, values] = await safe(getDnsRecords(location.subdomain, location.domain, recordType));
|
|
if (getError) {
|
|
const retryable = getError.reason !== BoxError.ACCESS_DENIED && getError.reason !== BoxError.NOT_FOUND; // NOT_FOUND is when zone is not found
|
|
debug(`registerLocation: Get error. retryable: ${retryable}. ${getError.message}`);
|
|
throw new BoxError(getError.reason, getError.message, { domain: location, retryable });
|
|
}
|
|
|
|
if (values.length === 1 && values[0] === recordValue) return; // up-to-date
|
|
|
|
// refuse to update any existing DNS record for custom domains that we did not create
|
|
if (values.length !== 0 && !overwriteDns) throw new BoxError(BoxError.ALREADY_EXISTS, `DNS ${recordType} record already exists`, { domain: location, retryable: false });
|
|
|
|
const [upsertError] = await safe(upsertDnsRecords(location.subdomain, location.domain, recordType, [ recordValue ]));
|
|
if (upsertError) {
|
|
const retryable = upsertError.reason === BoxError.BUSY || upsertError.reason === BoxError.EXTERNAL_ERROR;
|
|
debug(`registerLocation: Upsert error. retryable: ${retryable}. ${upsertError.message}`);
|
|
throw new BoxError(BoxError.EXTERNAL_ERROR, upsertError.message, { domain: location, retryable });
|
|
}
|
|
}
|
|
|
|
async function registerLocations(locations, options, progressCallback) {
|
|
assert(Array.isArray(locations));
|
|
assert.strictEqual(typeof options, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
debug(`registerLocations: Will register ${JSON.stringify(locations)} with options ${JSON.stringify(options)}`);
|
|
|
|
const ipv4 = await network.getIPv4();
|
|
const ipv6 = await network.getIPv6();
|
|
|
|
for (const location of locations) {
|
|
progressCallback({ message: `Registering location ${fqdn(location.subdomain, location.domain)}` });
|
|
|
|
await promiseRetry({ times: 200, interval: 5000, debug, retry: (error) => error.retryable }, async function () {
|
|
// cname records cannot co-exist with other records
|
|
const [getError, values] = await safe(getDnsRecords(location.subdomain, location.domain, 'CNAME'));
|
|
if (!getError && values.length === 1) {
|
|
if (!options.overwriteDns) throw new BoxError(BoxError.ALREADY_EXISTS, 'DNS CNAME record already exists', { domain: location, retryable: false });
|
|
debug(`registerLocations: removing CNAME record of ${fqdn(location.subdomain, location.domain)}`);
|
|
await removeDnsRecords(location.subdomain, location.domain, 'CNAME', values);
|
|
}
|
|
|
|
if (ipv4) {
|
|
await registerLocation(location, options, 'A', ipv4);
|
|
} else {
|
|
const [error, values] = await safe(getDnsRecords(location.subdomain, location.domain, 'A'));
|
|
if (!error && values.length) await safe(removeDnsRecords(location.subdomain, location.domain, 'A', values), { debug });
|
|
}
|
|
|
|
if (ipv6) {
|
|
await registerLocation(location, options, 'AAAA', ipv6);
|
|
} else {
|
|
const [error, values] = await safe(getDnsRecords(location.subdomain, location.domain, 'AAAA'));
|
|
if (!error && values.length) await safe(removeDnsRecords(location.subdomain, location.domain, 'AAAA', values), { debug });
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
async function unregisterLocation(location, recordType, recordValue) {
|
|
assert.strictEqual(typeof location, 'object');
|
|
assert.strictEqual(typeof recordType, 'string');
|
|
assert.strictEqual(typeof recordValue, 'string');
|
|
|
|
const [error] = await safe(removeDnsRecords(location.subdomain, location.domain, recordType, [ recordValue ]));
|
|
if (!error || error.reason === BoxError.NOT_FOUND) return;
|
|
|
|
const retryable = error.reason === BoxError.BUSY || error.reason === BoxError.EXTERNAL_ERROR;
|
|
debug(`unregisterLocation: Error unregistering location ${recordType}. retryable: ${retryable}. ${error.message}`);
|
|
|
|
throw new BoxError(BoxError.EXTERNAL_ERROR, error.message, { domain: location, retryable });
|
|
}
|
|
|
|
async function unregisterLocations(locations, progressCallback) {
|
|
assert(Array.isArray(locations));
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
const ipv4 = await network.getIPv4();
|
|
const ipv6 = await network.getIPv6();
|
|
|
|
for (const location of locations) {
|
|
progressCallback({ message: `Unregistering location: ${location.subdomain ? (location.subdomain + '.') : ''}${location.domain}` });
|
|
|
|
await promiseRetry({ times: 30, interval: 5000, debug, retry: (error) => error.retryable }, async function () {
|
|
if (ipv4) await unregisterLocation(location, 'A', ipv4);
|
|
if (ipv6) await unregisterLocation(location, 'AAAA', ipv6);
|
|
});
|
|
}
|
|
}
|
|
|
|
async function syncDnsRecords(options, progressCallback) {
|
|
assert.strictEqual(typeof options, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
const errors = [];
|
|
|
|
if (options.domain && options.type === 'mail') {
|
|
const [error] = await safe(mail.setDnsRecords(options.domain));
|
|
if (error) errors.push({ domain: options.domain, error: error.message });
|
|
return errors;
|
|
}
|
|
|
|
let allDomains = await domains.list();
|
|
|
|
if (options.domain) allDomains = allDomains.filter(d => d.domain === options.domain);
|
|
|
|
const { domain:mailDomain, fqdn:mailFqdn, subdomain:mailSubdomain } = await mailServer.getLocation();
|
|
const dashboardLocation = await dashboard.getLocation();
|
|
|
|
const allApps = await apps.list();
|
|
let progress = 1;
|
|
|
|
// we sync by domain only to get some nice progress
|
|
for (const domain of allDomains) {
|
|
progressCallback({ percent: progress, message: `Updating DNS of ${domain.domain}`});
|
|
progress += Math.round(100/(1+allDomains.length));
|
|
|
|
let locations = [];
|
|
if (domain.domain === dashboardLocation.domain) locations.push({ subdomain: dashboardLocation.subdomain, domain: dashboardLocation.domain });
|
|
if (domain.domain === mailDomain && mailFqdn !== dashboardLocation.fqdn) locations.push({ subdomain: mailSubdomain, domain: mailDomain });
|
|
|
|
for (const app of allApps) {
|
|
const appLocations = [{ subdomain: app.subdomain, domain: app.domain }]
|
|
.concat(app.secondaryDomains)
|
|
.concat(app.redirectDomains)
|
|
.concat(app.aliasDomains);
|
|
locations = locations.concat(appLocations.filter(al => al.domain === domain.domain));
|
|
}
|
|
|
|
try {
|
|
await registerLocations(locations, { overwriteDns: true }, progressCallback);
|
|
progressCallback({ message: `Updating mail DNS of ${domain.domain}`});
|
|
await mail.setDnsRecords(domain.domain);
|
|
} catch (error) {
|
|
errors.push({ domain: domain.domain, message: error.message });
|
|
}
|
|
}
|
|
|
|
return { errors };
|
|
}
|
|
|
|
|
|
async function startSyncDnsRecords(options) {
|
|
assert.strictEqual(typeof options, 'object');
|
|
|
|
const taskId = await tasks.add(tasks.TASK_SYNC_DNS_RECORDS, [ options ]);
|
|
safe(tasks.startTask(taskId, {}), { debug }); // background
|
|
return taskId;
|
|
}
|