Files
cloudron-box/src/domains.js

307 lines
12 KiB
JavaScript
Raw Normal View History

2017-10-28 22:18:07 +02:00
'use strict';
module.exports = exports = {
add,
get,
2021-08-13 17:22:28 -07:00
list,
update,
del,
clear,
2017-10-28 22:18:07 +02:00
removePrivateFields,
removeRestrictedFields,
2017-10-28 22:18:07 +02:00
};
2021-08-13 17:22:28 -07:00
const assert = require('assert'),
2019-10-23 10:02:04 -07:00
BoxError = require('./boxerror.js'),
crypto = require('crypto'),
2021-08-13 17:22:28 -07:00
database = require('./database.js'),
2018-11-10 00:43:46 -08:00
eventlog = require('./eventlog.js'),
mail = require('./mail.js'),
reverseProxy = require('./reverseproxy.js'),
2021-08-13 17:22:28 -07:00
safe = require('safetydance'),
settings = require('./settings.js'),
2017-10-28 23:23:58 +02:00
tld = require('tldjs'),
2021-08-13 17:22:28 -07:00
util = require('util'),
_ = require('underscore');
2017-10-28 22:18:07 +02:00
2021-08-13 17:22:28 -07:00
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) {
2017-11-21 19:18:03 -08:00
case 'cloudflare': return require('./dns/cloudflare.js');
case 'route53': return require('./dns/route53.js');
case 'gcdns': return require('./dns/gcdns.js');
case 'digitalocean': return require('./dns/digitalocean.js');
2018-05-06 18:57:27 -07:00
case 'gandi': return require('./dns/gandi.js');
2018-05-06 22:22:42 -07:00
case 'godaddy': return require('./dns/godaddy.js');
case 'linode': return require('./dns/linode.js');
2021-05-29 22:30:26 -07:00
case 'vultr': return require('./dns/vultr.js');
2018-05-09 12:24:33 +02:00
case 'namecom': return require('./dns/namecom.js');
2019-01-16 18:05:42 +02:00
case 'namecheap': return require('./dns/namecheap.js');
2021-01-18 19:43:47 +01:00
case 'netcup': return require('./dns/netcup.js');
2017-11-21 19:18:03 -08:00
case 'noop': return require('./dns/noop.js');
case 'manual': return require('./dns/manual.js');
2018-09-06 20:26:24 -07:00
case 'wildcard': return require('./dns/wildcard.js');
2017-11-21 19:18:03 -08:00
default: return null;
}
}
function maybePromisify(func) {
if (util.types.isAsyncFunction(func)) return func;
return util.promisify(func);
}
async function verifyDnsConfig(dnsConfig, domain, zoneName, provider) {
assert(dnsConfig && typeof dnsConfig === 'object'); // the dns config to test with
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof zoneName, 'string');
2018-01-09 14:46:38 -08:00
assert.strictEqual(typeof provider, 'string');
const backend = api(provider);
if (!backend) throw new BoxError(BoxError.BAD_FIELD, 'Invalid provider', { field: 'provider' });
const domainObject = { config: dnsConfig, domain: domain, zoneName: zoneName };
const [error, result] = await safe(maybePromisify(api(provider).verifyDnsConfig)(domainObject));
if (error && error.reason === BoxError.ACCESS_DENIED) return { error: new BoxError(BoxError.BAD_FIELD, `Access denied: ${error.message}`) };
if (error && error.reason === BoxError.NOT_FOUND) return { error: new BoxError(BoxError.BAD_FIELD, `Zone not found: ${error.message}`) };
if (error && error.reason === BoxError.EXTERNAL_ERROR) return { error: new BoxError(BoxError.BAD_FIELD, `Configuration error: ${error.message}`) };
if (error) return { error };
return { error: null, sanitizedConfig: result };
}
function validateTlsConfig(tlsConfig, dnsProvider) {
2018-09-11 21:53:18 -07:00
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', { field: 'tlsProvider' });
}
2018-09-11 21:53:18 -07:00
if (tlsConfig.wildcard) {
2019-10-23 10:02:04 -07:00
if (!tlsConfig.provider.startsWith('letsencrypt')) return new BoxError(BoxError.BAD_FIELD, 'wildcard can only be set with letsencrypt', { field: 'wildcard' });
if (dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') return new BoxError(BoxError.BAD_FIELD, 'wildcard cert requires a programmable DNS backend', { field: 'tlsProvider' });
2018-09-11 21:53:18 -07:00
}
return null;
}
function validateWellKnown(wellKnown) {
assert.strictEqual(typeof wellKnown, 'object');
return null;
}
2021-08-13 17:22:28 -07:00
async function add(domain, data, auditSource) {
2017-10-28 22:18:07 +02:00
assert.strictEqual(typeof domain, 'string');
2018-11-10 00:43:46 -08:00
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');
2017-10-28 22:18:07 +02:00
let { zoneName, provider, config, fallbackCertificate, tlsConfig, dkimSelector } = data;
2018-11-10 00:43:46 -08:00
2021-08-13 17:22:28 -07:00
if (!tld.isValid(domain)) throw new BoxError(BoxError.BAD_FIELD, 'Invalid domain', { field: 'domain' });
if (domain.endsWith('.')) throw new BoxError(BoxError.BAD_FIELD, 'Invalid domain', { field: 'domain' });
if (zoneName) {
2021-08-13 17:22:28 -07:00
if (!tld.isValid(zoneName)) throw new BoxError(BoxError.BAD_FIELD, 'Invalid zoneName', { field: 'zoneName' });
if (zoneName.endsWith('.')) throw new BoxError(BoxError.BAD_FIELD, 'Invalid zoneName', { field: 'zoneName' });
} else {
zoneName = tld.getDomain(domain) || domain;
}
2017-10-28 22:18:07 +02:00
if (fallbackCertificate) {
2018-11-10 00:43:46 -08:00
let error = reverseProxy.validateCertificate('test', { domain, config }, fallbackCertificate);
2021-08-13 17:22:28 -07:00
if (error) throw error;
} else {
2021-08-17 14:04:29 -07:00
fallbackCertificate = await reverseProxy.generateFallbackCertificate(domain);
}
let error = validateTlsConfig(tlsConfig, provider);
2021-08-13 17:22:28 -07:00
if (error) throw error;
2018-01-31 18:20:11 +01:00
2021-10-11 19:51:29 -07:00
const dkimKey = await mail.generateDkimKey();
if (!dkimSelector) {
// create a unique suffix. this lets one add this domain can be added in another cloudron instance and not have their dkim selector conflict
2021-05-05 12:29:04 -07:00
const suffix = crypto.createHash('sha256').update(settings.dashboardDomain()).digest('hex').substr(0, 6);
dkimSelector = `cloudron-${suffix}`;
}
const result = await verifyDnsConfig(config, domain, zoneName, provider);
if (result.error) throw result.error;
2017-10-28 23:23:58 +02:00
2021-08-13 17:22:28 -07:00
let queries = [
{ query: 'INSERT INTO domains (domain, zoneName, provider, configJson, tlsConfigJson, fallbackCertificateJson) VALUES (?, ?, ?, ?, ?, ?)',
args: [ domain, zoneName, provider, JSON.stringify(result.sanitizedConfig), JSON.stringify(tlsConfig), JSON.stringify(fallbackCertificate) ] },
2021-10-11 19:51:29 -07:00
{ query: 'INSERT INTO mail (domain, dkimKeyJson, dkimSelector) VALUES (?, ?, ?)', args: [ domain, JSON.stringify(dkimKey), dkimSelector || 'cloudron' ] },
2021-08-13 17:22:28 -07:00
];
2017-10-28 23:23:58 +02:00
2021-08-13 17:22:28 -07:00
[error] = await safe(database.transaction(queries));
if (error && error.code === 'ER_DUP_ENTRY') throw new BoxError(BoxError.ALREADY_EXISTS, 'Domain already exists');
if (error) throw new BoxError(BoxError.DATABASE_ERROR, error);
2021-08-13 17:22:28 -07:00
await reverseProxy.setFallbackCertificate(domain, fallbackCertificate);
2021-08-13 17:22:28 -07:00
eventlog.add(eventlog.ACTION_DOMAIN_ADD, auditSource, { domain, zoneName, provider });
safe(mail.onDomainAdded(domain)); // background
2017-10-28 22:18:07 +02:00
}
2021-08-13 17:22:28 -07:00
async function get(domain) {
2017-10-28 22:18:07 +02:00
assert.strictEqual(typeof domain, 'string');
2021-08-13 17:22:28 -07:00
const result = await database.query(`SELECT ${DOMAINS_FIELDS} FROM domains WHERE domain=?`, [ domain ]);
if (result.length === 0) return null;
return postProcess(result[0]);
2017-10-28 22:18:07 +02:00
}
2021-08-13 17:22:28 -07:00
async function list() {
const results = await database.query(`SELECT ${DOMAINS_FIELDS} FROM domains ORDER BY domain`);
results.forEach(postProcess);
return results;
2017-10-28 22:18:07 +02:00
}
2021-08-13 17:22:28 -07:00
async function update(domain, data, auditSource) {
2017-10-28 22:18:07 +02:00
assert.strictEqual(typeof domain, 'string');
2018-11-10 00:43:46 -08:00
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');
2017-10-28 22:18:07 +02:00
let { zoneName, provider, config, fallbackCertificate, tlsConfig, wellKnown } = data;
2021-08-13 17:22:28 -07:00
let error;
2018-11-10 00:43:46 -08:00
2021-08-13 17:22:28 -07:00
if (settings.isDemo() && (domain === settings.dashboardDomain())) throw new BoxError(BoxError.CONFLICT, 'Not allowed in demo mode');
2021-08-13 17:22:28 -07:00
const domainObject = await get(domain);
if (zoneName) {
if (!tld.isValid(zoneName)) throw new BoxError(BoxError.BAD_FIELD, 'Invalid zoneName', { field: 'zoneName' });
} else {
zoneName = domainObject.zoneName;
}
2017-10-28 22:18:07 +02:00
2021-08-13 17:22:28 -07:00
if (fallbackCertificate) {
let error = reverseProxy.validateCertificate('test', domainObject, fallbackCertificate);
if (error) throw error;
}
2021-08-13 17:22:28 -07:00
error = validateTlsConfig(tlsConfig, provider);
if (error) throw error;
2021-08-13 17:22:28 -07:00
error = validateWellKnown(wellKnown, provider);
if (error) throw error;
2018-01-31 18:20:11 +01:00
2021-08-13 17:22:28 -07:00
if (provider === domainObject.provider) api(provider).injectPrivateFields(config, domainObject.config);
const result = await verifyDnsConfig(config, domain, zoneName, provider);
if (result.error) throw result.error;
2017-10-28 23:23:58 +02:00
const newData = {
config: result.sanitizedConfig,
2021-08-13 17:22:28 -07:00
zoneName,
provider,
tlsConfig,
wellKnown,
};
2021-08-13 17:22:28 -07:00
if (fallbackCertificate) newData.fallbackCertificate = fallbackCertificate;
2021-05-04 21:40:11 -07:00
2021-08-13 17:22:28 -07:00
let args = [ ], fields = [ ];
for (const k in newData) {
if (k === 'config' || k === 'tlsConfig' || k === 'wellKnown' || 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);
2017-10-28 23:23:58 +02:00
2021-08-13 17:22:28 -07:00
[error] = await safe(database.query('UPDATE domains SET ' + fields.join(', ') + ' WHERE domain=?', args));
if (error && error.reason === BoxError.NOT_FOUND) throw new BoxError(BoxError.NOT_FOUND, 'Domain not found');
if (error) throw new BoxError(BoxError.DATABASE_ERROR, error);
2018-01-26 22:27:32 -08:00
2021-08-13 17:22:28 -07:00
if (!fallbackCertificate) return;
2021-08-13 17:22:28 -07:00
await reverseProxy.setFallbackCertificate(domain, fallbackCertificate);
2018-11-10 00:43:46 -08:00
2021-08-13 17:22:28 -07:00
eventlog.add(eventlog.ACTION_DOMAIN_UPDATE, auditSource, { domain, zoneName, provider });
2017-10-28 22:18:07 +02:00
}
2021-08-13 17:22:28 -07:00
async function del(domain, auditSource) {
2017-10-28 22:18:07 +02:00
assert.strictEqual(typeof domain, 'string');
2018-11-10 00:43:46 -08:00
assert.strictEqual(typeof auditSource, 'object');
2021-08-13 17:22:28 -07:00
if (domain === settings.dashboardDomain()) throw new BoxError(BoxError.CONFLICT, 'Cannot remove admin domain');
if (domain === settings.mailDomain()) throw new BoxError(BoxError.CONFLICT, 'Cannot remove mail domain. Change the mail server location first');
2021-08-13 17:22:28 -07:00
let queries = [
{ query: 'DELETE FROM mail WHERE domain = ?', args: [ domain ] },
{ query: 'DELETE FROM domains WHERE domain = ?', args: [ domain ] },
];
2021-08-13 17:22:28 -07:00
const [error, results] = await safe(database.transaction(queries));
if (error && error.code === 'ER_ROW_IS_REFERENCED_2') {
if (error.message.indexOf('apps_mailDomain_constraint') !== -1) throw new BoxError(BoxError.CONFLICT, 'Domain is in use by an app or the mailbox of an app. Check the domains of apps and the Email section of each app.');
if (error.message.indexOf('subdomains') !== -1) throw new BoxError(BoxError.CONFLICT, 'Domain is in use by one or more app(s).');
if (error.message.indexOf('mail') !== -1) throw new BoxError(BoxError.CONFLICT, 'Domain is in use by one or more mailboxes. Delete them first in the Email view.');
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');
2021-08-13 17:22:28 -07:00
eventlog.add(eventlog.ACTION_DOMAIN_REMOVE, auditSource, { domain });
2021-08-25 19:41:46 -07:00
safe(mail.onDomainRemoved(domain));
2017-11-21 19:18:03 -08:00
}
2021-08-13 17:22:28 -07:00
async function clear() {
await database.query('DELETE FROM domains');
}
2018-01-01 19:19:07 -08:00
// removes all fields that are strictly private and should never be returned by API calls
function removePrivateFields(domain) {
var result = _.pick(domain, 'domain', 'zoneName', 'provider', 'config', 'tlsConfig', 'fallbackCertificate', 'wellKnown');
return api(result.provider).removePrivateFields(result);
2018-04-29 11:20:12 -07:00
}
// removes all fields that are not accessible by a normal user
function removeRestrictedFields(domain) {
var result = _.pick(domain, 'domain', 'zoneName', 'provider');
result.config = {}; // always ensure config object
return result;
}