2021-08-13 17:22:28 -07:00
'use strict' ;
module . exports = exports = {
fqdn ,
getName ,
getDnsRecords ,
upsertDnsRecords ,
removeDnsRecords ,
waitForDnsRecord ,
validateHostname ,
makeWildcard ,
registerLocations ,
unregisterLocations ,
checkDnsRecords ,
syncDnsRecords ,
2021-08-27 09:52:24 -07:00
resolve ,
promises : {
resolve : require ( 'util' ) . promisify ( resolve )
}
2021-08-13 17:22:28 -07:00
} ;
const apps = require ( './apps.js' ) ,
assert = require ( 'assert' ) ,
BoxError = require ( './boxerror.js' ) ,
constants = require ( './constants.js' ) ,
2021-09-03 09:08:20 -07:00
debug = require ( 'debug' ) ( 'box:dns' ) ,
2021-08-13 17:22:28 -07:00
dns = require ( 'dns' ) ,
domains = require ( './domains.js' ) ,
mail = require ( './mail.js' ) ,
2021-08-27 09:52:24 -07:00
promiseRetry = require ( './promise-retry.js' ) ,
safe = require ( 'safetydance' ) ,
2021-08-13 17:22:28 -07:00
settings = require ( './settings.js' ) ,
sysinfo = require ( './sysinfo.js' ) ,
tld = require ( 'tldjs' ) ,
util = require ( 'util' ) ,
_ = require ( 'underscore' ) ;
// 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 fqdn ( location , domainObject ) {
assert . strictEqual ( typeof location , 'string' ) ;
assert . strictEqual ( typeof domainObject , 'object' ) ;
return location + ( location ? '.' : '' ) + domainObject . 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 ( location , domainObject ) {
assert . strictEqual ( typeof location , 'string' ) ;
assert . strictEqual ( typeof domainObject , 'object' ) ;
const hostname = fqdn ( location , domainObject ) ;
const RESERVED _LOCATIONS = [
constants . SMTP _LOCATION ,
constants . IMAP _LOCATION
] ;
if ( RESERVED _LOCATIONS . indexOf ( location ) !== - 1 ) return new BoxError ( BoxError . BAD _FIELD , location + ' is reserved' , { field : 'location' } ) ;
if ( hostname === settings . dashboardFqdn ( ) ) return new BoxError ( BoxError . BAD _FIELD , location + ' is reserved' , { field : 'location' } ) ;
// workaround https://github.com/oncletom/tld.js/issues/73
var tmp = hostname . replace ( '_' , '-' ) ;
if ( ! tld . isValid ( tmp ) ) return new BoxError ( BoxError . BAD _FIELD , 'Hostname is not a valid domain name' , { field : 'location' } ) ;
if ( hostname . length > 253 ) return new BoxError ( BoxError . BAD _FIELD , 'Hostname length exceeds 253 characters' , { field : 'location' } ) ;
if ( location ) {
// label validation
if ( location . split ( '.' ) . some ( function ( p ) { return p . length > 63 || p . length < 1 ; } ) ) return new BoxError ( BoxError . BAD _FIELD , 'Invalid subdomain length' , { field : 'location' } ) ;
if ( location . match ( /^[A-Za-z0-9-.]+$/ ) === null ) return new BoxError ( BoxError . BAD _FIELD , 'Subdomain can only contain alphanumeric, hyphen and dot' , { field : 'location' } ) ;
if ( /^[-.]/ . test ( location ) ) return new BoxError ( BoxError . BAD _FIELD , 'Subdomain cannot start or end with hyphen or dot' , { field : 'location' } ) ;
}
return null ;
}
// returns the 'name' that needs to be inserted into zone
// eslint-disable-next-line no-unused-vars
function getName ( domain , location , type ) {
const part = domain . domain . slice ( 0 , - domain . zoneName . length - 1 ) ;
if ( location === '' ) return part ;
return part ? ` ${ location } . ${ part } ` : location ;
}
2021-08-27 09:52:24 -07:00
function maybePromisify ( func ) {
if ( util . types . isAsyncFunction ( func ) ) return func ;
return util . promisify ( func ) ;
}
async function getDnsRecords ( location , domain , type ) {
2021-08-13 17:22:28 -07:00
assert . strictEqual ( typeof location , 'string' ) ;
assert . strictEqual ( typeof domain , 'string' ) ;
assert . strictEqual ( typeof type , 'string' ) ;
2021-08-27 09:52:24 -07:00
const domainObject = await domains . get ( domain ) ;
return await maybePromisify ( api ( domainObject . provider ) . get ) ( domainObject , location , type ) ;
2021-08-13 17:22:28 -07:00
}
2021-08-27 09:52:24 -07:00
async function checkDnsRecords ( location , domain ) {
2021-08-13 17:22:28 -07:00
assert . strictEqual ( typeof location , 'string' ) ;
assert . strictEqual ( typeof domain , 'string' ) ;
2021-08-27 09:52:24 -07:00
const values = await getDnsRecords ( location , domain , 'A' ) ;
2021-08-13 17:22:28 -07:00
2021-08-27 09:52:24 -07:00
const ip = await sysinfo . getServerIp ( ) ;
2021-08-13 17:22:28 -07:00
2021-08-27 09:52:24 -07:00
if ( values . length === 0 ) return { needsOverwrite : false } ; // does not exist
if ( values [ 0 ] === ip ) return { needsOverwrite : false } ; // exists but in sync
2021-08-13 17:22:28 -07:00
2021-08-27 09:52:24 -07:00
return { needsOverwrite : true } ;
2021-08-13 17:22:28 -07:00
}
// note: for TXT records the values must be quoted
2021-08-27 09:52:24 -07:00
async function upsertDnsRecords ( location , domain , type , values ) {
2021-08-13 17:22:28 -07:00
assert . strictEqual ( typeof location , 'string' ) ;
assert . strictEqual ( typeof domain , 'string' ) ;
assert . strictEqual ( typeof type , 'string' ) ;
assert ( Array . isArray ( values ) ) ;
2021-09-03 09:08:20 -07:00
debug ( ` upsertDNSRecord: location ${ location } on domain ${ domain } of type ${ type } with values ${ JSON . stringify ( values ) } ` ) ;
2021-08-13 17:22:28 -07:00
2021-08-27 09:52:24 -07:00
const domainObject = await domains . get ( domain ) ;
await maybePromisify ( api ( domainObject . provider ) . upsert ) ( domainObject , location , type , values ) ;
2021-08-13 17:22:28 -07:00
}
2021-08-27 09:52:24 -07:00
async function removeDnsRecords ( location , domain , type , values ) {
2021-08-13 17:22:28 -07:00
assert . strictEqual ( typeof location , 'string' ) ;
assert . strictEqual ( typeof domain , 'string' ) ;
assert . strictEqual ( typeof type , 'string' ) ;
assert ( Array . isArray ( values ) ) ;
debug ( 'removeDNSRecord: %s on %s type %s values' , location , domain , type , values ) ;
2021-08-27 09:52:24 -07:00
const domainObject = await domains . get ( domain ) ;
const [ error ] = await safe ( maybePromisify ( api ( domainObject . provider ) . del ) ( domainObject , location , type , values ) ) ;
if ( error && error . reason !== BoxError . NOT _FOUND ) throw error ;
2021-08-13 17:22:28 -07:00
}
2021-08-27 09:52:24 -07:00
async function waitForDnsRecord ( location , domain , type , value , options ) {
2021-08-13 17:22:28 -07:00
assert . strictEqual ( typeof location , 'string' ) ;
assert . strictEqual ( typeof domain , 'string' ) ;
assert ( type === 'A' || type === 'TXT' ) ;
assert . strictEqual ( typeof value , 'string' ) ;
assert ( options && typeof options === 'object' ) ; // { interval: 5000, times: 50000 }
2021-08-27 09:52:24 -07:00
const domainObject = await domains . get ( domain ) ;
2021-08-13 17:22:28 -07:00
2021-08-27 09:52:24 -07:00
// linode DNS takes ~15mins
if ( ! options . interval ) options . interval = domainObject . provider === 'linode' ? 20000 : 5000 ;
2021-08-13 17:22:28 -07:00
2021-08-27 09:52:24 -07:00
await maybePromisify ( api ( domainObject . provider ) . wait ) ( domainObject , location , type , value , options ) ;
2021-08-13 17:22:28 -07:00
}
function makeWildcard ( vhost ) {
assert . strictEqual ( typeof vhost , 'string' ) ;
// if the vhost is like *.example.com, this function will do nothing
let parts = vhost . split ( '.' ) ;
parts [ 0 ] = '*' ;
return parts . join ( '.' ) ;
}
2021-08-27 09:52:24 -07:00
async function registerLocations ( locations , options , progressCallback ) {
2021-08-13 17:22:28 -07:00
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 overwriteDns = options . overwriteDns || false ;
2021-08-27 09:52:24 -07:00
const ip = await sysinfo . getServerIp ( ) ;
for ( const location of locations ) {
const error = await promiseRetry ( { times : 200 , interval : 5000 } , async function ( ) {
progressCallback ( { message : ` Registering location: ${ location . subdomain ? ( location . subdomain + '.' ) : '' } ${ location . domain } ` } ) ;
// get the current record before updating it
const [ error , values ] = await safe ( getDnsRecords ( location . subdomain , location . domain , 'A' ) ) ;
if ( error && error . reason === BoxError . EXTERNAL _ERROR ) throw new BoxError ( BoxError . EXTERNAL _ERROR , error . message , { domain : location } ) ; // try again
// give up for other errors
if ( error && error . reason === BoxError . ACCESS _DENIED ) return new BoxError ( BoxError . ACCESS _DENIED , error . message , { domain : location } ) ;
if ( error && error . reason === BoxError . NOT _FOUND ) return new BoxError ( BoxError . NOT _FOUND , error . message , { domain : location } ) ;
if ( error ) return new BoxError ( BoxError . EXTERNAL _ERROR , error . message , location ) ;
if ( values . length !== 0 && values [ 0 ] === ip ) return null ; // up-to-date
// refuse to update any existing DNS record for custom domains that we did not create
if ( values . length !== 0 && ! overwriteDns ) return new BoxError ( BoxError . ALREADY _EXISTS , 'DNS Record already exists' , { domain : location } ) ;
const [ upsertError ] = await safe ( upsertDnsRecords ( location . subdomain , location . domain , 'A' , [ ip ] ) ) ;
if ( upsertError && ( upsertError . reason === BoxError . BUSY || upsertError . reason === BoxError . EXTERNAL _ERROR ) ) {
progressCallback ( { message : ` registerSubdomains: Upsert error. Will retry. ${ upsertError . message } ` } ) ;
throw new BoxError ( BoxError . EXTERNAL _ERROR , upsertError . message , { domain : location } ) ; // try again
}
return upsertError ? new BoxError ( BoxError . EXTERNAL _ERROR , upsertError . message , location ) : null ;
} ) ;
if ( error ) throw error ;
}
2021-08-13 17:22:28 -07:00
}
2021-08-27 09:52:24 -07:00
async function unregisterLocations ( locations , progressCallback ) {
2021-08-13 17:22:28 -07:00
assert ( Array . isArray ( locations ) ) ;
assert . strictEqual ( typeof progressCallback , 'function' ) ;
2021-08-27 09:52:24 -07:00
const ip = await sysinfo . getServerIp ( ) ;
for ( const location of locations ) {
const error = await promiseRetry ( { times : 30 , interval : 5000 } , async function ( ) {
progressCallback ( { message : ` Unregistering location: ${ location . subdomain ? ( location . subdomain + '.' ) : '' } ${ location . domain } ` } ) ;
const [ error ] = await safe ( removeDnsRecords ( location . subdomain , location . domain , 'A' , [ ip ] ) ) ;
if ( error && error . reason === BoxError . NOT _FOUND ) return ;
if ( error && ( error . reason === BoxError . BUSY || error . reason === BoxError . EXTERNAL _ERROR ) ) {
progressCallback ( { message : ` Error unregistering location. Will retry. ${ error . message } ` } ) ;
throw new BoxError ( BoxError . EXTERNAL _ERROR , error . message , { domain : location } ) ; // try again
}
return error ? new BoxError ( BoxError . EXTERNAL _ERROR , error . message , { domain : location } ) : null ; // give up for other errors
} ) ;
if ( error ) throw error ;
}
2021-08-13 17:22:28 -07:00
}
2021-08-27 09:52:24 -07:00
async function syncDnsRecords ( options , progressCallback ) {
2021-08-13 17:22:28 -07:00
assert . strictEqual ( typeof options , 'object' ) ;
assert . strictEqual ( typeof progressCallback , 'function' ) ;
2021-08-27 09:52:24 -07:00
if ( options . domain && options . type === 'mail' ) return await mail . setDnsRecords ( options . domain ) ;
2021-08-13 17:22:28 -07:00
2021-09-05 12:10:37 +02:00
let allDomains = await domains . list ( ) ;
2021-08-13 17:22:28 -07:00
2021-09-05 12:10:37 +02:00
if ( options . domain ) allDomains = allDomains . filter ( d => d . domain === options . domain ) ;
2021-08-13 17:22:28 -07:00
2021-08-27 09:52:24 -07:00
const mailSubdomain = settings . mailFqdn ( ) . substr ( 0 , settings . mailFqdn ( ) . length - settings . mailDomain ( ) . length - 1 ) ;
2021-08-13 17:22:28 -07:00
2021-08-27 09:52:24 -07:00
const allApps = await apps . list ( ) ;
2021-08-13 17:22:28 -07:00
2021-08-27 09:52:24 -07:00
let progress = 1 , errors = [ ] ;
2021-08-13 17:22:28 -07:00
2021-08-27 09:52:24 -07:00
// we sync by domain only to get some nice progress
2021-09-05 12:10:37 +02:00
for ( const domain of allDomains ) {
2021-08-27 09:52:24 -07:00
progressCallback ( { percent : progress , message : ` Updating DNS of ${ domain . domain } ` } ) ;
2021-09-06 09:46:27 +02:00
progress += Math . round ( 100 / ( 1 + allDomains . length ) ) ;
2021-08-13 17:22:28 -07:00
2021-08-27 09:52:24 -07:00
let locations = [ ] ;
if ( domain . domain === settings . dashboardDomain ( ) ) locations . push ( { subdomain : constants . DASHBOARD _LOCATION , domain : settings . dashboardDomain ( ) } ) ;
if ( domain . domain === settings . mailDomain ( ) && settings . mailFqdn ( ) !== settings . dashboardFqdn ( ) ) locations . push ( { subdomain : mailSubdomain , domain : settings . mailDomain ( ) } ) ;
2021-08-13 17:22:28 -07:00
2021-08-27 09:52:24 -07:00
allApps . forEach ( function ( app ) {
const appLocations = [ { subdomain : app . location , domain : app . domain } ] . concat ( app . alternateDomains ) . concat ( app . aliasDomains ) ;
locations = locations . concat ( appLocations . filter ( al => al . domain === domain . domain ) ) ;
2021-08-13 17:22:28 -07:00
} ) ;
2021-08-27 09:52:24 -07:00
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 } ;
2021-08-13 17:22:28 -07:00
}
// a note on TXT records. It doesn't have quotes ("") at the DNS level. Those quotes
// are added for DNS server software to enclose spaces. Such quotes may also be returned
// by the DNS REST API of some providers
function resolve ( hostname , rrtype , options , callback ) {
assert . strictEqual ( typeof hostname , 'string' ) ;
assert . strictEqual ( typeof rrtype , 'string' ) ;
assert ( options && typeof options === 'object' ) ;
assert . strictEqual ( typeof callback , 'function' ) ;
const defaultOptions = { server : '127.0.0.1' , timeout : 5000 } ; // unbound runs on 127.0.0.1
const resolver = new dns . Resolver ( ) ;
options = _ . extend ( { } , defaultOptions , options ) ;
// Only use unbound on a Cloudron
if ( constants . CLOUDRON ) resolver . setServers ( [ options . server ] ) ;
// should callback with ECANCELLED but looks like we might hit https://github.com/nodejs/node/issues/14814
const timerId = setTimeout ( resolver . cancel . bind ( resolver ) , options . timeout || 5000 ) ;
resolver . resolve ( hostname , rrtype , function ( error , result ) {
clearTimeout ( timerId ) ;
if ( error && error . code === 'ECANCELLED' ) error . code = 'TIMEOUT' ;
// result is an empty array if there was no error but there is no record. when you query a random
// domain, it errors with ENOTFOUND. But if you query an existing domain (A record) but with different
// type (CNAME) it is not an error and empty array
// for TXT records, result is 2d array of strings
callback ( error , result ) ;
} ) ;
}