Migrate codebase from CommonJS to ES Modules
- Convert all require()/module.exports to import/export across 260+ files
- Add "type": "module" to package.json to enable ESM by default
- Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible
- Convert eslint.config.js to ESM with sourceType: "module"
- Replace __dirname/__filename with import.meta.dirname/import.meta.filename
- Replace require.main === module with process.argv[1] === import.meta.filename
- Remove 'use strict' directives (implicit in ESM)
- Convert dynamic require() in switch statements to static import lookup maps
(dns.js, domains.js, backupformats.js, backupsites.js, network.js)
- Extract self-referencing exports.CONSTANT patterns into standalone const
declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.)
- Lazify SERVICES object in services.js to avoid circular dependency TDZ issues
- Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests
- Add _setMockApp() to ldapserver.js for ESM-safe test mocking
- Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests
- Convert backupupload.js to use dynamic imports so --check exits before
loading the module graph (which requires BOX_ENV)
- Update check-install to use ESM import for infra_version.js
- Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations)
- All 1315 tests passing
Migration stats (AI-assisted using Cursor with Claude):
- Wall clock time: ~3-4 hours
- Assistant completions: ~80-100
- Estimated token usage: ~1-2M tokens
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:53:14 +01:00
import assert from 'node:assert' ;
import BoxError from '../boxerror.js' ;
import constants from '../constants.js' ;
2026-03-12 22:55:28 +05:30
import logger from '../logger.js' ;
2026-02-14 15:43:24 +01:00
import dig from '../dig.js' ;
import dns from '../dns.js' ;
Migrate codebase from CommonJS to ES Modules
- Convert all require()/module.exports to import/export across 260+ files
- Add "type": "module" to package.json to enable ESM by default
- Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible
- Convert eslint.config.js to ESM with sourceType: "module"
- Replace __dirname/__filename with import.meta.dirname/import.meta.filename
- Replace require.main === module with process.argv[1] === import.meta.filename
- Remove 'use strict' directives (implicit in ESM)
- Convert dynamic require() in switch statements to static import lookup maps
(dns.js, domains.js, backupformats.js, backupsites.js, network.js)
- Extract self-referencing exports.CONSTANT patterns into standalone const
declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.)
- Lazify SERVICES object in services.js to avoid circular dependency TDZ issues
- Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests
- Add _setMockApp() to ldapserver.js for ESM-safe test mocking
- Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests
- Convert backupupload.js to use dynamic imports so --check exits before
loading the module graph (which requires BOX_ENV)
- Update check-install to use ESM import for infra_version.js
- Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations)
- All 1315 tests passing
Migration stats (AI-assisted using Cursor with Claude):
- Wall clock time: ~3-4 hours
- Assistant completions: ~80-100
- Estimated token usage: ~1-2M tokens
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:53:14 +01:00
import safe from 'safetydance' ;
import superagent from '@cloudron/superagent' ;
import waitForDns from './waitfordns.js' ;
2026-03-12 22:55:28 +05:30
const { log , trace } = logger ( 'dns/hetzner' ) ;
Migrate codebase from CommonJS to ES Modules
- Convert all require()/module.exports to import/export across 260+ files
- Add "type": "module" to package.json to enable ESM by default
- Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible
- Convert eslint.config.js to ESM with sourceType: "module"
- Replace __dirname/__filename with import.meta.dirname/import.meta.filename
- Replace require.main === module with process.argv[1] === import.meta.filename
- Remove 'use strict' directives (implicit in ESM)
- Convert dynamic require() in switch statements to static import lookup maps
(dns.js, domains.js, backupformats.js, backupsites.js, network.js)
- Extract self-referencing exports.CONSTANT patterns into standalone const
declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.)
- Lazify SERVICES object in services.js to avoid circular dependency TDZ issues
- Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests
- Add _setMockApp() to ldapserver.js for ESM-safe test mocking
- Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests
- Convert backupupload.js to use dynamic imports so --check exits before
loading the module graph (which requires BOX_ENV)
- Update check-install to use ESM import for infra_version.js
- Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations)
- All 1315 tests passing
Migration stats (AI-assisted using Cursor with Claude):
- Wall clock time: ~3-4 hours
- Assistant completions: ~80-100
- Estimated token usage: ~1-2M tokens
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:53:14 +01:00
2025-10-08 20:11:55 +02:00
2025-10-20 15:17:02 +02:00
const ENDPOINT = 'https://dns.hetzner.com/api/v1' ;
2022-05-02 22:00:42 -07:00
function formatError ( response ) {
2025-02-14 17:26:54 +01:00
return ` Hetzner DNS error ${ response . status } ${ response . text } ` ;
2022-05-02 22:00:42 -07:00
}
function removePrivateFields ( domainObject ) {
2025-10-08 12:04:31 +02:00
delete domainObject . config . token ;
2022-05-02 22:00:42 -07:00
return domainObject ;
}
function injectPrivateFields ( newConfig , currentConfig ) {
2025-10-08 12:04:31 +02:00
if ( ! Object . hasOwn ( newConfig , 'token' ) ) newConfig . token = currentConfig . token ;
2022-05-02 22:00:42 -07:00
}
async function getZone ( domainConfig , zoneName ) {
assert . strictEqual ( typeof domainConfig , 'object' ) ;
assert . strictEqual ( typeof zoneName , 'string' ) ;
const [ error , response ] = await safe ( superagent . get ( ` ${ ENDPOINT } /zones ` )
2025-10-20 15:17:02 +02:00
. set ( 'Auth-API-Token' , domainConfig . token )
2022-05-02 22:00:42 -07:00
. query ( { search _name : zoneName } )
. timeout ( 30 * 1000 )
. retry ( 5 )
. 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 || response . status === 403 ) throw new BoxError ( BoxError . ACCESS _DENIED , formatError ( response ) ) ;
if ( response . status !== 200 ) throw new BoxError ( BoxError . EXTERNAL _ERROR , formatError ( response ) ) ;
2022-05-02 22:00:42 -07:00
if ( ! Array . isArray ( response . body . zones ) ) throw new BoxError ( BoxError . EXTERNAL _ERROR , formatError ( response ) ) ;
const zone = response . body . zones . filter ( z => z . name === zoneName ) ;
if ( zone . length === 0 ) throw new BoxError ( BoxError . NOT _FOUND , formatError ( response ) ) ;
return zone [ 0 ] ;
}
async function getZoneRecords ( domainConfig , zone , name , type ) {
assert . strictEqual ( typeof domainConfig , 'object' ) ;
assert . strictEqual ( typeof zone , 'object' ) ;
assert . strictEqual ( typeof name , 'string' ) ;
assert . strictEqual ( typeof type , 'string' ) ;
let page = 1 , matchingRecords = [ ] ;
2026-03-12 22:55:28 +05:30
log ( ` getZoneRecords: getting dns records of ${ zone . name } with ${ name } and type ${ type } ` ) ;
2022-05-02 22:00:42 -07:00
const perPage = 50 ;
while ( true ) {
const [ error , response ] = await safe ( superagent . get ( ` ${ ENDPOINT } /records ` )
2025-10-20 15:17:02 +02:00
. set ( 'Auth-API-Token' , domainConfig . token )
2022-05-02 22:00:42 -07:00
. query ( { zone _id : zone . id , page , per _page : perPage } )
. timeout ( 30 * 1000 )
. retry ( 5 )
. 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 === 404 ) throw new BoxError ( BoxError . NOT _FOUND , formatError ( response ) ) ;
if ( response . status === 401 || response . status === 403 ) throw new BoxError ( BoxError . ACCESS _DENIED , formatError ( response ) ) ;
if ( response . status !== 200 ) throw new BoxError ( BoxError . EXTERNAL _ERROR , formatError ( response ) ) ;
2022-05-02 22:00:42 -07:00
matchingRecords = matchingRecords . concat ( response . body . records . filter ( function ( record ) {
return ( record . type === type && record . name === name ) ;
} ) ) ;
if ( response . body . records . length < perPage ) break ;
++ page ;
}
return matchingRecords ;
}
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 ) || '@' ;
2026-03-12 22:55:28 +05:30
log ( ` upsert: ${ name } for zone ${ zoneName } of type ${ type } with values ${ JSON . stringify ( values ) } ` ) ;
2022-05-02 22:00:42 -07:00
const zone = await getZone ( domainConfig , zoneName ) ;
const records = await getZoneRecords ( domainConfig , zone , name , type ) ;
// used to track available records to update instead of create
let i = 0 ;
2024-11-19 17:08:19 +05:30
for ( const value of values ) {
2022-05-02 22:00:42 -07:00
const data = {
type ,
name ,
value ,
ttl : 60 ,
zone _id : zone . id
} ;
if ( i >= records . length ) {
const [ error , response ] = await safe ( superagent . post ( ` ${ ENDPOINT } /records ` )
2025-10-20 15:17:02 +02:00
. set ( 'Auth-API-Token' , domainConfig . token )
2022-05-02 22:00:42 -07:00
. send ( data )
. timeout ( 30 * 1000 )
. retry ( 5 )
. 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 === 403 || response . status === 401 ) throw new BoxError ( BoxError . ACCESS _DENIED , formatError ( response ) ) ;
if ( response . status === 422 ) throw new BoxError ( BoxError . BAD _FIELD , response . body . message ) ;
if ( response . status !== 200 ) throw new BoxError ( BoxError . EXTERNAL _ERROR , formatError ( response ) ) ;
2022-05-02 22:00:42 -07:00
} else {
const [ error , response ] = await safe ( superagent . put ( ` ${ ENDPOINT } /records/ ${ records [ i ] . id } ` )
2025-10-20 15:17:02 +02:00
. set ( 'Auth-API-Token' , domainConfig . token )
2022-05-02 22:00:42 -07:00
. send ( data )
. timeout ( 30 * 1000 )
. retry ( 5 )
. 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 === 403 || response . status === 401 ) throw new BoxError ( BoxError . ACCESS _DENIED , formatError ( response ) ) ;
if ( response . status === 422 ) throw new BoxError ( BoxError . BAD _FIELD , response . body . message ) ;
if ( response . status !== 200 ) throw new BoxError ( BoxError . EXTERNAL _ERROR , formatError ( response ) ) ;
2022-05-02 22:00:42 -07:00
}
}
for ( let j = values . length + 1 ; j < records . length ; j ++ ) {
const [ error ] = await safe ( superagent . del ( ` ${ ENDPOINT } /records/ ${ records [ j ] . id } ` )
2025-10-20 15:17:02 +02:00
. set ( 'Auth-API-Token' , domainConfig . token )
2022-05-02 22:00:42 -07:00
. timeout ( 30 * 1000 )
. retry ( 5 )
. ok ( ( ) => true ) ) ;
2026-03-12 22:55:28 +05:30
if ( error ) log ( ` upsert: error removing record ${ records [ j ] . id } : ${ error . message } ` ) ;
2022-05-02 22:00:42 -07:00
}
2026-03-12 22:55:28 +05:30
log ( 'upsert: completed' ) ;
2022-05-02 22:00:42 -07:00
}
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 zone = await getZone ( domainConfig , zoneName ) ;
const result = await getZoneRecords ( domainConfig , zone , name , type ) ;
return result . map ( function ( record ) { return record . value ; } ) ;
}
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 ) || '@' ;
const zone = await getZone ( domainConfig , zoneName ) ;
const records = await getZoneRecords ( domainConfig , zone , name , type ) ;
if ( records . length === 0 ) return ;
const matchingRecords = records . filter ( function ( record ) { return values . some ( function ( value ) { return value === record . value ; } ) ; } ) ;
if ( matchingRecords . length === 0 ) return ;
for ( const r of matchingRecords ) {
const [ error , response ] = await safe ( superagent . del ( ` ${ ENDPOINT } /records/ ${ r . id } ` )
2025-10-20 15:17:02 +02:00
. set ( 'Auth-API-Token' , domainConfig . token )
2022-05-02 22:00:42 -07:00
. timeout ( 30 * 1000 )
. retry ( 5 )
. 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 === 404 ) return ;
if ( response . status === 403 || response . status === 401 ) throw new BoxError ( BoxError . ACCESS _DENIED , formatError ( response ) ) ;
if ( response . status !== 200 ) throw new BoxError ( BoxError . EXTERNAL _ERROR , formatError ( response ) ) ;
2022-05-02 22:00:42 -07: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 }
2022-11-28 21:23:06 +01:00
const fqdn = dns . fqdn ( subdomain , domainObject . domain ) ;
2022-05-02 22:00:42 -07:00
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 . token || typeof domainConfig . token !== 'string' ) throw new BoxError ( BoxError . BAD _FIELD , 'token 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' ) ;
2022-05-02 22:00:42 -07:00
const ip = '127.0.0.1' ;
const credentials = {
2025-03-02 07:27:09 +01:00
token : domainConfig . token ,
customNameservers : ! ! domainConfig . customNameservers
2022-05-02 22:00:42 -07:00
} ;
2023-10-01 13:52:19 +05:30
if ( constants . TEST ) return credentials ; // this shouldn't be here
2022-05-02 22:00:42 -07:00
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' ) ;
// https://docs.hetzner.com/dns-console/dns/general/dns-overview#the-hetzner-online-name-servers-are
2023-12-05 18:13:34 +01:00
if ( ! nameservers . every ( function ( n ) { return n . toLowerCase ( ) . search ( /hetzner|your-server|second-ns/ ) !== - 1 ; } ) ) {
2026-03-12 22:55:28 +05:30
log ( 'verifyDomainConfig: %j does not contain Hetzner 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 Hetzner' ) ;
2022-05-02 22:00:42 -07:00
}
const location = 'cloudrontestdns' ;
await upsert ( domainObject , location , 'A' , [ ip ] ) ;
2026-03-12 22:55:28 +05:30
log ( 'verifyDomainConfig: Test A record added' ) ;
2022-05-02 22:00:42 -07:00
await del ( domainObject , location , 'A' , [ ip ] ) ;
2026-03-12 22:55:28 +05:30
log ( 'verifyDomainConfig: Test A record removed again' ) ;
2022-05-02 22:00:42 -07:00
return credentials ;
}
2025-10-08 12:04:31 +02:00
2026-02-14 15:43:24 +01:00
export default {
removePrivateFields ,
injectPrivateFields ,
upsert ,
get ,
del ,
wait ,
verifyDomainConfig
} ;