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:
NixReaper
2026-04-11 05:17:49 -05:00
parent 10e31482b0
commit 8ff00c544c
65 changed files with 11223 additions and 1259 deletions
+73 -65
View File
@@ -1,4 +1,4 @@
import { execFile } from 'child_process'
import { execFile, spawn } from 'child_process'
import { promisify } from 'util'
import { existsSync } from 'fs'
@@ -7,62 +7,49 @@ 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',
'useradd', 'userdel', 'usermod', 'chage', 'id',
// 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',
'mkdir', 'rmdir', 'rm', 'cp', 'mv', 'chmod', 'chown', 'ln', 'tar',
'find', 'du', 'df', 'stat',
// Web server
'apache2ctl', 'a2ensite', 'a2dissite', 'a2enmod', 'a2dismod',
// DNS
'named', 'named-checkconf', 'named-checkzone', 'rndc',
'pdns_control', 'pdnsutil',
// Mail
'postfix', 'postmap', 'postconf', 'dovecot', 'doveadm',
'dovecot', 'openssl',
// SSL
'certbot', 'openssl',
// FTP
'vsftpd',
'certbot',
// Services
'systemctl', 'service',
'systemctl',
// Network
'ss', 'ip', 'hostname', 'ping', 'traceroute', 'dig', 'nslookup',
'ss', 'ip', 'hostname',
// 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',
'uname', 'free', 'ps',
// PHP
'php', 'php7.4', 'php8.0', 'php8.1', 'php8.2', 'php8.3', 'update-alternatives',
// Misc
'touch', 'echo', 'which', 'whoami', 'env',
'php8.2', 'php8.3',
// Fail2ban
'fail2ban-client',
// UFW
'ufw',
// iptables
'iptables', 'netfilter-persistent',
])
// Characters never allowed in arguments (shell injection prevention)
// Shell metacharacters never allowed in arguments
const DANGEROUS_CHARS = /[;&|`$<>()\n\r\0]/
// Paths outside these prefixes are blocked for file operations
// Paths allowed for file read/write operations
const ALLOWED_PATH_PREFIXES = [
'/home/',
'/etc/nginx/',
'/etc/apache2/',
'/etc/bind/',
'/etc/postfix/',
'/etc/dovecot/',
'/etc/vsftpd',
'/etc/php/',
'/etc/pdns/',
'/etc/exim4/',
'/etc/dovecot/',
'/etc/letsencrypt/',
'/etc/fail2ban/',
'/etc/nixpanel/',
'/var/www/',
'/var/mail/',
'/var/log/',
'/var/spool/cron/',
'/tmp/',
'/usr/local/nixpanel/',
'/opt/nixpanel/',
]
function validateArg(arg: string): void {
@@ -71,8 +58,8 @@ function validateArg(arg: string): void {
}
}
function isAllowedPath(path: string): boolean {
return ALLOWED_PATH_PREFIXES.some(prefix => path.startsWith(prefix))
function isAllowedPath(p: string): boolean {
return ALLOWED_PATH_PREFIXES.some(prefix => p.startsWith(prefix))
}
export interface ExecResult {
@@ -83,12 +70,13 @@ export interface ExecResult {
export interface ExecOptions {
timeout?: number
allowedPaths?: string[]
input?: string // data to pipe to stdin
}
/**
* Execute a whitelisted system command safely.
* Throws if the command is not in the allowlist or args contain shell metacharacters.
* Uses execFile (no shell). Throws if command is not in the allowlist
* or any argument contains shell metacharacters.
*/
export async function exec(
command: string,
@@ -103,12 +91,15 @@ export async function exec(
validateArg(arg)
}
const timeout = options.timeout ?? 30_000
// 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)
}
try {
const { stdout, stderr } = await execFileAsync(command, args, {
timeout,
maxBuffer: 10 * 1024 * 1024, // 10MB
timeout: options.timeout ?? 30_000,
maxBuffer: 10 * 1024 * 1024,
})
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 }
} catch (err: unknown) {
@@ -121,52 +112,69 @@ export async function exec(
}
}
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()
})
}
/**
* Write content to a file in an allowed path using a safe method (no shell).
* Write content to a file in an allowed path.
*/
export async function writeFile(path: string, content: string): Promise<void> {
if (!isAllowedPath(path)) {
throw new Error(`Path not in allowed list: ${path}`)
export async function writeFile(filePath: string, content: string): Promise<void> {
if (!isAllowedPath(filePath)) {
throw new Error(`Path not in allowed list: ${filePath}`)
}
const { writeFile: fsWrite } = await import('fs/promises')
await fsWrite(path, content, 'utf8')
await fsWrite(filePath, 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}`)
export async function readFile(filePath: string): Promise<string> {
if (!isAllowedPath(filePath)) {
throw new Error(`Path not in allowed list: ${filePath}`)
}
const { readFile: fsRead } = await import('fs/promises')
return fsRead(path, 'utf8')
return fsRead(filePath, '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)
export function fileExists(filePath: string): boolean {
return existsSync(filePath)
}