Files
nixpanel-server/server/src/core/exec.ts
T

181 lines
4.6 KiB
TypeScript
Raw Normal View History

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
'useradd', 'userdel', 'usermod', 'chage', 'id',
2026-04-05 10:45:21 -05:00
// File operations
'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
'pdns_control', 'pdnsutil',
2026-04-05 10:45:21 -05:00
// Mail
'dovecot', 'openssl',
2026-04-05 10:45:21 -05:00
// SSL
'certbot',
2026-04-05 10:45:21 -05:00
// Services
'systemctl',
2026-04-05 10:45:21 -05:00
// Network
'ss', 'ip', 'hostname',
2026-04-05 10:45:21 -05:00
// System
'uname', 'uptime', 'free', 'ps', 'kill',
2026-04-05 10:45:21 -05:00
// PHP
'php8.2', 'php8.3',
2026-04-05 10:45:21 -05:00
// Fail2ban
'fail2ban-client',
// iptables
'iptables', 'netfilter-persistent',
2026-04-05 10:45:21 -05:00
])
// Shell metacharacters never allowed in arguments
2026-04-05 10:45:21 -05:00
const DANGEROUS_CHARS = /[;&|`$<>()\n\r\0]/
// Paths allowed for file read/write operations
2026-04-05 10:45:21 -05:00
const ALLOWED_PATH_PREFIXES = [
'/home/',
'/etc/apache2/',
'/etc/php/',
'/etc/pdns/',
'/etc/exim4/',
'/etc/dovecot/',
2026-04-05 10:45:21 -05:00
'/etc/letsencrypt/',
'/etc/fail2ban/',
'/etc/nixpanel/',
2026-04-05 10:45:21 -05:00
'/var/www/',
'/var/log/',
'/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}`)
}
}
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
input?: string // data to pipe to stdin
2026-04-05 10:45:21 -05:00
}
/**
* Execute a whitelisted system command safely.
* 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)
}
// 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, {
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,
}
}
}
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
/**
* Write content to a file in an allowed path.
2026-04-05 10:45:21 -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')
await fsWrite(filePath, content, 'utf8')
2026-04-05 10:45:21 -05:00
}
/**
* Read a file from an allowed path.
*/
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')
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)
}
export function fileExists(filePath: string): boolean {
return existsSync(filePath)
2026-04-05 10:45:21 -05:00
}