2026-04-05 10:45:21 -05:00
|
|
|
import path from 'path'
|
|
|
|
|
import { config } from '../config.js'
|
|
|
|
|
import { exec, writeFile } from './exec.js'
|
|
|
|
|
|
|
|
|
|
export interface DnsZoneOptions {
|
|
|
|
|
domain: string
|
|
|
|
|
ip: string
|
|
|
|
|
ns1: string
|
|
|
|
|
ns2: string
|
|
|
|
|
mail?: string
|
|
|
|
|
serial?: number
|
|
|
|
|
ttl?: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Generate a BIND9 zone file for a domain.
|
|
|
|
|
*/
|
|
|
|
|
export function generateZoneFile(opts: DnsZoneOptions): string {
|
|
|
|
|
const {
|
|
|
|
|
domain,
|
|
|
|
|
ip,
|
|
|
|
|
ns1,
|
|
|
|
|
ns2,
|
|
|
|
|
mail = `mail.${opts.domain}`,
|
|
|
|
|
serial = Math.floor(Date.now() / 1000),
|
|
|
|
|
ttl = 3600,
|
|
|
|
|
} = opts
|
|
|
|
|
|
|
|
|
|
return `; Zone file for ${domain}
|
|
|
|
|
; Generated by NixPanel
|
|
|
|
|
$TTL ${ttl}
|
|
|
|
|
@ IN SOA ${ns1}. hostmaster.${domain}. (
|
|
|
|
|
${serial} ; Serial
|
|
|
|
|
3600 ; Refresh
|
|
|
|
|
1800 ; Retry
|
|
|
|
|
604800 ; Expire
|
|
|
|
|
300 ) ; Minimum TTL
|
|
|
|
|
|
|
|
|
|
; Name servers
|
|
|
|
|
@ IN NS ${ns1}.
|
|
|
|
|
@ IN NS ${ns2}.
|
|
|
|
|
|
|
|
|
|
; A records
|
|
|
|
|
@ IN A ${ip}
|
|
|
|
|
www IN A ${ip}
|
|
|
|
|
mail IN A ${ip}
|
|
|
|
|
ftp IN A ${ip}
|
2026-04-05 10:54:15 -05:00
|
|
|
nixclient IN A ${ip}
|
2026-04-05 10:45:21 -05:00
|
|
|
webmail IN A ${ip}
|
|
|
|
|
|
|
|
|
|
; MX record
|
|
|
|
|
@ IN MX 10 ${mail}.
|
|
|
|
|
|
|
|
|
|
; SPF
|
|
|
|
|
@ IN TXT "v=spf1 a mx ip4:${ip} ~all"
|
|
|
|
|
|
|
|
|
|
; DMARC
|
|
|
|
|
_dmarc IN TXT "v=DMARC1; p=none; rua=mailto:admin@${domain}"
|
|
|
|
|
`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Write zone file and update named.conf.local, then reload BIND.
|
|
|
|
|
*/
|
|
|
|
|
export async function createZone(opts: DnsZoneOptions): Promise<string> {
|
|
|
|
|
const zoneFile = path.join(config.paths.bindZones, `db.${opts.domain}`)
|
|
|
|
|
await writeFile(zoneFile, generateZoneFile(opts))
|
|
|
|
|
|
|
|
|
|
// Add zone declaration to named.conf.local (idempotent)
|
|
|
|
|
const namedConf = '/etc/bind/named.conf.local'
|
|
|
|
|
const zoneDecl = `
|
|
|
|
|
zone "${opts.domain}" {
|
|
|
|
|
type master;
|
|
|
|
|
file "${zoneFile}";
|
|
|
|
|
allow-transfer { any; };
|
|
|
|
|
};
|
|
|
|
|
`
|
|
|
|
|
// Read existing, only add if not already there
|
|
|
|
|
const { readFile } = await import('fs/promises')
|
|
|
|
|
let existing = ''
|
|
|
|
|
try {
|
|
|
|
|
existing = await readFile(namedConf, 'utf8')
|
|
|
|
|
} catch {
|
|
|
|
|
// file may not exist yet in test env
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!existing.includes(`zone "${opts.domain}"`)) {
|
|
|
|
|
const { appendFile } = await import('fs/promises')
|
|
|
|
|
await appendFile(namedConf, zoneDecl)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate zone
|
|
|
|
|
const check = await exec('named-checkzone', [opts.domain, zoneFile])
|
|
|
|
|
if (check.exitCode !== 0) {
|
|
|
|
|
throw new Error(`Zone validation failed: ${check.stderr}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await exec('rndc', ['reload'])
|
|
|
|
|
|
|
|
|
|
return zoneFile
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Remove a zone and reload BIND.
|
|
|
|
|
*/
|
|
|
|
|
export async function removeZone(domain: string): Promise<void> {
|
|
|
|
|
const zoneFile = path.join(config.paths.bindZones, `db.${domain}`)
|
|
|
|
|
await exec('rm', ['-f', zoneFile])
|
|
|
|
|
|
|
|
|
|
// Remove zone block from named.conf.local
|
|
|
|
|
const namedConf = '/etc/bind/named.conf.local'
|
|
|
|
|
const { readFile, writeFile: fsWrite } = await import('fs/promises')
|
|
|
|
|
try {
|
|
|
|
|
const content = await readFile(namedConf, 'utf8')
|
|
|
|
|
const cleaned = content.replace(
|
|
|
|
|
new RegExp(`zone\\s+"${domain.replace('.', '\\.')}\"[^}]+\\};\\s*`, 'g'),
|
|
|
|
|
''
|
|
|
|
|
)
|
|
|
|
|
await fsWrite(namedConf, cleaned, 'utf8')
|
|
|
|
|
} catch {
|
|
|
|
|
// best effort
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await exec('rndc', ['reload'])
|
|
|
|
|
}
|