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:
@@ -0,0 +1,174 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs/promises'
|
||||
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
|
||||
aliases?: string[]
|
||||
}
|
||||
|
||||
export function generateVhost(opts: VhostOptions): string {
|
||||
const {
|
||||
domain,
|
||||
username,
|
||||
documentRoot,
|
||||
phpVersion = '8.3',
|
||||
sslEnabled = false,
|
||||
certPath,
|
||||
keyPath,
|
||||
aliases = [],
|
||||
} = opts
|
||||
|
||||
const phpSocket = `/run/php/php${phpVersion}-fpm-${username}.sock`
|
||||
const serverAlias = aliases.length > 0 ? ` ServerAlias ${aliases.join(' ')}\n` : ''
|
||||
|
||||
const httpVhost = `<VirtualHost *:80>
|
||||
ServerName ${domain}
|
||||
${serverAlias} DocumentRoot ${documentRoot}
|
||||
|
||||
<FilesMatch \\.php$>
|
||||
SetHandler "proxy:unix:${phpSocket}|fcgi://localhost"
|
||||
</FilesMatch>
|
||||
|
||||
<Directory ${documentRoot}>
|
||||
Options -Indexes +FollowSymLinks
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
CustomLog /var/log/apache2/${username}-access.log combined
|
||||
ErrorLog /var/log/apache2/${username}-error.log
|
||||
|
||||
Alias /.well-known/acme-challenge/ /var/www/letsencrypt/.well-known/acme-challenge/
|
||||
<Directory /var/www/letsencrypt/.well-known/acme-challenge/>
|
||||
Require all granted
|
||||
</Directory>
|
||||
${sslEnabled ? `\n RewriteEngine On\n RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=301,L]\n` : ''}
|
||||
</VirtualHost>`
|
||||
|
||||
if (!sslEnabled || !certPath || !keyPath) {
|
||||
return httpVhost
|
||||
}
|
||||
|
||||
const sslVhost = `
|
||||
<VirtualHost *:443>
|
||||
ServerName ${domain}
|
||||
${serverAlias} DocumentRoot ${documentRoot}
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile ${certPath}
|
||||
SSLCertificateKeyFile ${keyPath}
|
||||
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
|
||||
SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
|
||||
SSLHonorCipherOrder off
|
||||
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
|
||||
<FilesMatch \\.php$>
|
||||
SetHandler "proxy:unix:${phpSocket}|fcgi://localhost"
|
||||
</FilesMatch>
|
||||
|
||||
<Directory ${documentRoot}>
|
||||
Options -Indexes +FollowSymLinks
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
CustomLog /var/log/apache2/${username}-access.log combined
|
||||
ErrorLog /var/log/apache2/${username}-error.log
|
||||
</VirtualHost>`
|
||||
|
||||
return `${httpVhost}\n${sslVhost}`
|
||||
}
|
||||
|
||||
export function generateSuspendedVhost(domain: string, username: string, aliases: string[] = []): string {
|
||||
const serverAlias = aliases.length > 0 ? ` ServerAlias ${aliases.join(' ')}\n` : ''
|
||||
return `<VirtualHost *:80>
|
||||
ServerName ${domain}
|
||||
${serverAlias}
|
||||
DocumentRoot /var/www/html
|
||||
|
||||
RedirectMatch 503 ^/(?!\.well-known)
|
||||
ErrorDocument 503 "<html><body><h1>Account Suspended</h1><p>This account has been suspended. Please contact support.</p></body></html>"
|
||||
|
||||
CustomLog /var/log/apache2/${username}-access.log combined
|
||||
ErrorLog /var/log/apache2/${username}-error.log
|
||||
</VirtualHost>`
|
||||
}
|
||||
|
||||
export function generatePhpFpmPool(username: string, phpVersion: string): string {
|
||||
return `[${username}]
|
||||
user = ${username}
|
||||
group = ${username}
|
||||
listen = /run/php/php${phpVersion}-fpm-${username}.sock
|
||||
listen.owner = www-data
|
||||
listen.group = www-data
|
||||
listen.mode = 0660
|
||||
|
||||
pm = ondemand
|
||||
pm.max_children = 10
|
||||
pm.process_idle_timeout = 10s
|
||||
pm.max_requests = 500
|
||||
|
||||
chdir = /
|
||||
|
||||
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec
|
||||
php_admin_value[open_basedir] = /home/${username}/:/tmp/
|
||||
php_admin_value[error_log] = /home/${username}/logs/php_errors.log
|
||||
php_admin_flag[log_errors] = on
|
||||
php_admin_flag[allow_url_fopen] = off
|
||||
`
|
||||
}
|
||||
|
||||
export async function enableVhost(opts: VhostOptions): Promise<void> {
|
||||
const confFile = `${opts.domain}.conf`
|
||||
const confPath = path.join(config.paths.apacheSites, confFile)
|
||||
const enabledPath = path.join(config.paths.apacheEnabled, confFile)
|
||||
|
||||
await writeFile(confPath, generateVhost(opts))
|
||||
await exec('ln', ['-sf', confPath, enabledPath])
|
||||
|
||||
const test = await exec('apache2ctl', ['configtest'])
|
||||
if (test.exitCode !== 0) {
|
||||
// Roll back on bad config
|
||||
await fs.unlink(enabledPath).catch(() => {})
|
||||
await fs.unlink(confPath).catch(() => {})
|
||||
throw new Error(`Apache config test failed: ${test.stderr}`)
|
||||
}
|
||||
|
||||
await exec('systemctl', ['reload', 'apache2'])
|
||||
}
|
||||
|
||||
export async function disableVhost(domain: string): Promise<void> {
|
||||
const confFile = `${domain}.conf`
|
||||
await exec('a2dissite', [confFile])
|
||||
await fs.unlink(path.join(config.paths.apacheSites, confFile)).catch(() => {})
|
||||
await exec('systemctl', ['reload', 'apache2'])
|
||||
}
|
||||
|
||||
export async function suspendVhost(domain: string, username: string, aliases: string[] = []): Promise<void> {
|
||||
const confFile = `${domain}.conf`
|
||||
const confPath = path.join(config.paths.apacheSites, confFile)
|
||||
await writeFile(confPath, generateSuspendedVhost(domain, username, aliases))
|
||||
await exec('systemctl', ['reload', 'apache2'])
|
||||
}
|
||||
|
||||
export async function enablePhpFpmPool(username: string, phpVersion: string): Promise<void> {
|
||||
const poolDir = phpVersion === '8.2' ? config.paths.phpFpmPool82 : config.paths.phpFpmPool83
|
||||
const poolPath = path.join(poolDir, `${username}.conf`)
|
||||
await writeFile(poolPath, generatePhpFpmPool(username, phpVersion))
|
||||
await exec('systemctl', ['reload', `php${phpVersion}-fpm`])
|
||||
}
|
||||
|
||||
export async function disablePhpFpmPool(username: string, phpVersion: string): Promise<void> {
|
||||
const poolDir = phpVersion === '8.2' ? config.paths.phpFpmPool82 : config.paths.phpFpmPool83
|
||||
const poolPath = path.join(poolDir, `${username}.conf`)
|
||||
await fs.unlink(poolPath).catch(() => {})
|
||||
await exec('systemctl', ['reload', `php${phpVersion}-fpm`])
|
||||
}
|
||||
+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)
|
||||
}
|
||||
|
||||
+73
-65
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { config } from '../config.js'
|
||||
|
||||
let licenseValid = false
|
||||
let lastChecked: Date | null = null
|
||||
|
||||
/**
|
||||
* Validate the license with the licensing server on startup.
|
||||
* Throws if the license is invalid or the server is unreachable.
|
||||
*/
|
||||
export async function validateOnStartup(): Promise<void> {
|
||||
if (!config.licensing.licenseKey) {
|
||||
if (config.env === 'development') {
|
||||
licenseValid = true
|
||||
return
|
||||
}
|
||||
throw new Error('LICENSE_KEY is not set. Set it in .env to activate NixPanel.')
|
||||
}
|
||||
|
||||
await checkLicense()
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a heartbeat to the licensing server every 6 hours.
|
||||
* If the license becomes invalid, logs a critical event but does NOT shut down —
|
||||
* the panel keeps running to avoid accidental outages.
|
||||
*/
|
||||
export async function heartbeat(): Promise<void> {
|
||||
if (!config.licensing.licenseKey) return
|
||||
|
||||
try {
|
||||
await checkLicense()
|
||||
} catch (err) {
|
||||
console.error('[license] Heartbeat failed:', err)
|
||||
// Log to DB if available — non-fatal, don't crash
|
||||
try {
|
||||
const { default: prisma } = await import('../db/client.js')
|
||||
await prisma.securityEvent.create({
|
||||
data: {
|
||||
type: 'license_heartbeat_failed',
|
||||
severity: 'warning',
|
||||
message: `License heartbeat failed: ${String(err)}`,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// DB may be unavailable — ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isLicenseValid(): boolean {
|
||||
return licenseValid
|
||||
}
|
||||
|
||||
export function getLastChecked(): Date | null {
|
||||
return lastChecked
|
||||
}
|
||||
|
||||
async function checkLicense(): Promise<void> {
|
||||
const url = `${config.licensing.serverUrl}/api/v1/validate`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
licenseKey: config.licensing.licenseKey,
|
||||
serverIp: await getServerIp(),
|
||||
}),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
licenseValid = false
|
||||
throw new Error(`License server returned ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json() as { valid: boolean; message?: string }
|
||||
licenseValid = data.valid
|
||||
lastChecked = new Date()
|
||||
|
||||
if (!data.valid) {
|
||||
throw new Error(data.message ?? 'License is not valid')
|
||||
}
|
||||
}
|
||||
|
||||
async function getServerIp(): Promise<string> {
|
||||
try {
|
||||
const { createConnection } = await import('net')
|
||||
return new Promise((resolve) => {
|
||||
const socket = createConnection(80, '8.8.8.8')
|
||||
socket.on('connect', () => {
|
||||
resolve(socket.localAddress ?? '')
|
||||
socket.destroy()
|
||||
})
|
||||
socket.on('error', () => resolve(''))
|
||||
})
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs/promises'
|
||||
import { config } from '../config.js'
|
||||
import { exec } from './exec.js'
|
||||
|
||||
/**
|
||||
* Hash a password using SHA-512-CRYPT — the scheme Dovecot understands
|
||||
* natively without any extra configuration.
|
||||
*/
|
||||
export async function hashMailPassword(password: string): Promise<string> {
|
||||
const result = await exec('openssl', ['passwd', '-6', '-stdin'], { input: password })
|
||||
if (result.exitCode !== 0 || !result.stdout.trim()) {
|
||||
throw new Error(`Failed to hash mail password: ${result.stderr}`)
|
||||
}
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an email address to /etc/dovecot/passwd.
|
||||
* Format: address:hash:::::::
|
||||
*/
|
||||
export async function addMailboxToPasswd(address: string, passwordHash: string): Promise<void> {
|
||||
const entry = `${address}:${passwordHash}:::::::\n`
|
||||
await fs.appendFile(config.paths.dovecotPasswd, entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an email address from /etc/dovecot/passwd.
|
||||
*/
|
||||
export async function removeMailboxFromPasswd(address: string): Promise<void> {
|
||||
await removeLineStartingWith(config.paths.dovecotPasswd, `${address}:`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the password for an existing mailbox in /etc/dovecot/passwd.
|
||||
*/
|
||||
export async function updateMailboxPassword(address: string, newPassword: string): Promise<void> {
|
||||
const hash = await hashMailPassword(newPassword)
|
||||
const passwd = config.paths.dovecotPasswd
|
||||
const content = await fs.readFile(passwd, 'utf8')
|
||||
const updated = content
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
if (line.startsWith(`${address}:`)) {
|
||||
const parts = line.split(':')
|
||||
parts[1] = hash
|
||||
return parts.join(':')
|
||||
}
|
||||
return line
|
||||
})
|
||||
.join('\n')
|
||||
await fs.writeFile(passwd, updated, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a domain is listed in /etc/exim4/virtual_domains.
|
||||
*/
|
||||
export async function addVirtualDomain(domain: string): Promise<void> {
|
||||
const content = await readFileOrEmpty(config.paths.eximVirtualDomains)
|
||||
const domains = content.split('\n').map(l => l.trim()).filter(Boolean)
|
||||
if (!domains.includes(domain)) {
|
||||
await fs.appendFile(config.paths.eximVirtualDomains, `${domain}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a domain from /etc/exim4/virtual_domains if no mailboxes remain.
|
||||
* The caller is responsible for checking whether mailboxes exist before calling.
|
||||
*/
|
||||
export async function removeVirtualDomain(domain: string): Promise<void> {
|
||||
await removeLineStartingWith(config.paths.eximVirtualDomains, domain)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a mailbox entry to /etc/exim4/virtual_mailboxes.
|
||||
* Format: address@domain domain/address/Maildir/
|
||||
*/
|
||||
export async function addMailboxToExim(address: string, maildirPath: string): Promise<void> {
|
||||
const entry = `${address} ${maildirPath}/\n`
|
||||
await fs.appendFile(config.paths.eximVirtualMailboxes, entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a mailbox entry from /etc/exim4/virtual_mailboxes.
|
||||
*/
|
||||
export async function removeMailboxFromExim(address: string): Promise<void> {
|
||||
await removeLineStartingWith(config.paths.eximVirtualMailboxes, `${address} `)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a forwarder to /etc/exim4/virtual_aliases.
|
||||
* Format: source@domain destination@domain
|
||||
*/
|
||||
export async function addForwarder(source: string, destination: string): Promise<void> {
|
||||
const entry = `${source} ${destination}\n`
|
||||
await fs.appendFile(config.paths.eximVirtualAliases, entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific forwarder from /etc/exim4/virtual_aliases.
|
||||
*/
|
||||
export async function removeForwarder(source: string): Promise<void> {
|
||||
await removeLineStartingWith(config.paths.eximVirtualAliases, `${source} `)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Maildir directory structure for a mailbox.
|
||||
* Path: /home/{username}/mail/{domain}/{localpart}/
|
||||
*/
|
||||
export async function createMaildir(username: string, domain: string, localpart: string): Promise<string> {
|
||||
const maildirBase = path.join(config.paths.homeDir, username, 'mail', domain, localpart)
|
||||
await fs.mkdir(path.join(maildirBase, 'new'), { recursive: true })
|
||||
await fs.mkdir(path.join(maildirBase, 'cur'), { recursive: true })
|
||||
await fs.mkdir(path.join(maildirBase, 'tmp'), { recursive: true })
|
||||
await exec('chown', ['-R', `${username}:${username}`, path.join(config.paths.homeDir, username, 'mail')])
|
||||
await exec('chmod', ['700', path.join(config.paths.homeDir, username, 'mail')])
|
||||
return maildirBase
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the Maildir for a mailbox.
|
||||
*/
|
||||
export async function removeMaildir(username: string, domain: string, localpart: string): Promise<void> {
|
||||
const maildirBase = path.join(config.paths.homeDir, username, 'mail', domain, localpart)
|
||||
await fs.rm(maildirBase, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Full lifecycle: create a mailbox including Exim entry, Dovecot passwd entry,
|
||||
* and Maildir on disk. Reloads both Exim and Dovecot.
|
||||
*/
|
||||
export async function createMailbox(
|
||||
username: string,
|
||||
address: string,
|
||||
password: string
|
||||
): Promise<void> {
|
||||
const [localpart, domain] = address.split('@')
|
||||
if (!localpart || !domain) throw new Error(`Invalid email address: ${address}`)
|
||||
|
||||
const hash = await hashMailPassword(password)
|
||||
const maildirPath = await createMaildir(username, domain, localpart)
|
||||
|
||||
await addVirtualDomain(domain)
|
||||
await addMailboxToExim(address, maildirPath)
|
||||
await addMailboxToPasswd(address, hash)
|
||||
|
||||
await reloadMailServices()
|
||||
}
|
||||
|
||||
/**
|
||||
* Full lifecycle: remove a mailbox. Does NOT remove the virtual domain —
|
||||
* caller should check if other mailboxes exist for the domain first.
|
||||
*/
|
||||
export async function removeMailbox(
|
||||
username: string,
|
||||
address: string,
|
||||
deleteMaildir = false
|
||||
): Promise<void> {
|
||||
const [localpart, domain] = address.split('@')
|
||||
if (!localpart || !domain) throw new Error(`Invalid email address: ${address}`)
|
||||
|
||||
await removeMailboxFromExim(address)
|
||||
await removeMailboxFromPasswd(address)
|
||||
if (deleteMaildir) {
|
||||
await removeMaildir(username, domain, localpart)
|
||||
}
|
||||
|
||||
await reloadMailServices()
|
||||
}
|
||||
|
||||
async function reloadMailServices(): Promise<void> {
|
||||
await exec('systemctl', ['reload', 'exim4'])
|
||||
await exec('systemctl', ['reload', 'dovecot'])
|
||||
}
|
||||
|
||||
async function readFileOrEmpty(filePath: string): Promise<string> {
|
||||
try {
|
||||
return await fs.readFile(filePath, 'utf8')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLineStartingWith(filePath: string, prefix: string): Promise<void> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf8')
|
||||
const cleaned = content
|
||||
.split('\n')
|
||||
.filter(line => !line.startsWith(prefix))
|
||||
.join('\n')
|
||||
await fs.writeFile(filePath, cleaned, 'utf8')
|
||||
} catch {
|
||||
// best effort — file may not exist
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import mysql from 'mysql2/promise'
|
||||
import { config } from '../config.js'
|
||||
|
||||
let pool: mysql.Pool | null = null
|
||||
|
||||
function getPool(): mysql.Pool {
|
||||
if (!pool) {
|
||||
pool = mysql.createPool({
|
||||
host: config.mariadb.host,
|
||||
port: config.mariadb.port,
|
||||
user: config.mariadb.user,
|
||||
password: config.mariadb.password,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 5,
|
||||
multipleStatements: false, // Never allow multiple statements
|
||||
})
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a hosting account database.
|
||||
*/
|
||||
export async function createDatabase(
|
||||
dbName: string,
|
||||
charset = 'utf8mb4',
|
||||
collation = 'utf8mb4_unicode_ci'
|
||||
): Promise<void> {
|
||||
validateIdentifier(dbName)
|
||||
validateIdentifier(charset)
|
||||
validateIdentifier(collation)
|
||||
const conn = await getPool().getConnection()
|
||||
try {
|
||||
await conn.query(
|
||||
`CREATE DATABASE IF NOT EXISTS \`${dbName}\` CHARACTER SET ${charset} COLLATE ${collation}`
|
||||
)
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a hosting account database.
|
||||
*/
|
||||
export async function dropDatabase(dbName: string): Promise<void> {
|
||||
validateIdentifier(dbName)
|
||||
const conn = await getPool().getConnection()
|
||||
try {
|
||||
await conn.query(`DROP DATABASE IF EXISTS \`${dbName}\``)
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a MariaDB user for a hosting account database.
|
||||
*/
|
||||
export async function createDatabaseUser(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<void> {
|
||||
validateIdentifier(username)
|
||||
const conn = await getPool().getConnection()
|
||||
try {
|
||||
await conn.execute(
|
||||
`CREATE USER IF NOT EXISTS ?@'localhost' IDENTIFIED BY ?`,
|
||||
[username, password]
|
||||
)
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant privileges on a database to a user.
|
||||
* privileges must be a validated string like 'ALL' or 'SELECT,INSERT,UPDATE,DELETE'.
|
||||
*/
|
||||
export async function grantPrivileges(
|
||||
dbName: string,
|
||||
username: string,
|
||||
privileges: string
|
||||
): Promise<void> {
|
||||
validateIdentifier(dbName)
|
||||
validateIdentifier(username)
|
||||
const safePrivileges = validatePrivileges(privileges)
|
||||
const conn = await getPool().getConnection()
|
||||
try {
|
||||
await conn.query(
|
||||
`GRANT ${safePrivileges} ON \`${dbName}\`.* TO ?@'localhost'`,
|
||||
[username]
|
||||
)
|
||||
await conn.query('FLUSH PRIVILEGES')
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a MariaDB user.
|
||||
*/
|
||||
export async function dropDatabaseUser(username: string): Promise<void> {
|
||||
validateIdentifier(username)
|
||||
const conn = await getPool().getConnection()
|
||||
try {
|
||||
await conn.execute(`DROP USER IF EXISTS ?@'localhost'`, [username])
|
||||
await conn.query('FLUSH PRIVILEGES')
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of a database in MB.
|
||||
*/
|
||||
export async function getDatabaseSizeMb(dbName: string): Promise<number> {
|
||||
validateIdentifier(dbName)
|
||||
const conn = await getPool().getConnection()
|
||||
try {
|
||||
const [rows] = await conn.execute<mysql.RowDataPacket[]>(
|
||||
`SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS size_mb
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = ?`,
|
||||
[dbName]
|
||||
)
|
||||
return rows[0]?.size_mb ?? 0
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
export async function closePool(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.end()
|
||||
pool = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a database/user identifier only contains safe characters.
|
||||
* Prevents identifier injection even when backtick-quoting.
|
||||
*/
|
||||
function validateIdentifier(value: string): void {
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(value)) {
|
||||
throw new Error(`Invalid identifier: ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a privileges string against an allowlist.
|
||||
*/
|
||||
function validatePrivileges(privileges: string): string {
|
||||
const allowed = new Set([
|
||||
'ALL', 'SELECT', 'INSERT', 'UPDATE', 'DELETE',
|
||||
'CREATE', 'DROP', 'INDEX', 'ALTER', 'REFERENCES',
|
||||
])
|
||||
const parts = privileges.toUpperCase().split(',').map(p => p.trim())
|
||||
for (const part of parts) {
|
||||
if (!allowed.has(part)) {
|
||||
throw new Error(`Invalid privilege: ${part}`)
|
||||
}
|
||||
}
|
||||
return parts.join(', ')
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import prisma from '../db/client.js'
|
||||
import { exec } from './exec.js'
|
||||
import { getServiceStatus } from './system.js'
|
||||
|
||||
interface MonitoredService {
|
||||
name: string
|
||||
restartOnFail: boolean
|
||||
maxRestarts: number
|
||||
restartWindowMinutes: number
|
||||
}
|
||||
|
||||
const MONITORED: MonitoredService[] = [
|
||||
{ name: 'apache2', restartOnFail: true, maxRestarts: 3, restartWindowMinutes: 10 },
|
||||
{ name: 'php8.2-fpm', restartOnFail: true, maxRestarts: 3, restartWindowMinutes: 10 },
|
||||
{ name: 'php8.3-fpm', restartOnFail: true, maxRestarts: 3, restartWindowMinutes: 10 },
|
||||
{ name: 'exim4', restartOnFail: true, maxRestarts: 3, restartWindowMinutes: 10 },
|
||||
{ name: 'dovecot', restartOnFail: true, maxRestarts: 3, restartWindowMinutes: 10 },
|
||||
{ name: 'pdns', restartOnFail: true, maxRestarts: 3, restartWindowMinutes: 10 },
|
||||
{ name: 'mariadb', restartOnFail: true, maxRestarts: 2, restartWindowMinutes: 10 },
|
||||
{ name: 'fail2ban', restartOnFail: true, maxRestarts: 2, restartWindowMinutes: 30 },
|
||||
]
|
||||
|
||||
// In-memory restart history: service name → timestamps of recent restarts
|
||||
const restartHistory = new Map<string, Date[]>()
|
||||
|
||||
export async function checkAll(): Promise<void> {
|
||||
for (const svc of MONITORED) {
|
||||
try {
|
||||
await checkService(svc)
|
||||
} catch (err) {
|
||||
// Don't let one service check failure stop the rest
|
||||
console.error(`[monitor] Error checking ${svc.name}:`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkService(svc: MonitoredService): Promise<void> {
|
||||
const status = await getServiceStatus(svc.name)
|
||||
|
||||
if (status.status !== 'failed') return
|
||||
|
||||
if (!svc.restartOnFail) {
|
||||
await logEvent('service_failed', `Service ${svc.name} is in failed state`, 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
const recentRestarts = getRecentRestartCount(svc.name, svc.restartWindowMinutes)
|
||||
|
||||
if (recentRestarts >= svc.maxRestarts) {
|
||||
await logEvent(
|
||||
'service_restart_loop',
|
||||
`Service ${svc.name} failed ${recentRestarts} times in ${svc.restartWindowMinutes} minutes — not restarting`,
|
||||
'critical'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await exec('systemctl', ['restart', svc.name])
|
||||
recordRestart(svc.name)
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
await logEvent(
|
||||
'service_restart_failed',
|
||||
`Failed to restart ${svc.name}: ${result.stderr}`,
|
||||
'critical'
|
||||
)
|
||||
} else {
|
||||
await logEvent(
|
||||
'service_restarted',
|
||||
`Service ${svc.name} was in failed state — automatically restarted`,
|
||||
'warning'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getRecentRestartCount(serviceName: string, windowMinutes: number): number {
|
||||
const history = restartHistory.get(serviceName) ?? []
|
||||
const cutoff = new Date(Date.now() - windowMinutes * 60 * 1000)
|
||||
const recent = history.filter(ts => ts > cutoff)
|
||||
restartHistory.set(serviceName, recent)
|
||||
return recent.length
|
||||
}
|
||||
|
||||
function recordRestart(serviceName: string): void {
|
||||
const history = restartHistory.get(serviceName) ?? []
|
||||
history.push(new Date())
|
||||
restartHistory.set(serviceName, history)
|
||||
}
|
||||
|
||||
async function logEvent(type: string, message: string, severity: 'info' | 'warning' | 'critical'): Promise<void> {
|
||||
try {
|
||||
await prisma.securityEvent.create({ data: { type, message, severity } })
|
||||
} catch {
|
||||
// Don't let a DB error crash the monitor
|
||||
console.error(`[monitor] Failed to log event: ${type} — ${message}`)
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
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,104 @@
|
||||
import prisma from '../db/client.js'
|
||||
import { exec } from './exec.js'
|
||||
import { config } from '../config.js'
|
||||
|
||||
const RENEW_DAYS_BEFORE_EXPIRY = 30
|
||||
|
||||
/**
|
||||
* Issue a Let's Encrypt certificate for a domain via certbot Apache plugin.
|
||||
*/
|
||||
export async function issueCertificate(domain: string, email: string): Promise<void> {
|
||||
const result = await exec('certbot', [
|
||||
'certonly',
|
||||
'--apache',
|
||||
'--non-interactive',
|
||||
'--agree-tos',
|
||||
'--email', email,
|
||||
'-d', domain,
|
||||
'-d', `www.${domain}`,
|
||||
], { timeout: 120_000 })
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`certbot failed for ${domain}: ${result.stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renew a specific certificate.
|
||||
*/
|
||||
export async function renewCertificate(domain: string): Promise<void> {
|
||||
const result = await exec('certbot', [
|
||||
'renew',
|
||||
'--cert-name', domain,
|
||||
'--non-interactive',
|
||||
'--quiet',
|
||||
], { timeout: 120_000 })
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`certbot renew failed for ${domain}: ${result.stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke and delete a certificate.
|
||||
*/
|
||||
export async function revokeCertificate(domain: string): Promise<void> {
|
||||
await exec('certbot', ['delete', '--cert-name', domain, '--non-interactive'], { timeout: 60_000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all certificates expiring within RENEW_DAYS_BEFORE_EXPIRY days
|
||||
* and attempt renewal. Called by the daily scheduler cron.
|
||||
*/
|
||||
export async function renewExpiring(): Promise<void> {
|
||||
const cutoff = new Date()
|
||||
cutoff.setDate(cutoff.getDate() + RENEW_DAYS_BEFORE_EXPIRY)
|
||||
|
||||
const expiring = await prisma.sslCertificate.findMany({
|
||||
where: {
|
||||
autoRenew: true,
|
||||
status: 'active',
|
||||
expiresAt: { lte: cutoff },
|
||||
},
|
||||
})
|
||||
|
||||
for (const cert of expiring) {
|
||||
try {
|
||||
await renewCertificate(cert.domain)
|
||||
await prisma.sslCertificate.update({
|
||||
where: { id: cert.id },
|
||||
data: {
|
||||
expiresAt: newExpiryFromNow(),
|
||||
status: 'active',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`[ssl] Failed to renew cert for ${cert.domain}:`, err)
|
||||
await prisma.securityEvent.create({
|
||||
data: {
|
||||
type: 'ssl_renewal_failed',
|
||||
severity: 'warning',
|
||||
message: `SSL renewal failed for ${cert.domain}: ${String(err)}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cert/key paths for a domain from the letsencrypt live directory.
|
||||
*/
|
||||
export function getCertPaths(domain: string): { certPath: string; keyPath: string; chainPath: string } {
|
||||
const base = `${config.paths.letsencryptLive}/${domain}`
|
||||
return {
|
||||
certPath: `${base}/fullchain.pem`,
|
||||
keyPath: `${base}/privkey.pem`,
|
||||
chainPath: `${base}/chain.pem`,
|
||||
}
|
||||
}
|
||||
|
||||
function newExpiryFromNow(): Date {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + 90) // Let's Encrypt issues 90-day certs
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs/promises'
|
||||
import prisma from '../db/client.js'
|
||||
import { exec } from './exec.js'
|
||||
import { config } from '../config.js'
|
||||
|
||||
/**
|
||||
* Collect disk usage for all active accounts and update the DB.
|
||||
* Called every 15 minutes by the scheduler.
|
||||
*/
|
||||
export async function collectDiskUsage(): Promise<void> {
|
||||
const accounts = await prisma.account.findMany({
|
||||
where: { status: { in: ['active', 'suspended'] } },
|
||||
select: { id: true, username: true, homedir: true },
|
||||
})
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
const usedMb = await getDirSizeMb(account.homedir)
|
||||
await prisma.account.update({
|
||||
where: { id: account.id },
|
||||
data: { diskUsedMb: BigInt(Math.round(usedMb)) },
|
||||
})
|
||||
} catch {
|
||||
// Non-fatal — homedir may not exist yet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset bandwidth counters for all accounts on the 1st of each month.
|
||||
* Called by the scheduler monthly cron.
|
||||
*/
|
||||
export async function resetMonthlyBandwidth(): Promise<void> {
|
||||
await prisma.account.updateMany({
|
||||
where: { status: { in: ['active', 'suspended'] } },
|
||||
data: { bandwidthUsedMb: BigInt(0) },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get disk usage of a directory in MB using `du`.
|
||||
*/
|
||||
async function getDirSizeMb(dirPath: string): Promise<number> {
|
||||
// du is not in exec allowlist — read from /proc or use stat directly
|
||||
// Use fs.stat recursively for safety, or fall back to parsing /proc
|
||||
try {
|
||||
const result = await exec('df', ['-BM', '--output=used', dirPath])
|
||||
// df output: header line + value line like "123M"
|
||||
const lines = result.stdout.split('\n').filter(Boolean)
|
||||
const valueStr = lines[lines.length - 1]?.replace('M', '').trim() ?? '0'
|
||||
return parseInt(valueStr, 10) || 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get per-account Apache access log size as a bandwidth proxy.
|
||||
* This is a rough estimate — real bandwidth tracking requires log parsing.
|
||||
*/
|
||||
export async function getAccessLogSizeMb(username: string): Promise<number> {
|
||||
const logPath = `/var/log/apache2/${username}-access.log`
|
||||
try {
|
||||
const stat = await fs.stat(logPath)
|
||||
return Math.round(stat.size / 1024 / 1024)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,13 @@ export async function getServiceStatus(name: string): Promise<ServiceStatus> {
|
||||
}
|
||||
|
||||
export const MANAGED_SERVICES = [
|
||||
'nginx', 'apache2', 'mysql', 'mariadb',
|
||||
'postfix', 'dovecot', 'vsftpd', 'bind9',
|
||||
'fail2ban', 'ufw', 'cron', 'ssh',
|
||||
'apache2',
|
||||
'php8.2-fpm',
|
||||
'php8.3-fpm',
|
||||
'exim4',
|
||||
'dovecot',
|
||||
'pdns',
|
||||
'mariadb',
|
||||
'fail2ban',
|
||||
'ssh',
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user