Initial NixPanel Build
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
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}
|
||||
cpanel IN A ${ip}
|
||||
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'])
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { existsSync } from 'fs'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
// Commands allowed to be executed — nothing outside this list runs
|
||||
const ALLOWED_COMMANDS = new Set([
|
||||
// User management
|
||||
'useradd', 'userdel', 'usermod', 'passwd', 'chage', 'id', 'groups',
|
||||
// File operations
|
||||
'mkdir', 'rmdir', 'rm', 'cp', 'mv', 'chmod', 'chown', 'ln', 'tar', 'gzip', 'gunzip',
|
||||
'find', 'du', 'df', 'ls', 'stat',
|
||||
// Web servers
|
||||
'nginx', 'apache2', 'apache2ctl', 'a2ensite', 'a2dissite', 'a2enmod', 'a2dismod',
|
||||
// DNS
|
||||
'named', 'named-checkconf', 'named-checkzone', 'rndc',
|
||||
// Mail
|
||||
'postfix', 'postmap', 'postconf', 'dovecot', 'doveadm',
|
||||
// SSL
|
||||
'certbot', 'openssl',
|
||||
// FTP
|
||||
'vsftpd',
|
||||
// Services
|
||||
'systemctl', 'service',
|
||||
// Network
|
||||
'ss', 'ip', 'hostname', 'ping', 'traceroute', 'dig', 'nslookup',
|
||||
// System
|
||||
'uname', 'uptime', 'free', 'vmstat', 'iostat', 'top', 'ps', 'kill', 'pkill',
|
||||
'cat', 'grep', 'awk', 'sed', 'head', 'tail', 'wc', 'sort', 'uniq', 'cut',
|
||||
// Package managers
|
||||
'apt', 'apt-get', 'dpkg',
|
||||
// MySQL/MariaDB
|
||||
'mysql', 'mysqladmin', 'mysqlcheck', 'mysqldump',
|
||||
// PHP
|
||||
'php', 'php7.4', 'php8.0', 'php8.1', 'php8.2', 'php8.3', 'update-alternatives',
|
||||
// Misc
|
||||
'touch', 'echo', 'which', 'whoami', 'env',
|
||||
// Fail2ban
|
||||
'fail2ban-client',
|
||||
// UFW
|
||||
'ufw',
|
||||
])
|
||||
|
||||
// Characters never allowed in arguments (shell injection prevention)
|
||||
const DANGEROUS_CHARS = /[;&|`$<>()\n\r\0]/
|
||||
|
||||
// Paths outside these prefixes are blocked for file operations
|
||||
const ALLOWED_PATH_PREFIXES = [
|
||||
'/home/',
|
||||
'/etc/nginx/',
|
||||
'/etc/apache2/',
|
||||
'/etc/bind/',
|
||||
'/etc/postfix/',
|
||||
'/etc/dovecot/',
|
||||
'/etc/vsftpd',
|
||||
'/etc/php/',
|
||||
'/etc/letsencrypt/',
|
||||
'/etc/fail2ban/',
|
||||
'/var/www/',
|
||||
'/var/mail/',
|
||||
'/var/log/',
|
||||
'/var/spool/cron/',
|
||||
'/tmp/',
|
||||
'/usr/local/nixpanel/',
|
||||
]
|
||||
|
||||
function validateArg(arg: string): void {
|
||||
if (DANGEROUS_CHARS.test(arg)) {
|
||||
throw new Error(`Argument contains forbidden characters: ${arg}`)
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedPath(path: string): boolean {
|
||||
return ALLOWED_PATH_PREFIXES.some(prefix => path.startsWith(prefix))
|
||||
}
|
||||
|
||||
export interface ExecResult {
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number
|
||||
}
|
||||
|
||||
export interface ExecOptions {
|
||||
timeout?: number
|
||||
allowedPaths?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a whitelisted system command safely.
|
||||
* Throws if the command is not in the allowlist or args contain shell metacharacters.
|
||||
*/
|
||||
export async function exec(
|
||||
command: string,
|
||||
args: string[] = [],
|
||||
options: ExecOptions = {}
|
||||
): Promise<ExecResult> {
|
||||
if (!ALLOWED_COMMANDS.has(command)) {
|
||||
throw new Error(`Command not allowed: ${command}`)
|
||||
}
|
||||
|
||||
for (const arg of args) {
|
||||
validateArg(arg)
|
||||
}
|
||||
|
||||
const timeout = options.timeout ?? 30_000
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(command, args, {
|
||||
timeout,
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB
|
||||
})
|
||||
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 }
|
||||
} catch (err: unknown) {
|
||||
const e = err as NodeJS.ErrnoException & { stdout?: string; stderr?: string; code?: number }
|
||||
return {
|
||||
stdout: (e.stdout ?? '').trim(),
|
||||
stderr: (e.stderr ?? e.message ?? '').trim(),
|
||||
exitCode: typeof e.code === 'number' ? e.code : 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write content to a file in an allowed path using a safe method (no shell).
|
||||
*/
|
||||
export async function writeFile(path: string, content: string): Promise<void> {
|
||||
if (!isAllowedPath(path)) {
|
||||
throw new Error(`Path not in allowed list: ${path}`)
|
||||
}
|
||||
const { writeFile: fsWrite } = await import('fs/promises')
|
||||
await fsWrite(path, content, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file from an allowed path.
|
||||
*/
|
||||
export async function readFile(path: string): Promise<string> {
|
||||
if (!isAllowedPath(path)) {
|
||||
throw new Error(`Path not in allowed list: ${path}`)
|
||||
}
|
||||
const { readFile: fsRead } = await import('fs/promises')
|
||||
return fsRead(path, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running as root.
|
||||
*/
|
||||
export function isRoot(): boolean {
|
||||
return process.getuid?.() === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a Linux username.
|
||||
*/
|
||||
export function validateUsername(username: string): boolean {
|
||||
return /^[a-z][a-z0-9_-]{0,31}$/.test(username)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a domain name.
|
||||
*/
|
||||
export function validateDomain(domain: string): boolean {
|
||||
return /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(domain)
|
||||
}
|
||||
|
||||
/**
|
||||
* File exists check without shell.
|
||||
*/
|
||||
export function fileExists(path: string): boolean {
|
||||
return existsSync(path)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import path from 'path'
|
||||
import { config } from '../config.js'
|
||||
import { exec, writeFile } from './exec.js'
|
||||
|
||||
export interface VhostOptions {
|
||||
domain: string
|
||||
username: string
|
||||
documentRoot: string
|
||||
phpVersion?: string
|
||||
sslEnabled?: boolean
|
||||
certPath?: string
|
||||
keyPath?: string
|
||||
chainPath?: string
|
||||
aliases?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an nginx vhost config for a hosting account.
|
||||
*/
|
||||
export function generateVhost(opts: VhostOptions): string {
|
||||
const {
|
||||
domain,
|
||||
username,
|
||||
documentRoot,
|
||||
phpVersion = '8.3',
|
||||
sslEnabled = false,
|
||||
certPath,
|
||||
keyPath,
|
||||
chainPath,
|
||||
aliases = [],
|
||||
} = opts
|
||||
|
||||
const phpSocket = `/run/php/php${phpVersion}-fpm-${username}.sock`
|
||||
const serverName = [domain, ...aliases].join(' ')
|
||||
|
||||
const sslBlock = sslEnabled && certPath && keyPath
|
||||
? `
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
|
||||
ssl_certificate ${certPath};
|
||||
ssl_certificate_key ${keyPath};
|
||||
${chainPath ? `ssl_trusted_certificate ${chainPath};` : ''}
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
`
|
||||
: `
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
`
|
||||
|
||||
const redirectBlock = sslEnabled
|
||||
? `
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ${serverName};
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
`
|
||||
: ''
|
||||
|
||||
return `${redirectBlock}
|
||||
server {
|
||||
${sslBlock.trim()}
|
||||
server_name ${serverName};
|
||||
|
||||
root ${documentRoot};
|
||||
index index.php index.html index.htm;
|
||||
|
||||
access_log /var/log/nginx/${username}.access.log;
|
||||
error_log /var/log/nginx/${username}.error.log;
|
||||
|
||||
client_max_body_size 64M;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \\.php$ {
|
||||
include snippets/fastcgi-php.conf;
|
||||
fastcgi_pass unix:${phpSocket};
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
|
||||
location ~ /\\.ht {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location ~ /\\.git {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# Let's Encrypt webroot
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/letsencrypt;
|
||||
}
|
||||
}
|
||||
`.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an nginx vhost to disk and enable it.
|
||||
*/
|
||||
export async function enableVhost(opts: VhostOptions): Promise<void> {
|
||||
const confPath = path.join(config.paths.nginxSites, `${opts.domain}.conf`)
|
||||
const enabledPath = path.join(config.paths.nginxEnabled, `${opts.domain}.conf`)
|
||||
|
||||
await writeFile(confPath, generateVhost(opts))
|
||||
|
||||
// Symlink to sites-enabled
|
||||
await exec('ln', ['-sf', confPath, enabledPath])
|
||||
|
||||
// Test and reload
|
||||
const test = await exec('nginx', ['-t'])
|
||||
if (test.exitCode !== 0) {
|
||||
throw new Error(`nginx config test failed: ${test.stderr}`)
|
||||
}
|
||||
|
||||
await exec('systemctl', ['reload', 'nginx'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a vhost config and reload nginx.
|
||||
*/
|
||||
export async function disableVhost(domain: string): Promise<void> {
|
||||
const confPath = path.join(config.paths.nginxSites, `${domain}.conf`)
|
||||
const enabledPath = path.join(config.paths.nginxEnabled, `${domain}.conf`)
|
||||
|
||||
await exec('rm', ['-f', enabledPath])
|
||||
await exec('rm', ['-f', confPath])
|
||||
await exec('systemctl', ['reload', 'nginx'])
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { readFile } from 'fs/promises'
|
||||
import { exec } from './exec.js'
|
||||
import type { SystemStats, ServiceStatus } from '../types/index.js'
|
||||
|
||||
/**
|
||||
* Read CPU usage (0-100%) from /proc/stat.
|
||||
*/
|
||||
async function getCpuUsage(): Promise<number> {
|
||||
try {
|
||||
const stat1 = await readFile('/proc/stat', 'utf8')
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
const stat2 = await readFile('/proc/stat', 'utf8')
|
||||
|
||||
const parse = (s: string) => {
|
||||
const line = s.split('\n')[0].split(' ').filter(Boolean).slice(1).map(Number)
|
||||
const idle = line[3] + line[4]
|
||||
const total = line.reduce((a, b) => a + b, 0)
|
||||
return { idle, total }
|
||||
}
|
||||
|
||||
const s1 = parse(stat1)
|
||||
const s2 = parse(stat2)
|
||||
const idleDiff = s2.idle - s1.idle
|
||||
const totalDiff = s2.total - s1.total
|
||||
return Math.round((1 - idleDiff / totalDiff) * 100)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get memory stats from /proc/meminfo (bytes).
|
||||
*/
|
||||
async function getMemoryStats(): Promise<SystemStats['memory']> {
|
||||
try {
|
||||
const content = await readFile('/proc/meminfo', 'utf8')
|
||||
const get = (key: string) => {
|
||||
const match = content.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`))
|
||||
return match ? parseInt(match[1], 10) * 1024 : 0
|
||||
}
|
||||
const total = get('MemTotal')
|
||||
const free = get('MemFree')
|
||||
const buffers = get('Buffers')
|
||||
const cached = get('Cached')
|
||||
const used = total - free - buffers - cached
|
||||
return {
|
||||
total,
|
||||
used,
|
||||
free: total - used,
|
||||
percent: Math.round((used / total) * 100),
|
||||
}
|
||||
} catch {
|
||||
return { total: 0, used: 0, free: 0, percent: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get disk usage for / (bytes).
|
||||
*/
|
||||
async function getDiskStats(): Promise<SystemStats['disk']> {
|
||||
try {
|
||||
const result = await exec('df', ['-B1', '/'])
|
||||
const lines = result.stdout.split('\n')
|
||||
const parts = lines[1]?.split(/\s+/)
|
||||
if (!parts) return { total: 0, used: 0, free: 0, percent: 0 }
|
||||
const total = parseInt(parts[1], 10)
|
||||
const used = parseInt(parts[2], 10)
|
||||
const free = parseInt(parts[3], 10)
|
||||
return { total, used, free, percent: Math.round((used / total) * 100) }
|
||||
} catch {
|
||||
return { total: 0, used: 0, free: 0, percent: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system load averages from /proc/loadavg.
|
||||
*/
|
||||
async function getLoadAvg(): Promise<[number, number, number]> {
|
||||
try {
|
||||
const content = await readFile('/proc/loadavg', 'utf8')
|
||||
const parts = content.split(' ')
|
||||
return [parseFloat(parts[0]), parseFloat(parts[1]), parseFloat(parts[2])]
|
||||
} catch {
|
||||
return [0, 0, 0]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather all system stats in parallel.
|
||||
*/
|
||||
export async function getSystemStats(): Promise<SystemStats> {
|
||||
const [cpu, memory, disk, load] = await Promise.all([
|
||||
getCpuUsage(),
|
||||
getMemoryStats(),
|
||||
getDiskStats(),
|
||||
getLoadAvg(),
|
||||
])
|
||||
|
||||
return {
|
||||
cpu,
|
||||
memory,
|
||||
disk,
|
||||
load,
|
||||
uptime: Math.floor(process.uptime()),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the status of a systemd service.
|
||||
*/
|
||||
export async function getServiceStatus(name: string): Promise<ServiceStatus> {
|
||||
const [activeResult, enabledResult] = await Promise.all([
|
||||
exec('systemctl', ['is-active', name]),
|
||||
exec('systemctl', ['is-enabled', name]),
|
||||
])
|
||||
|
||||
const active = activeResult.stdout.trim()
|
||||
const enabled = enabledResult.stdout.trim() === 'enabled'
|
||||
|
||||
let status: ServiceStatus['status'] = 'unknown'
|
||||
if (active === 'active') status = 'running'
|
||||
else if (active === 'inactive') status = 'stopped'
|
||||
else if (active === 'failed') status = 'failed'
|
||||
|
||||
return { name, status, enabled }
|
||||
}
|
||||
|
||||
export const MANAGED_SERVICES = [
|
||||
'nginx', 'apache2', 'mysql', 'mariadb',
|
||||
'postfix', 'dovecot', 'vsftpd', 'bind9',
|
||||
'fail2ban', 'ufw', 'cron', 'ssh',
|
||||
]
|
||||
Reference in New Issue
Block a user