2023-10-23 23:17:32 +02:00
'use strict' ;
2025-08-14 11:17:38 +05:30
const assert = require ( 'node:assert' ) ,
2023-10-23 23:17:32 +02:00
BoxError = require ( '../boxerror.js' ) ,
constants = require ( '../constants.js' ) ,
debug = require ( 'debug' ) ( 'box:dns/dnsimple' ) ,
dig = require ( '../dig.js' ) ,
dns = require ( '../dns.js' ) ,
safe = require ( 'safetydance' ) ,
2025-07-10 10:55:52 +02:00
superagent = require ( '@cloudron/superagent' ) ,
2023-10-23 23:17:32 +02:00
waitForDns = require ( './waitfordns.js' ) ;
const DNSIMPLE _API = 'https://api.dnsimple.com/v2' ;
function formatError ( response ) {
2025-02-14 17:26:54 +01:00
return ` dnsimple DNS error ${ response . status } ${ response . text } ` ;
2023-10-23 23:17:32 +02:00
}
function removePrivateFields ( domainObject ) {
2025-10-08 12:04:31 +02:00
delete domainObject . config . accessToken ;
2023-10-23 23:17:32 +02:00
return domainObject ;
}
function injectPrivateFields ( newConfig , currentConfig ) {
2025-10-08 12:04:31 +02:00
if ( ! Object . hasOwn ( newConfig , 'accessToken' ) ) newConfig . accessToken = currentConfig . accessToken ;
2023-10-23 23:17:32 +02:00
}
async function getAccountId ( domainConfig ) {
assert . strictEqual ( typeof domainConfig , 'object' ) ;
const [ error , response ] = await safe ( superagent . get ( ` ${ DNSIMPLE _API } /accounts ` )
. set ( 'Authorization' , ` Bearer ${ domainConfig . accessToken } ` )
. retry ( 5 )
. timeout ( 30 * 1000 )
. ok ( ( ) => true ) ) ;
2024-11-19 17:08:19 +05:30
if ( error ) throw new BoxError ( BoxError . NETWORK _ERROR , error ) ;
2025-02-14 17:26:54 +01:00
if ( response . status === 401 ) throw new BoxError ( BoxError . ACCESS _DENIED , formatError ( response ) ) ;
if ( response . status !== 200 ) throw new BoxError ( BoxError . EXTERNAL _ERROR , formatError ( response ) ) ;
2023-10-23 23:17:32 +02:00
const accountId = safe . query ( response . body , 'data[0].id' , null ) ;
if ( ! accountId || typeof accountId !== 'number' ) throw new BoxError ( BoxError . EXTERNAL _ERROR , ` Could not determine account id: ${ JSON . stringify ( response . body ) } ` ) ;
return String ( accountId ) ;
}
async function getZone ( domainConfig , zoneName ) {
assert . strictEqual ( typeof domainConfig , 'object' ) ;
assert . strictEqual ( typeof zoneName , 'string' ) ;
const accountId = await getAccountId ( domainConfig ) ;
const [ error , response ] = await safe ( superagent . get ( ` ${ DNSIMPLE _API } / ${ accountId } /zones?name_like= ${ zoneName } ` )
. set ( 'Authorization' , ` Bearer ${ domainConfig . accessToken } ` )
. retry ( 5 )
. timeout ( 30 * 1000 )
. ok ( ( ) => true ) ) ;
2024-11-19 17:08:19 +05:30
if ( error ) throw new BoxError ( BoxError . NETWORK _ERROR , error ) ;
2025-02-14 17:26:54 +01:00
if ( response . status === 401 ) throw new BoxError ( BoxError . ACCESS _DENIED , formatError ( response ) ) ;
if ( response . status !== 200 ) throw new BoxError ( BoxError . EXTERNAL _ERROR , formatError ( response ) ) ;
2023-10-23 23:17:32 +02:00
if ( ! Array . isArray ( response . body . data ) ) throw new BoxError ( BoxError . EXTERNAL _ERROR , ` Invalid data in response: ${ JSON . stringify ( response . body ) } ` ) ;
const item = response . body . data . filter ( item => item . name === zoneName ) ;
if ( item . length === 0 ) throw new BoxError ( BoxError . NOT _FOUND , 'Domain not found' ) ;
return { accountId , zoneId : item [ 0 ] . id } ;
}
async function getDnsRecords ( domainConfig , zoneName , name , type ) {
assert . strictEqual ( typeof domainConfig , 'object' ) ;
assert . strictEqual ( typeof zoneName , 'string' ) ;
assert . strictEqual ( typeof name , 'string' ) ;
assert . strictEqual ( typeof type , 'string' ) ;
debug ( ` get: ${ name } in zone ${ zoneName } of type ${ type } ` ) ;
const { accountId , zoneId } = await getZone ( domainConfig , zoneName ) ;
const [ error , response ] = await safe ( superagent . get ( ` ${ DNSIMPLE _API } / ${ accountId } /zones/ ${ zoneId } /records?name= ${ name } &type= ${ type } ` )
. set ( 'Authorization' , ` Bearer ${ domainConfig . accessToken } ` )
. retry ( 5 )
. timeout ( 30 * 1000 )
. ok ( ( ) => true ) ) ;
2024-11-19 17:08:19 +05:30
if ( error ) throw new BoxError ( BoxError . NETWORK _ERROR , error ) ;
2025-02-14 17:26:54 +01:00
if ( response . status === 401 ) throw new BoxError ( BoxError . ACCESS _DENIED , formatError ( response ) ) ;
if ( response . status !== 200 ) throw new BoxError ( BoxError . EXTERNAL _ERROR , formatError ( response ) ) ;
2023-10-23 23:17:32 +02:00
if ( ! Array . isArray ( response . body . data ) ) throw new BoxError ( BoxError . EXTERNAL _ERROR , ` Invalid data in response: ${ JSON . stringify ( response . body ) } ` ) ;
return response . body . data ;
}
async function upsert ( domainObject , location , type , values ) {
assert . strictEqual ( typeof domainObject , 'object' ) ;
assert . strictEqual ( typeof location , 'string' ) ;
assert . strictEqual ( typeof type , 'string' ) ;
assert ( Array . isArray ( values ) ) ;
const domainConfig = domainObject . config ,
zoneName = domainObject . zoneName ,
name = dns . getName ( domainObject , location , type ) || '' ;
debug ( ` upsert: ${ name } in zone ${ zoneName } of type ${ type } with values ${ JSON . stringify ( values ) } ` ) ;
const { accountId , zoneId } = await getZone ( domainConfig , zoneName ) ;
const records = await getDnsRecords ( domainConfig , zoneName , name , type ) ;
// used to track available records to update instead of create
2025-02-14 17:26:54 +01:00
let i = 0 ;
const recordIds = [ ] ;
2023-10-23 23:17:32 +02:00
for ( let value of values ) {
let priority = 0 ;
if ( type === 'MX' ) {
priority = parseInt ( value . split ( ' ' ) [ 0 ] , 10 ) ;
value = value . split ( ' ' ) [ 1 ] ;
} else if ( type === 'TXT' ) {
value = value . replace ( /^"(.*)"$/ , '$1' ) ; // strip any double quotes
}
const data = {
type ,
name ,
content : value ,
priority ,
ttl : 60
} ;
if ( i >= records . length ) {
const [ error , response ] = await safe ( superagent . post ( ` ${ DNSIMPLE _API } / ${ accountId } /zones/ ${ zoneId } /records ` )
. set ( 'Authorization' , ` Bearer ${ domainConfig . accessToken } ` )
. send ( data )
. retry ( 5 )
. timeout ( 30 * 1000 )
. ok ( ( ) => true ) ) ;
2024-11-19 17:08:19 +05:30
if ( error ) throw new BoxError ( BoxError . NETWORK _ERROR , error ) ;
2025-02-14 17:26:54 +01:00
if ( response . status === 401 ) throw new BoxError ( BoxError . ACCESS _DENIED , formatError ( response ) ) ;
if ( response . status !== 201 ) throw new BoxError ( BoxError . EXTERNAL _ERROR , formatError ( response ) ) ;
2023-10-23 23:17:32 +02:00
recordIds . push ( safe . query ( response . body , 'data.id' ) ) ;
} else {
const [ error , response ] = await safe ( superagent . patch ( ` ${ DNSIMPLE _API } / ${ accountId } /zones/ ${ zoneId } /records/ ${ records [ i ] . id } ` )
. set ( 'Authorization' , ` Bearer ${ domainConfig . accessToken } ` )
. send ( data )
. retry ( 5 )
. timeout ( 30 * 1000 )
. ok ( ( ) => true ) ) ;
++ i ;
2024-11-19 17:08:19 +05:30
if ( error ) throw new BoxError ( BoxError . NETWORK _ERROR , error ) ;
2025-02-14 17:26:54 +01:00
if ( response . status === 401 ) throw new BoxError ( BoxError . ACCESS _DENIED , formatError ( response ) ) ;
if ( response . status !== 200 ) throw new BoxError ( BoxError . EXTERNAL _ERROR , formatError ( response ) ) ;
2023-10-23 23:17:32 +02:00
recordIds . push ( safe . query ( response . body , 'data.id' ) ) ;
}
}
for ( let j = values . length + 1 ; j < records . length ; j ++ ) {
const [ error ] = await safe ( superagent . del ( ` ${ DNSIMPLE _API } / ${ accountId } /zones/ ${ zoneId } /records/ ${ records [ i ] . id } ` )
. set ( 'Authorization' , ` Bearer ${ domainConfig . accessToken } ` )
. retry ( 5 )
. timeout ( 30 * 1000 )
. ok ( ( ) => true ) ) ;
if ( error ) debug ( ` upsert: error removing record ${ records [ j ] . id } : ${ error . message } ` ) ;
}
debug ( 'upsert: completed with recordIds:%j' , recordIds ) ;
}
async function get ( domainObject , location , type ) {
assert . strictEqual ( typeof domainObject , 'object' ) ;
assert . strictEqual ( typeof location , 'string' ) ;
assert . strictEqual ( typeof type , 'string' ) ;
const domainConfig = domainObject . config ,
zoneName = domainObject . zoneName ,
name = dns . getName ( domainObject , location , type ) || '' ;
const records = await getDnsRecords ( domainConfig , zoneName , name , type ) ;
return records . map ( r => r . content ) ;
}
async function del ( domainObject , location , type , values ) {
assert . strictEqual ( typeof domainObject , 'object' ) ;
assert . strictEqual ( typeof location , 'string' ) ;
assert . strictEqual ( typeof type , 'string' ) ;
assert ( Array . isArray ( values ) ) ;
const domainConfig = domainObject . config ,
zoneName = domainObject . zoneName ,
name = dns . getName ( domainObject , location , type ) || '' ;
debug ( ` del: ${ name } in zone ${ zoneName } of type ${ type } with values ${ JSON . stringify ( values ) } ` ) ;
const { accountId , zoneId } = await getZone ( domainConfig , zoneName ) ;
const records = await getDnsRecords ( domainConfig , zoneName , name , type ) ;
const ids = records . map ( r => r . id ) ;
for ( const id of ids ) {
const [ error , response ] = await safe ( superagent . del ( ` ${ DNSIMPLE _API } / ${ accountId } /zones/ ${ zoneId } /records/ ${ id } ` )
. set ( 'Authorization' , ` Bearer ${ domainConfig . accessToken } ` )
. retry ( 5 )
. timeout ( 30 * 1000 )
. ok ( ( ) => true ) ) ;
2024-11-19 17:08:19 +05:30
if ( error ) throw new BoxError ( BoxError . NETWORK _ERROR , error ) ;
2025-02-14 17:26:54 +01:00
if ( response . status === 401 ) throw new BoxError ( BoxError . ACCESS _DENIED , formatError ( response ) ) ;
if ( response . status === 404 ) continue ;
if ( response . status !== 204 ) throw new BoxError ( BoxError . EXTERNAL _ERROR , formatError ( response ) ) ;
2023-10-23 23:17:32 +02:00
}
}
async function wait ( domainObject , subdomain , type , value , options ) {
assert . strictEqual ( typeof domainObject , 'object' ) ;
assert . strictEqual ( typeof subdomain , 'string' ) ;
assert . strictEqual ( typeof type , 'string' ) ;
assert . strictEqual ( typeof value , 'string' ) ;
assert ( options && typeof options === 'object' ) ; // { interval: 5000, times: 50000 }
const fqdn = dns . fqdn ( subdomain , domainObject . domain ) ;
await waitForDns ( fqdn , domainObject . zoneName , type , value , options ) ;
}
async function verifyDomainConfig ( domainObject ) {
assert . strictEqual ( typeof domainObject , 'object' ) ;
const domainConfig = domainObject . config ,
zoneName = domainObject . zoneName ;
if ( ! domainConfig . accessToken || typeof domainConfig . accessToken !== 'string' ) throw new BoxError ( BoxError . BAD _FIELD , 'accessToken must be a non-empty string' ) ;
2025-03-02 07:27:09 +01:00
if ( 'customNameservers' in domainConfig && typeof domainConfig . customNameservers !== 'boolean' ) throw new BoxError ( BoxError . BAD _FIELD , 'customNameservers must be a boolean' ) ;
2023-10-23 23:17:32 +02:00
const ip = '127.0.0.1' ;
const credentials = {
accessToken : domainConfig . accessToken ,
2025-03-02 07:27:09 +01:00
customNameservers : ! ! domainConfig . customNameservers
2023-10-23 23:17:32 +02:00
} ;
if ( constants . TEST ) return credentials ; // this shouldn't be here
const [ error , nameservers ] = await safe ( dig . resolve ( zoneName , 'NS' , { timeout : 5000 } ) ) ;
if ( error && error . code === 'ENOTFOUND' ) throw new BoxError ( BoxError . BAD _FIELD , 'Unable to resolve nameservers for this domain' ) ;
if ( error || ! nameservers ) throw new BoxError ( BoxError . BAD _FIELD , error ? error . message : 'Unable to get nameservers' ) ;
if ( ! nameservers . every ( function ( n ) { return n . toLowerCase ( ) . indexOf ( 'dnsimple' ) !== - 1 ; } ) ) { // can be dnsimple.com or dnsimple-edge.org
debug ( 'verifyDomainConfig: %j does not contain dnsimple NS' , nameservers ) ;
2025-03-02 07:27:09 +01:00
if ( ! domainConfig . customNameservers ) throw new BoxError ( BoxError . BAD _FIELD , 'Domain nameservers are not set to dnsimple' ) ;
2023-10-23 23:17:32 +02:00
}
const location = 'cloudrontestdns' ;
await upsert ( domainObject , location , 'A' , [ ip ] ) ;
debug ( 'verifyDomainConfig: Test A record added' ) ;
await del ( domainObject , location , 'A' , [ ip ] ) ;
debug ( 'verifyDomainConfig: Test A record removed again' ) ;
return credentials ;
}
2025-10-08 12:04:31 +02:00
exports = module . exports = {
removePrivateFields ,
injectPrivateFields ,
upsert ,
get ,
del ,
wait ,
verifyDomainConfig
} ;