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
This commit is contained in:
+85
-57
@@ -1,4 +1,5 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs/promises'
|
||||
import { config } from '../config.js'
|
||||
import { exec, writeFile } from './exec.js'
|
||||
|
||||
@@ -12,9 +13,6 @@ export interface DnsZoneOptions {
|
||||
ttl?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a BIND9 zone file for a domain.
|
||||
*/
|
||||
export function generateZoneFile(opts: DnsZoneOptions): string {
|
||||
const {
|
||||
domain,
|
||||
@@ -22,19 +20,19 @@ export function generateZoneFile(opts: DnsZoneOptions): string {
|
||||
ns1,
|
||||
ns2,
|
||||
mail = `mail.${opts.domain}`,
|
||||
serial = Math.floor(Date.now() / 1000),
|
||||
serial = generateSerial(),
|
||||
ttl = 3600,
|
||||
} = opts
|
||||
|
||||
return `; Zone file for ${domain}
|
||||
; Generated by NixPanel
|
||||
; 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
|
||||
${serial} ; Serial
|
||||
3600 ; Refresh
|
||||
1800 ; Retry
|
||||
604800 ; Expire
|
||||
300 ) ; Minimum TTL
|
||||
|
||||
; Name servers
|
||||
@ IN NS ${ns1}.
|
||||
@@ -44,12 +42,11 @@ $TTL ${ttl}
|
||||
@ IN A ${ip}
|
||||
www IN A ${ip}
|
||||
mail IN A ${ip}
|
||||
ftp IN A ${ip}
|
||||
nixclient IN A ${ip}
|
||||
webmail IN A ${ip}
|
||||
cpanel IN A ${ip}
|
||||
|
||||
; MX record
|
||||
@ IN MX 10 ${mail}.
|
||||
@ IN MX 10 ${mail}.
|
||||
|
||||
; SPF
|
||||
@ IN TXT "v=spf1 a mx ip4:${ip} ~all"
|
||||
@@ -60,66 +57,97 @@ _dmarc IN TXT "v=DMARC1; p=none; rua=mailto:admin@${domain}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Write zone file and update named.conf.local, then reload BIND.
|
||||
* 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.bindZones, `db.${opts.domain}`)
|
||||
const zoneFile = path.join(config.paths.pdnsZones, `db.${opts.domain}`)
|
||||
await writeFile(zoneFile, generateZoneFile(opts))
|
||||
await registerZoneInBindBackend(opts.domain, zoneFile)
|
||||
|
||||
// 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
|
||||
// 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'])
|
||||
}
|
||||
|
||||
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.
|
||||
* Remove a DNS zone and reload PowerDNS.
|
||||
*/
|
||||
export async function removeZone(domain: string): Promise<void> {
|
||||
const zoneFile = path.join(config.paths.bindZones, `db.${domain}`)
|
||||
await exec('rm', ['-f', zoneFile])
|
||||
const zoneFile = path.join(config.paths.pdnsZones, `db.${domain}`)
|
||||
await fs.unlink(zoneFile).catch(() => {})
|
||||
await unregisterZoneFromBindBackend(domain)
|
||||
await exec('systemctl', ['reload', 'pdns'])
|
||||
}
|
||||
|
||||
// Remove zone block from named.conf.local
|
||||
const namedConf = '/etc/bind/named.conf.local'
|
||||
const { readFile, writeFile: fsWrite } = await import('fs/promises')
|
||||
/**
|
||||
* 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 {
|
||||
const content = await readFile(namedConf, 'utf8')
|
||||
const cleaned = content.replace(
|
||||
new RegExp(`zone\\s+"${domain.replace('.', '\\.')}\"[^}]+\\};\\s*`, 'g'),
|
||||
''
|
||||
)
|
||||
await fsWrite(namedConf, cleaned, 'utf8')
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
await exec('rndc', ['reload'])
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user