This reverts commit fdcc5d68a2.
Unfortunately, this requires us to move exports to the bottom.
This in turn causes circular dep issues and also access of
exports.GLOBAL_VAR in the global context
373 lines
15 KiB
JavaScript
373 lines
15 KiB
JavaScript
'use strict';
|
|
|
|
exports = module.exports = {
|
|
add,
|
|
get,
|
|
list,
|
|
setConfig,
|
|
setWellKnown,
|
|
del,
|
|
clear,
|
|
|
|
getDomainObjectMap,
|
|
|
|
removePrivateFields,
|
|
removeRestrictedFields,
|
|
|
|
checkConfigs
|
|
};
|
|
|
|
const assert = require('node:assert'),
|
|
BoxError = require('./boxerror.js'),
|
|
constants = require('./constants.js'),
|
|
crypto = require('node:crypto'),
|
|
dashboard = require('./dashboard.js'),
|
|
database = require('./database.js'),
|
|
debug = require('debug')('box:domains'),
|
|
eventlog = require('./eventlog.js'),
|
|
mailServer = require('./mailserver.js'),
|
|
notifications = require('./notifications.js'),
|
|
reverseProxy = require('./reverseproxy.js'),
|
|
safe = require('safetydance'),
|
|
tld = require('tldjs'),
|
|
_ = require('./underscore.js');
|
|
|
|
const DOMAINS_FIELDS = [ 'domain', 'zoneName', 'provider', 'configJson', 'tlsConfigJson', 'wellKnownJson', 'fallbackCertificateJson' ].join(',');
|
|
|
|
function postProcess(data) {
|
|
data.config = safe.JSON.parse(data.configJson);
|
|
delete data.configJson;
|
|
|
|
data.tlsConfig = safe.JSON.parse(data.tlsConfigJson);
|
|
delete data.tlsConfigJson;
|
|
|
|
data.wellKnown = safe.JSON.parse(data.wellKnownJson);
|
|
delete data.wellKnownJson;
|
|
|
|
data.fallbackCertificate = safe.JSON.parse(data.fallbackCertificateJson);
|
|
delete data.fallbackCertificateJson;
|
|
|
|
return data;
|
|
}
|
|
|
|
// 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 'hetzner': return require('./dns/hetzner.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 'noop': return require('./dns/noop.js');
|
|
case 'ovh': return require('./dns/ovh.js');
|
|
case 'manual': return require('./dns/manual.js');
|
|
case 'porkbun': return require('./dns/porkbun.js');
|
|
case 'wildcard': return require('./dns/wildcard.js');
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
async function verifyDomainConfig(domainConfig, domain, zoneName, provider) {
|
|
assert(domainConfig && typeof domainConfig === 'object'); // the dns config to test with
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof zoneName, 'string');
|
|
assert.strictEqual(typeof provider, 'string');
|
|
|
|
const backend = api(provider);
|
|
if (!backend) throw new BoxError(BoxError.BAD_FIELD, 'Invalid provider');
|
|
|
|
const domainObject = { config: domainConfig, domain, zoneName };
|
|
const [error, sanitizedConfig] = await safe(api(provider).verifyDomainConfig(domainObject));
|
|
if (error && error.reason === BoxError.ACCESS_DENIED) throw new BoxError(BoxError.BAD_FIELD, `Access denied: ${error.message}`);
|
|
if (error && error.reason === BoxError.NOT_FOUND) throw new BoxError(BoxError.BAD_FIELD, `Zone not found: ${error.message}`);
|
|
if (error && error.reason === BoxError.EXTERNAL_ERROR) throw new BoxError(BoxError.BAD_FIELD, `Configuration error: ${error.message}`);
|
|
if (error) throw error;
|
|
|
|
return sanitizedConfig;
|
|
}
|
|
|
|
function validateTlsConfig(tlsConfig, dnsProvider) {
|
|
assert.strictEqual(typeof tlsConfig, 'object');
|
|
assert.strictEqual(typeof dnsProvider, 'string');
|
|
|
|
switch (tlsConfig.provider) {
|
|
case 'letsencrypt-prod':
|
|
case 'letsencrypt-staging':
|
|
case 'fallback':
|
|
break;
|
|
default:
|
|
return new BoxError(BoxError.BAD_FIELD, 'tlsConfig.provider must be fallback, letsencrypt-prod/staging');
|
|
}
|
|
|
|
if (tlsConfig.wildcard) {
|
|
if (!tlsConfig.provider.startsWith('letsencrypt')) return new BoxError(BoxError.BAD_FIELD, 'wildcard can only be set with letsencrypt');
|
|
if (dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') return new BoxError(BoxError.BAD_FIELD, 'wildcard cert requires a programmable DNS backend');
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function validateWellKnown(wellKnown) {
|
|
assert.strictEqual(typeof wellKnown, 'object');
|
|
|
|
return null;
|
|
}
|
|
|
|
async function add(domain, data, auditSource) {
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof data.zoneName, 'string');
|
|
assert.strictEqual(typeof data.provider, 'string');
|
|
assert.strictEqual(typeof data.config, 'object');
|
|
assert.strictEqual(typeof data.fallbackCertificate, 'object');
|
|
assert.strictEqual(typeof data.tlsConfig, 'object');
|
|
|
|
const { provider, config, tlsConfig } = data;
|
|
|
|
if (!tld.isValid(domain)) throw new BoxError(BoxError.BAD_FIELD, 'Invalid domain');
|
|
if (domain.endsWith('.')) throw new BoxError(BoxError.BAD_FIELD, 'Invalid domain');
|
|
|
|
let zoneName;
|
|
if (data.zoneName) {
|
|
if (!tld.isValid(data.zoneName)) throw new BoxError(BoxError.BAD_FIELD, 'Invalid zoneName');
|
|
if (data.zoneName.endsWith('.')) throw new BoxError(BoxError.BAD_FIELD, 'Invalid zoneName');
|
|
zoneName = data.zoneName;
|
|
} else {
|
|
zoneName = tld.getDomain(domain) || domain;
|
|
}
|
|
|
|
let fallbackCertificate;
|
|
if (data.fallbackCertificate) {
|
|
await reverseProxy.validateCertificate('test', domain, data.fallbackCertificate);
|
|
fallbackCertificate = data.fallbackCertificate;
|
|
} else {
|
|
fallbackCertificate = await reverseProxy.generateFallbackCertificate(domain);
|
|
}
|
|
|
|
let error = validateTlsConfig(tlsConfig, provider);
|
|
if (error) throw error;
|
|
|
|
const dkimKey = await mailServer.generateDkimKey();
|
|
|
|
let dkimSelector = data.dkimSelector;
|
|
if (!data.dkimSelector) {
|
|
const { domain:dashboardDomain } = await dashboard.getLocation();
|
|
// create a unique suffix. this lets one add this domain can be added in another cloudron instance and not have their dkim selector conflict
|
|
const suffix = crypto.createHash('sha256').update(dashboardDomain).digest('hex').substr(0, 6);
|
|
dkimSelector = `cloudron-${suffix}`;
|
|
}
|
|
|
|
const sanitizedConfig = await verifyDomainConfig(config, domain, zoneName, provider);
|
|
|
|
const queries = [
|
|
{ query: 'INSERT INTO domains (domain, zoneName, provider, configJson, tlsConfigJson, fallbackCertificateJson) VALUES (?, ?, ?, ?, ?, ?)',
|
|
args: [ domain, zoneName, provider, JSON.stringify(sanitizedConfig), JSON.stringify(tlsConfig), JSON.stringify(fallbackCertificate) ] },
|
|
{ query: 'INSERT INTO mail (domain, dkimKeyJson, dkimSelector) VALUES (?, ?, ?)', args: [ domain, JSON.stringify(dkimKey), dkimSelector || 'cloudron' ] },
|
|
];
|
|
|
|
[error] = await safe(database.transaction(queries));
|
|
if (error && error.sqlCode === 'ER_DUP_ENTRY') throw new BoxError(BoxError.ALREADY_EXISTS, 'Domain already exists');
|
|
if (error) throw new BoxError(BoxError.DATABASE_ERROR, error);
|
|
|
|
await reverseProxy.setFallbackCertificate(domain, fallbackCertificate);
|
|
|
|
await eventlog.add(eventlog.ACTION_DOMAIN_ADD, auditSource, { domain, zoneName, provider });
|
|
|
|
safe(mailServer.onDomainAdded(domain), { debug }); // background
|
|
}
|
|
|
|
async function get(domain) {
|
|
assert.strictEqual(typeof domain, 'string');
|
|
|
|
const result = await database.query(`SELECT ${DOMAINS_FIELDS} FROM domains WHERE domain=?`, [ domain ]);
|
|
if (result.length === 0) return null;
|
|
return postProcess(result[0]);
|
|
}
|
|
|
|
async function list() {
|
|
const results = await database.query(`SELECT ${DOMAINS_FIELDS} FROM domains ORDER BY domain`);
|
|
|
|
results.forEach(postProcess);
|
|
|
|
const { domain:dashboardDomain } = await dashboard.getLocation();
|
|
return results.sort((d1, d2) => { // domains are alphabetically sorted with the dashboard domain at index 0
|
|
return d1.domain === dashboardDomain ? -1 : (d2.domain === dashboardDomain ? 1: d1.domain.localeCompare(d2.domain));
|
|
});
|
|
}
|
|
|
|
async function setConfig(domain, data, auditSource) {
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof data.zoneName, 'string');
|
|
assert.strictEqual(typeof data.provider, 'string');
|
|
assert.strictEqual(typeof data.config, 'object');
|
|
assert.strictEqual(typeof data.fallbackCertificate, 'object');
|
|
assert.strictEqual(typeof data.tlsConfig, 'object');
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
|
|
const { provider, config, fallbackCertificate, tlsConfig } = data;
|
|
const { domain:dashboardDomain } = await dashboard.getLocation();
|
|
if (constants.DEMO && (domain === dashboardDomain)) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
|
|
|
|
const domainObject = await get(domain);
|
|
let zoneName;
|
|
if (data.zoneName) {
|
|
if (!tld.isValid(data.zoneName)) throw new BoxError(BoxError.BAD_FIELD, 'Invalid zoneName');
|
|
zoneName = data.zoneName;
|
|
} else {
|
|
zoneName = domainObject.zoneName;
|
|
}
|
|
|
|
if (fallbackCertificate) await reverseProxy.validateCertificate('test', domain, fallbackCertificate);
|
|
|
|
const tlsConfigError = validateTlsConfig(tlsConfig, provider);
|
|
if (tlsConfigError) throw tlsConfigError;
|
|
|
|
if (provider === domainObject.provider) api(provider).injectPrivateFields(config, domainObject.config);
|
|
|
|
const sanitizedConfig = await verifyDomainConfig(config, domain, zoneName, provider);
|
|
|
|
const newData = {
|
|
config: sanitizedConfig,
|
|
zoneName,
|
|
provider,
|
|
tlsConfig,
|
|
};
|
|
|
|
if (fallbackCertificate) newData.fallbackCertificate = fallbackCertificate;
|
|
|
|
const args = [], fields = [];
|
|
for (const k in newData) {
|
|
if (k === 'config' || k === 'tlsConfig' || k === 'fallbackCertificate') { // json fields
|
|
fields.push(`${k}Json = ?`);
|
|
args.push(JSON.stringify(newData[k]));
|
|
} else {
|
|
fields.push(k + ' = ?');
|
|
args.push(newData[k]);
|
|
}
|
|
}
|
|
args.push(domain);
|
|
|
|
const result = await database.query('UPDATE domains SET ' + fields.join(', ') + ' WHERE domain=?', args);
|
|
if (result.affectedRows === 0) throw new BoxError(BoxError.NOT_FOUND, 'Domain not found');
|
|
|
|
if (fallbackCertificate) await reverseProxy.setFallbackCertificate(domain, fallbackCertificate);
|
|
if (!_.isEqual(domainObject.tlsConfig, tlsConfig.provider)) await reverseProxy.handleCertificateProviderChanged(domain);
|
|
|
|
await eventlog.add(eventlog.ACTION_DOMAIN_UPDATE, auditSource, { domain, zoneName, provider });
|
|
}
|
|
|
|
async function setWellKnown(domain, wellKnown, auditSource) {
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof wellKnown, 'object');
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
|
|
const wellKnownError = validateWellKnown(wellKnown);
|
|
if (wellKnownError) throw wellKnownError;
|
|
|
|
const result = await database.query('UPDATE domains SET wellKnownJson = ? WHERE domain=?', [ JSON.stringify(wellKnown), domain ]);
|
|
if (result.affectedRows === 0) throw new BoxError(BoxError.NOT_FOUND, 'Domain not found');
|
|
|
|
await eventlog.add(eventlog.ACTION_DOMAIN_UPDATE, auditSource, { domain, wellKnown });
|
|
}
|
|
|
|
async function del(domain, auditSource) {
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
|
|
const { domain:dashboardDomain } = await dashboard.getLocation();
|
|
if (domain === dashboardDomain) throw new BoxError(BoxError.CONFLICT, 'Cannot remove admin domain');
|
|
|
|
const { domain:mailDomain } = await mailServer.getLocation();
|
|
if (domain === mailDomain) throw new BoxError(BoxError.CONFLICT, 'Cannot remove mail domain. Change the mail server location first');
|
|
|
|
const queries = [
|
|
{ query: 'DELETE FROM mail WHERE domain = ?', args: [ domain ] },
|
|
{ query: 'DELETE FROM domains WHERE domain = ?', args: [ domain ] },
|
|
];
|
|
|
|
const [error, results] = await safe(database.transaction(queries));
|
|
console.dir(error);
|
|
if (error && error.sqlCode === 'ER_ROW_IS_REFERENCED_2') {
|
|
if (error.message.includes('mailboxes_aliasDomain_constraint')) throw new BoxError(BoxError.CONFLICT, 'Domain is in use in a mailbox, list or an alias');
|
|
if (error.message.includes('mailboxes_domain_constraint')) throw new BoxError(BoxError.CONFLICT, 'Domain is in use in a mailbox, list or an alias');
|
|
if (error.message.includes('apps_mailDomain_constraint')) throw new BoxError(BoxError.CONFLICT, 'Domain is in use in an app\'s mailbox section');
|
|
if (error.message.includes('locations')) throw new BoxError(BoxError.CONFLICT, 'Domain is in use in an app\'s location');
|
|
throw new BoxError(BoxError.CONFLICT, error.message);
|
|
}
|
|
if (error) throw error;
|
|
if (results[1].affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Domain not found');
|
|
|
|
await eventlog.add(eventlog.ACTION_DOMAIN_REMOVE, auditSource, { domain });
|
|
|
|
safe(mailServer.onDomainRemoved(domain));
|
|
}
|
|
|
|
async function clear() {
|
|
await database.query('DELETE FROM domains');
|
|
}
|
|
|
|
// removes all fields that are strictly private and should never be returned by API calls
|
|
function removePrivateFields(domain) {
|
|
const result = _.pick(domain, ['domain', 'zoneName', 'provider', 'config', 'tlsConfig', 'fallbackCertificate', 'wellKnown']);
|
|
const out = api(result.provider).removePrivateFields(result);
|
|
delete out.fallbackCertificate.key;
|
|
return out;
|
|
}
|
|
|
|
// removes all fields that are not accessible by a normal user. currently, this is same as for admin users since config tokens are removed
|
|
function removeRestrictedFields(domain) {
|
|
return removePrivateFields(domain);
|
|
}
|
|
|
|
async function getDomainObjectMap() {
|
|
const domainObjects = await list();
|
|
const domainObjectMap = {};
|
|
for (const d of domainObjects) { domainObjectMap[d.domain] = d; }
|
|
return domainObjectMap;
|
|
}
|
|
|
|
async function checkConfigs(auditSource) {
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
|
|
debug(`checkConfig: validating domain configs`);
|
|
|
|
for (const domainObject of await list()) {
|
|
if (domainObject.provider === 'noop' || domainObject.provider === 'manual' || domainObject.provider === 'wildcard') {
|
|
await notifications.unpin(notifications.TYPE_DOMAIN_CONFIG_CHECK_FAILED, { context: domainObject.domain });
|
|
continue;
|
|
}
|
|
|
|
const [error] = await safe(api(domainObject.provider).verifyDomainConfig(domainObject));
|
|
if (!error) {
|
|
await notifications.unpin(notifications.TYPE_DOMAIN_CONFIG_CHECK_FAILED, { context: domainObject.domain });
|
|
continue;
|
|
}
|
|
|
|
let errorMessage;
|
|
if (error.reason === BoxError.ACCESS_DENIED) {
|
|
errorMessage = `Access denied: ${error.message}`;
|
|
} else if (error.reason === BoxError.NOT_FOUND) {
|
|
errorMessage = `Zone not found: ${error.message}`;
|
|
} else if (error.reason === BoxError.EXTERNAL_ERROR) {
|
|
errorMessage = `Configuration error: ${error.message}`;
|
|
} else {
|
|
errorMessage = `General error: ${error.message}`;
|
|
}
|
|
|
|
await notifications.pin(notifications.TYPE_DOMAIN_CONFIG_CHECK_FAILED, `Domain ${domainObject.domain} is not configured properly`,
|
|
errorMessage, { context: domainObject.domain });
|
|
}
|
|
}
|