Files
nixpanel-server/server/src/core/dns.ts
T
NixReaper 8ff00c544c feat: v0.4.0 — Apache service stack, shell access, log viewer, changelog
Service stack (backend):
- webserver.ts rewritten: apache2/php-fpm routes replacing nginx; per-account
  Apache log endpoint; PHP-FPM reload per version; apache2ctl configtest/reload
- accounts.ts: added PUT /:id/shell to toggle /bin/bash vs /usr/sbin/nologin,
  runs usermod -s and writes audit log

New UI features (nixserver):
- ShellAccess page: grant/revoke SSH access per account via AccountPicker
- ApacheLogs page: live log viewer (access/error) per account; replaces stub
- NginxLogs: re-exports ApacheLogs (NixPanel uses Apache, not NGINX)
- WebServer page: shows apache2, php8.2-fpm, php8.3-fpm status with reload
  buttons; config test; Apache log viewer

Bug fixes:
- Accounts.tsx: Lucide icon size prop type widened (string|number); removed
  unused Users import; dropped duplicate NGINX log card
- Removed soon badge from ShellAccess and ApacheLogs entries

Project:
- CHANGELOG.md created with full history from 0.2.x through 0.4.0
- All packages bumped from 0.3.2 to 0.4.0
2026-04-11 05:17:49 -05:00

154 lines
4.2 KiB
TypeScript

import path from 'path'
import fs from 'fs/promises'
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
}
export function generateZoneFile(opts: DnsZoneOptions): string {
const {
domain,
ip,
ns1,
ns2,
mail = `mail.${opts.domain}`,
serial = generateSerial(),
ttl = 3600,
} = opts
return `; Zone file for ${domain}
; Managed by NixPanel — do not edit manually
$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}
webmail IN A ${ip}
cpanel 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}"
`
}
/**
* Create a DNS zone: write the zone file and register it with PowerDNS
* bind backend, then reload.
*/
export async function createZone(opts: DnsZoneOptions): Promise<string> {
const zoneFile = path.join(config.paths.pdnsZones, `db.${opts.domain}`)
await writeFile(zoneFile, generateZoneFile(opts))
await registerZoneInBindBackend(opts.domain, zoneFile)
// Tell PowerDNS about the new zone without a full restart
const reload = await exec('pdns_control', ['bind-add-zone', opts.domain, zoneFile])
if (reload.exitCode !== 0) {
// Fall back to full reload if bind-add-zone not supported
await exec('systemctl', ['reload', 'pdns'])
}
return zoneFile
}
/**
* Remove a DNS zone and reload PowerDNS.
*/
export async function removeZone(domain: string): Promise<void> {
const zoneFile = path.join(config.paths.pdnsZones, `db.${domain}`)
await fs.unlink(zoneFile).catch(() => {})
await unregisterZoneFromBindBackend(domain)
await exec('systemctl', ['reload', 'pdns'])
}
/**
* Update just the serial in an existing zone file to force secondary
* nameservers to re-transfer the zone.
*/
export async function bumpSerial(domain: string): Promise<void> {
const zoneFile = path.join(config.paths.pdnsZones, `db.${domain}`)
let content: string
try {
content = await fs.readFile(zoneFile, 'utf8')
} catch {
throw new Error(`Zone file not found for ${domain}`)
}
const newSerial = generateSerial()
const updated = content.replace(
/(\d{10})\s*;\s*Serial/,
`${newSerial} ; Serial`
)
await fs.writeFile(zoneFile, updated, 'utf8')
await exec('pdns_control', ['bind-reload-now', domain])
}
/**
* Manage /etc/pdns/bindbackend.conf — the file that lists all zones
* PowerDNS should serve. Each zone gets one line:
* zone=domain.com:/etc/pdns/zones/db.domain.com
*/
async function registerZoneInBindBackend(domain: string, zoneFile: string): Promise<void> {
const backendConf = config.paths.pdnsBindBackend
let existing = ''
try {
existing = await fs.readFile(backendConf, 'utf8')
} catch {
// File may not exist yet on first zone creation
}
const entry = `zone=${domain}:${zoneFile}`
if (!existing.includes(`zone=${domain}`)) {
await fs.appendFile(backendConf, `${entry}\n`)
}
}
async function unregisterZoneFromBindBackend(domain: string): Promise<void> {
const backendConf = config.paths.pdnsBindBackend
try {
const content = await fs.readFile(backendConf, 'utf8')
const cleaned = content
.split('\n')
.filter(line => !line.startsWith(`zone=${domain}:`))
.join('\n')
await fs.writeFile(backendConf, cleaned, 'utf8')
} catch {
// best effort
}
}
/**
* Generate a YYYYMMDDNN-style serial number based on today's date.
* Uses seconds since epoch truncated to 10 digits as a simpler alternative.
*/
function generateSerial(): number {
const now = new Date()
const date = now.toISOString().slice(0, 10).replace(/-/g, '')
// Append 01 as the revision — sufficient for daily zone changes
return parseInt(`${date}01`, 10)
}