2026-04-11 05:17:49 -05:00
|
|
|
import { execFile, spawn } from 'child_process'
|
2026-04-05 10:45:21 -05:00
|
|
|
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
|
2026-04-11 05:17:49 -05:00
|
|
|
'useradd', 'userdel', 'usermod', 'chage', 'id',
|
2026-04-05 10:45:21 -05:00
|
|
|
// File operations
|
2026-04-11 05:17:49 -05:00
|
|
|
'mkdir', 'rmdir', 'rm', 'cp', 'mv', 'chmod', 'chown', 'ln', 'tar',
|
|
|
|
|
'find', 'du', 'df', 'stat',
|
|
|
|
|
// Web server
|
|
|
|
|
'apache2ctl', 'a2ensite', 'a2dissite', 'a2enmod', 'a2dismod',
|
2026-04-05 10:45:21 -05:00
|
|
|
// DNS
|
2026-04-11 05:17:49 -05:00
|
|
|
'pdns_control', 'pdnsutil',
|
2026-04-05 10:45:21 -05:00
|
|
|
// Mail
|
2026-04-11 05:17:49 -05:00
|
|
|
'dovecot', 'openssl',
|
2026-04-05 10:45:21 -05:00
|
|
|
// SSL
|
2026-04-11 05:17:49 -05:00
|
|
|
'certbot',
|
2026-04-05 10:45:21 -05:00
|
|
|
// Services
|
2026-04-11 05:17:49 -05:00
|
|
|
'systemctl',
|
2026-04-05 10:45:21 -05:00
|
|
|
// Network
|
2026-04-11 05:17:49 -05:00
|
|
|
'ss', 'ip', 'hostname',
|
2026-04-05 10:45:21 -05:00
|
|
|
// System
|
2026-04-11 05:17:49 -05:00
|
|
|
'uname', 'free', 'ps',
|
2026-04-05 10:45:21 -05:00
|
|
|
// PHP
|
2026-04-11 05:17:49 -05:00
|
|
|
'php8.2', 'php8.3',
|
2026-04-05 10:45:21 -05:00
|
|
|
// Fail2ban
|
|
|
|
|
'fail2ban-client',
|
2026-04-11 05:17:49 -05:00
|
|
|
// iptables
|
|
|
|
|
'iptables', 'netfilter-persistent',
|
2026-04-05 10:45:21 -05:00
|
|
|
])
|
|
|
|
|
|
2026-04-11 05:17:49 -05:00
|
|
|
// Shell metacharacters never allowed in arguments
|
2026-04-05 10:45:21 -05:00
|
|
|
const DANGEROUS_CHARS = /[;&|`$<>()\n\r\0]/
|
|
|
|
|
|
2026-04-11 05:17:49 -05:00
|
|
|
// Paths allowed for file read/write operations
|
2026-04-05 10:45:21 -05:00
|
|
|
const ALLOWED_PATH_PREFIXES = [
|
|
|
|
|
'/home/',
|
|
|
|
|
'/etc/apache2/',
|
|
|
|
|
'/etc/php/',
|
2026-04-11 05:17:49 -05:00
|
|
|
'/etc/pdns/',
|
|
|
|
|
'/etc/exim4/',
|
|
|
|
|
'/etc/dovecot/',
|
2026-04-05 10:45:21 -05:00
|
|
|
'/etc/letsencrypt/',
|
|
|
|
|
'/etc/fail2ban/',
|
2026-04-11 05:17:49 -05:00
|
|
|
'/etc/nixpanel/',
|
2026-04-05 10:45:21 -05:00
|
|
|
'/var/www/',
|
|
|
|
|
'/var/log/',
|
2026-04-11 05:17:49 -05:00
|
|
|
'/opt/nixpanel/',
|
2026-04-05 10:45:21 -05:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
function validateArg(arg: string): void {
|
|
|
|
|
if (DANGEROUS_CHARS.test(arg)) {
|
|
|
|
|
throw new Error(`Argument contains forbidden characters: ${arg}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 05:17:49 -05:00
|
|
|
function isAllowedPath(p: string): boolean {
|
|
|
|
|
return ALLOWED_PATH_PREFIXES.some(prefix => p.startsWith(prefix))
|
2026-04-05 10:45:21 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ExecResult {
|
|
|
|
|
stdout: string
|
|
|
|
|
stderr: string
|
|
|
|
|
exitCode: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ExecOptions {
|
|
|
|
|
timeout?: number
|
2026-04-11 05:17:49 -05:00
|
|
|
input?: string // data to pipe to stdin
|
2026-04-05 10:45:21 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Execute a whitelisted system command safely.
|
2026-04-11 05:17:49 -05:00
|
|
|
* Uses execFile (no shell). Throws if command is not in the allowlist
|
|
|
|
|
* or any argument contains shell metacharacters.
|
2026-04-05 10:45:21 -05:00
|
|
|
*/
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 05:17:49 -05:00
|
|
|
// If stdin input is needed, use spawn so we can write to stdin
|
|
|
|
|
if (options.input !== undefined) {
|
|
|
|
|
return execWithStdin(command, args, options.input, options.timeout ?? 30_000)
|
|
|
|
|
}
|
2026-04-05 10:45:21 -05:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const { stdout, stderr } = await execFileAsync(command, args, {
|
2026-04-11 05:17:49 -05:00
|
|
|
timeout: options.timeout ?? 30_000,
|
|
|
|
|
maxBuffer: 10 * 1024 * 1024,
|
2026-04-05 10:45:21 -05:00
|
|
|
})
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 05:17:49 -05:00
|
|
|
function execWithStdin(
|
|
|
|
|
command: string,
|
|
|
|
|
args: string[],
|
|
|
|
|
input: string,
|
|
|
|
|
timeout: number
|
|
|
|
|
): Promise<ExecResult> {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
|
|
|
let stdout = ''
|
|
|
|
|
let stderr = ''
|
|
|
|
|
|
|
|
|
|
child.stdout.on('data', (d: Buffer) => { stdout += d.toString() })
|
|
|
|
|
child.stderr.on('data', (d: Buffer) => { stderr += d.toString() })
|
|
|
|
|
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
child.kill()
|
|
|
|
|
resolve({ stdout: stdout.trim(), stderr: 'Command timed out', exitCode: 1 })
|
|
|
|
|
}, timeout)
|
|
|
|
|
|
|
|
|
|
child.on('close', (code) => {
|
|
|
|
|
clearTimeout(timer)
|
|
|
|
|
resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code ?? 1 })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
child.stdin.write(input)
|
|
|
|
|
child.stdin.end()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 10:45:21 -05:00
|
|
|
/**
|
2026-04-11 05:17:49 -05:00
|
|
|
* Write content to a file in an allowed path.
|
2026-04-05 10:45:21 -05:00
|
|
|
*/
|
2026-04-11 05:17:49 -05:00
|
|
|
export async function writeFile(filePath: string, content: string): Promise<void> {
|
|
|
|
|
if (!isAllowedPath(filePath)) {
|
|
|
|
|
throw new Error(`Path not in allowed list: ${filePath}`)
|
2026-04-05 10:45:21 -05:00
|
|
|
}
|
|
|
|
|
const { writeFile: fsWrite } = await import('fs/promises')
|
2026-04-11 05:17:49 -05:00
|
|
|
await fsWrite(filePath, content, 'utf8')
|
2026-04-05 10:45:21 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Read a file from an allowed path.
|
|
|
|
|
*/
|
2026-04-11 05:17:49 -05:00
|
|
|
export async function readFile(filePath: string): Promise<string> {
|
|
|
|
|
if (!isAllowedPath(filePath)) {
|
|
|
|
|
throw new Error(`Path not in allowed list: ${filePath}`)
|
2026-04-05 10:45:21 -05:00
|
|
|
}
|
|
|
|
|
const { readFile: fsRead } = await import('fs/promises')
|
2026-04-11 05:17:49 -05:00
|
|
|
return fsRead(filePath, 'utf8')
|
2026-04-05 10:45:21 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function isRoot(): boolean {
|
|
|
|
|
return process.getuid?.() === 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function validateUsername(username: string): boolean {
|
|
|
|
|
return /^[a-z][a-z0-9_-]{0,31}$/.test(username)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 05:17:49 -05:00
|
|
|
export function fileExists(filePath: string): boolean {
|
|
|
|
|
return existsSync(filePath)
|
2026-04-05 10:45:21 -05:00
|
|
|
}
|