'use strict'; module.exports = exports = { add, get, list, update, del, clear, removePrivateFields, removeRestrictedFields, }; const assert = require('assert'), BoxError = require('./boxerror.js'), crypto = require('crypto'), database = require('./database.js'), eventlog = require('./eventlog.js'), mail = require('./mail.js'), reverseProxy = require('./reverseproxy.js'), safe = require('safetydance'), settings = require('./settings.js'), tld = require('tldjs'), util = require('util'), _ = require('underscore'); 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 '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'); case 'gandi': return require('./dns/gandi.js'); case 'godaddy': return require('./dns/godaddy.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 'manual': return require('./dns/manual.js'); case 'wildcard': return require('./dns/wildcard.js'); 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'); 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) { 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' }); } if (tlsConfig.wildcard) { 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' }); } 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'); let { zoneName, provider, config, fallbackCertificate, tlsConfig, dkimSelector } = data; 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) { 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; } if (fallbackCertificate) { let error = reverseProxy.validateCertificate('test', { domain, config }, fallbackCertificate); if (error) throw error; } else { fallbackCertificate = await reverseProxy.generateFallbackCertificate(domain); } let error = validateTlsConfig(tlsConfig, provider); if (error) throw error; 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 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; 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) ] }, { query: 'INSERT INTO mail (domain, dkimKeyJson, dkimSelector) VALUES (?, ?, ?)', args: [ domain, JSON.stringify(dkimKey), dkimSelector || 'cloudron' ] }, ]; [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); await reverseProxy.setFallbackCertificate(domain, fallbackCertificate); eventlog.add(eventlog.ACTION_DOMAIN_ADD, auditSource, { domain, zoneName, provider }); safe(mail.onDomainAdded(domain)); // 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); return results; } async function update(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'); let { zoneName, provider, config, fallbackCertificate, tlsConfig, wellKnown } = data; let error; if (settings.isDemo() && (domain === settings.dashboardDomain())) throw new BoxError(BoxError.CONFLICT, 'Not allowed in demo mode'); 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; } if (fallbackCertificate) { let error = reverseProxy.validateCertificate('test', domainObject, fallbackCertificate); if (error) throw error; } error = validateTlsConfig(tlsConfig, provider); if (error) throw error; error = validateWellKnown(wellKnown, provider); if (error) throw error; if (provider === domainObject.provider) api(provider).injectPrivateFields(config, domainObject.config); const result = await verifyDnsConfig(config, domain, zoneName, provider); if (result.error) throw result.error; const newData = { config: result.sanitizedConfig, zoneName, provider, tlsConfig, wellKnown, }; if (fallbackCertificate) newData.fallbackCertificate = fallbackCertificate; 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); [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); if (!fallbackCertificate) return; await reverseProxy.setFallbackCertificate(domain, fallbackCertificate); eventlog.add(eventlog.ACTION_DOMAIN_UPDATE, auditSource, { domain, zoneName, provider }); } async function del(domain, auditSource) { assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof auditSource, 'object'); 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'); let 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)); 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'); eventlog.add(eventlog.ACTION_DOMAIN_REMOVE, auditSource, { domain }); safe(mail.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) { var result = _.pick(domain, 'domain', 'zoneName', 'provider', 'config', 'tlsConfig', 'fallbackCertificate', 'wellKnown'); return api(result.provider).removePrivateFields(result); } // 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; }