import assert from 'node:assert'; import BoxError from './boxerror.js'; import constants from './constants.js'; import crypto from 'node:crypto'; import dashboard from './dashboard.js'; import database from './database.js'; import debugModule from 'debug'; import eventlog from './eventlog.js'; import mailServer from './mailserver.js'; import notifications from './notifications.js'; import openssl from './openssl.js'; import reverseProxy from './reverseproxy.js'; import safe from 'safetydance'; import tld from 'tldjs'; import _ from './underscore.js'; import dnsBunny from './dns/bunny.js'; import dnsCloudflare from './dns/cloudflare.js'; import dnsDesec from './dns/desec.js'; import dnsDnsimple from './dns/dnsimple.js'; import dnsRoute53 from './dns/route53.js'; import dnsGcdns from './dns/gcdns.js'; import dnsDigitalocean from './dns/digitalocean.js'; import dnsGandi from './dns/gandi.js'; import dnsGodaddy from './dns/godaddy.js'; import dnsHetzner from './dns/hetzner.js'; import dnsHetznercloud from './dns/hetznercloud.js'; import dnsInwx from './dns/inwx.js'; import dnsLinode from './dns/linode.js'; import dnsVultr from './dns/vultr.js'; import dnsNamecom from './dns/namecom.js'; import dnsNamecheap from './dns/namecheap.js'; import dnsNetcup from './dns/netcup.js'; import dnsNoop from './dns/noop.js'; import dnsOvh from './dns/ovh.js'; import dnsManual from './dns/manual.js'; import dnsPorkbun from './dns/porkbun.js'; import dnsWildcard from './dns/wildcard.js'; const debug = debugModule('box:domains'); 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; } const DNS_PROVIDERS = { bunny: dnsBunny, cloudflare: dnsCloudflare, desec: dnsDesec, dnsimple: dnsDnsimple, route53: dnsRoute53, gcdns: dnsGcdns, digitalocean: dnsDigitalocean, gandi: dnsGandi, godaddy: dnsGodaddy, hetzner: dnsHetzner, hetznercloud: dnsHetznercloud, inwx: dnsInwx, linode: dnsLinode, vultr: dnsVultr, namecom: dnsNamecom, namecheap: dnsNamecheap, netcup: dnsNetcup, noop: dnsNoop, ovh: dnsOvh, manual: dnsManual, porkbun: dnsPorkbun, wildcard: dnsWildcard }; // choose which subdomain backend we use for test purpose we use route53 function api(provider) { assert.strictEqual(typeof provider, 'string'); return DNS_PROVIDERS[provider] || 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'); if (wellKnown === null) return null; for (const key of Object.keys(wellKnown)) { if (typeof wellKnown[key] !== 'string') return new BoxError(BoxError.BAD_FIELD, `well-known value for ${key} must be a string`); } if (wellKnown.carddav && wellKnown.carddav.includes('://')) return new BoxError(BoxError.BAD_FIELD, 'carddav must be a domain, not a URL'); if (wellKnown.caldav && wellKnown.caldav.includes('://')) return new BoxError(BoxError.BAD_FIELD, 'caldav must be a domain, not a URL'); 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 openssl.validateCertificate('test', domain, data.fallbackCertificate); fallbackCertificate = data.fallbackCertificate; } else { fallbackCertificate = await openssl.generateCertificate(domain); } let error = validateTlsConfig(tlsConfig, provider); if (error) throw error; const dkimKey = await openssl.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); return results.sort((d1, d2) => { // domains are alphabetically sorted return 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 openssl.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)); 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}`; } debug(`checkConfig: ${domainObject.domain} is not configured properly`, error); await notifications.pin(notifications.TYPE_DOMAIN_CONFIG_CHECK_FAILED, `Domain ${domainObject.domain} is not configured properly`, errorMessage, { context: domainObject.domain }); } } export default { add, get, list, setConfig, setWellKnown, del, clear, getDomainObjectMap, removePrivateFields, removeRestrictedFields, checkConfigs };