Fix SQLite backtick translation, add service-switch SSE streaming, Fail2Ban management page

- DB.php: fix backtick-quoted column names in ON DUPLICATE KEY UPDATE VALUES() regex
- DB.php: add global backtick→double-quote identifier strip
- system.php: add service-switch SSE streaming endpoint for web/mail/ftp/dns server changes
- system.php: simplify save-option to DB save only (no inline shell)
- firewall.php: add f2b-config-get, f2b-config-save, f2b-log, f2b-jail, f2b-ban, f2b-unban, f2b-ignoreip-* actions
- admin.js: Fail2Ban dedicated management page with jail table, global settings, whitelist, log viewer
- admin.js: soSave() now uses streaming terminal overlay instead of blocking spinner
- admin/index.php: split Firewall (UFW) and Fail2Ban into separate sidebar entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 16:18:07 +00:00
parent bcd3b65520
commit 7aa33defa2
8 changed files with 1019 additions and 92 deletions
+35 -16
View File
@@ -10,8 +10,7 @@ NOVACPX_VERSION="1.0.0"
PANEL_DIR="/opt/novacpx" PANEL_DIR="/opt/novacpx"
WEB_ROOT="/srv/novacpx/public" WEB_ROOT="/srv/novacpx/public"
LOG="/var/log/novacpx-install.log" LOG="/var/log/novacpx-install.log"
DB_NAME="novacpx" DB_PATH="/var/lib/novacpx/panel.db"
DB_USER="novacpx_user"
PHP_DEFAULT="8.3" PHP_DEFAULT="8.3"
# ── Panel ports (each tier has its own port) ────────────────────────────────── # ── Panel ports (each tier has its own port) ──────────────────────────────────
@@ -105,7 +104,8 @@ log "RAM: ${TOTAL_RAM}MB | Free disk: ${TOTAL_DISK}GB"
# ── Generate secrets ────────────────────────────────────────────────────────── # ── Generate secrets ──────────────────────────────────────────────────────────
step "Generating Credentials" step "Generating Credentials"
DB_PASS=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9!@#$' | head -c 20) DB_WP_USER="novacpx_wp"
DB_WP_PASS=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9!@#$' | head -c 20)
ADMIN_PASS=$(openssl rand -base64 16 | tr -dc 'A-Za-z0-9' | head -c 16) ADMIN_PASS=$(openssl rand -base64 16 | tr -dc 'A-Za-z0-9' | head -c 16)
SECRET_KEY=$(openssl rand -hex 32) SECRET_KEY=$(openssl rand -hex 32)
mkdir -p /root/.novacpx mkdir -p /root/.novacpx
@@ -118,9 +118,9 @@ Admin Panel: https://$(hostname -I | awk '{print $1}'):${PORT_ADMIN}
Webmail: https://$(hostname -I | awk '{print $1}'):${PORT_WEBMAIL} Webmail: https://$(hostname -I | awk '{print $1}'):${PORT_WEBMAIL}
Admin User: admin Admin User: admin
Admin Pass: $ADMIN_PASS Admin Pass: $ADMIN_PASS
DB Name: $DB_NAME Panel DB: ${DB_PATH} (SQLite — no credentials needed)
DB User: $DB_USER DB WP User: $DB_WP_USER
DB Pass: $DB_PASS DB WP Pass: $DB_WP_PASS
========================================== ==========================================
SAVE THIS FILE. It will not be shown again. SAVE THIS FILE. It will not be shown again.
CREDS CREDS
@@ -134,7 +134,7 @@ apt-get update -qq >> "$LOG" 2>&1
apt-get upgrade -y -qq >> "$LOG" 2>&1 apt-get upgrade -y -qq >> "$LOG" 2>&1
apt-get install -y -qq curl wget gnupg2 lsb-release ca-certificates \ apt-get install -y -qq curl wget gnupg2 lsb-release ca-certificates \
software-properties-common apt-transport-https unzip git \ software-properties-common apt-transport-https unzip git \
sudo cron logrotate ufw fail2ban sshpass >> "$LOG" 2>&1 sudo cron logrotate ufw fail2ban sshpass sqlite3 >> "$LOG" 2>&1
log "System packages updated" log "System packages updated"
# ── PHP multi-version setup ─────────────────────────────────────────────────── # ── PHP multi-version setup ───────────────────────────────────────────────────
@@ -305,6 +305,9 @@ fi
# Enable PHP-FPM services # Enable PHP-FPM services
for VER in "${PHP_VERSIONS[@]}"; do for VER in "${PHP_VERSIONS[@]}"; do
systemctl enable php${VER}-fpm >> "$LOG" 2>&1 && systemctl start php${VER}-fpm >> "$LOG" 2>&1 || true systemctl enable php${VER}-fpm >> "$LOG" 2>&1 && systemctl start php${VER}-fpm >> "$LOG" 2>&1 || true
# Allow unlimited execution time so long-running panel tasks (package installs, WP) don't get killed
grep -q "php_admin_value\[max_execution_time\]" /etc/php/${VER}/fpm/pool.d/www.conf 2>/dev/null || \
echo "php_admin_value[max_execution_time] = 0" >> /etc/php/${VER}/fpm/pool.d/www.conf
done done
# ── MySQL ───────────────────────────────────────────────────────────────────── # ── MySQL ─────────────────────────────────────────────────────────────────────
@@ -316,6 +319,10 @@ if $INSTALL_MYSQL; then
mysql -e "CREATE DATABASE IF NOT EXISTS $DB_NAME CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" >> "$LOG" 2>&1 mysql -e "CREATE DATABASE IF NOT EXISTS $DB_NAME CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" >> "$LOG" 2>&1
mysql -e "CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASS}';" >> "$LOG" 2>&1 mysql -e "CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASS}';" >> "$LOG" 2>&1
mysql -e "GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'localhost';" >> "$LOG" 2>&1 mysql -e "GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'localhost';" >> "$LOG" 2>&1
# Privileged user for WordPress DB provisioning (CREATE DATABASE + CREATE USER + GRANT)
mysql -e "CREATE USER IF NOT EXISTS '${DB_WP_USER}'@'localhost' IDENTIFIED BY '${DB_WP_PASS}';" >> "$LOG" 2>&1
mysql -e "GRANT ALL PRIVILEGES ON \`wp\_%\`.* TO '${DB_WP_USER}'@'localhost';" >> "$LOG" 2>&1
mysql -e "GRANT CREATE USER ON *.* TO '${DB_WP_USER}'@'localhost' WITH GRANT OPTION;" >> "$LOG" 2>&1
mysql -e "FLUSH PRIVILEGES;" >> "$LOG" 2>&1 mysql -e "FLUSH PRIVILEGES;" >> "$LOG" 2>&1
log "MySQL installed and database created" log "MySQL installed and database created"
fi fi
@@ -485,10 +492,9 @@ fi
mkdir -p /etc/novacpx mkdir -p /etc/novacpx
cat > /etc/novacpx/config.ini <<CONFIG cat > /etc/novacpx/config.ini <<CONFIG
[database] [database]
host = localhost path = ${DB_PATH}
name = ${DB_NAME} wp_user = ${DB_WP_USER}
user = ${DB_USER} wp_pass = ${DB_WP_PASS}
pass = ${DB_PASS}
[panel] [panel]
secret = ${SECRET_KEY} secret = ${SECRET_KEY}
@@ -506,16 +512,19 @@ CONFIG
chown root:www-data /etc/novacpx/config.ini chown root:www-data /etc/novacpx/config.ini
chmod 640 /etc/novacpx/config.ini chmod 640 /etc/novacpx/config.ini
# Import database schema # Create SQLite panel database
mkdir -p /var/lib/novacpx
if [[ -f /opt/novacpx-src/db/schema.sql ]]; then if [[ -f /opt/novacpx-src/db/schema.sql ]]; then
mysql "$DB_NAME" < /opt/novacpx-src/db/schema.sql >> "$LOG" 2>&1 sqlite3 "$DB_PATH" < /opt/novacpx-src/db/schema.sql >> "$LOG" 2>&1
# Create admin user # Create admin user
ADMIN_HASH=$(php -r "echo password_hash('${ADMIN_PASS}', PASSWORD_BCRYPT);") ADMIN_HASH=$(php -r "echo password_hash('${ADMIN_PASS}', PASSWORD_BCRYPT);")
mysql "$DB_NAME" -e "INSERT INTO users (username,password,email,role,status) VALUES ('admin','$ADMIN_HASH','root@localhost','admin','active') ON DUPLICATE KEY UPDATE password='$ADMIN_HASH';" >> "$LOG" 2>&1 sqlite3 "$DB_PATH" "INSERT OR REPLACE INTO users (username,password,email,role,status) VALUES ('admin','${ADMIN_HASH}','root@localhost','admin','active');" >> "$LOG" 2>&1
# Seed proxy defaults # Seed proxy defaults
mysql "$DB_NAME" -e "INSERT INTO settings (\`key\`, value) VALUES ('proxy_mode','disabled'),('proxy_apache_port','80') ON DUPLICATE KEY UPDATE value=VALUES(value);" >> "$LOG" 2>&1 sqlite3 "$DB_PATH" "INSERT OR IGNORE INTO settings (key, value) VALUES ('proxy_mode','disabled'),('proxy_apache_port','80');" >> "$LOG" 2>&1
log "Database schema imported and admin user created" log "SQLite panel database created and admin user seeded"
fi fi
chown www-data:www-data "$DB_PATH"
chmod 660 "$DB_PATH"
# Set permissions # Set permissions
chown -R www-data:www-data "$WEB_ROOT" chown -R www-data:www-data "$WEB_ROOT"
@@ -640,6 +649,16 @@ www-data ALL=(root) NOPASSWD: /bin/systemctl reload nginx
www-data ALL=(root) NOPASSWD: /bin/systemctl restart apache2 www-data ALL=(root) NOPASSWD: /bin/systemctl restart apache2
www-data ALL=(root) NOPASSWD: /bin/systemctl reload apache2 www-data ALL=(root) NOPASSWD: /bin/systemctl reload apache2
www-data ALL=(root) NOPASSWD: /usr/sbin/nginx * www-data ALL=(root) NOPASSWD: /usr/sbin/nginx *
# DB tool installation privileges
www-data ALL=(root) NOPASSWD: /usr/bin/gpg *
www-data ALL=(root) NOPASSWD: /usr/bin/curl *
www-data ALL=(root) NOPASSWD: /usr/sbin/debconf-set-selections *
www-data ALL=(root) NOPASSWD: /usr/bin/tee /etc/apt/sources.list.d/*
www-data ALL=(root) NOPASSWD: /usr/bin/tee /usr/share/keyrings/*
www-data ALL=(root) NOPASSWD: /usr/bin/tee /etc/nginx/conf.d/*
www-data ALL=(root) NOPASSWD: /usr/bin/tee /etc/apache2/conf-enabled/*
www-data ALL=(root) NOPASSWD: /usr/pgadmin4/bin/setup-web.sh *
www-data ALL=(root) NOPASSWD: /usr/bin/env *
SUDOERS SUDOERS
chmod 440 /etc/sudoers.d/novacpx-firewall chmod 440 /etc/sudoers.d/novacpx-firewall
log "Sudoers rules installed" log "Sudoers rules installed"
+46
View File
@@ -420,6 +420,52 @@ switch ($action) {
Response::success(['ignoreip' => $defaults], 'Whitelist reset to server defaults'); Response::success(['ignoreip' => $defaults], 'Whitelist reset to server defaults');
break; break;
// ── Fail2Ban: get global config (bantime/findtime/maxretry) ──────────────
case 'f2b-config-get':
$content = file_exists(JAIL_LOCAL) ? file_get_contents(JAIL_LOCAL) : '';
$get = function(string $key, string $default) use ($content): string {
return preg_match('/^\s*' . preg_quote($key) . '\s*=\s*(.+)$/m', $content, $m) ? trim($m[1]) : $default;
};
Response::success([
'bantime' => $get('bantime', '3600'),
'findtime' => $get('findtime', '600'),
'maxretry' => $get('maxretry', '5'),
]);
break;
// ── Fail2Ban: save global config ──────────────────────────────────────
case 'f2b-config-save':
$bantime = (int)($body['bantime'] ?? 3600);
$findtime = (int)($body['findtime'] ?? 600);
$maxretry = (int)($body['maxretry'] ?? 5);
if ($bantime < 60 || $findtime < 60 || $maxretry < 1) Response::error('Invalid values');
$content = file_exists(JAIL_LOCAL) ? file_get_contents(JAIL_LOCAL) : '';
$set = function(string $key, string $val) use (&$content): void {
if (preg_match('/^\s*' . preg_quote($key) . '\s*=/m', $content)) {
$content = preg_replace('/^(\s*' . preg_quote($key) . '\s*=\s*).+$/m', '${1}' . $val, $content);
} else {
// Insert into [DEFAULT] section
$content = preg_replace('/(\[DEFAULT\][^\[]*)/s', '$1' . "$key = $val\n", $content, 1);
}
};
$set('bantime', (string)$bantime);
$set('findtime', (string)$findtime);
$set('maxretry', (string)$maxretry);
file_put_contents(JAIL_LOCAL, $content);
fw_exec('fail2ban-client reload');
audit('firewall.f2b-config', "bantime=$bantime findtime=$findtime maxretry=$maxretry");
Response::success(null, 'Fail2Ban configuration saved');
break;
// ── Fail2Ban: tail log ────────────────────────────────────────────────
case 'f2b-log':
$lines = max(50, min(500, (int)($body['lines'] ?? 100)));
$log = '/var/log/fail2ban.log';
if (!file_exists($log)) Response::error('Fail2Ban log not found');
$raw = trim(shell_exec("tail -n {$lines} " . escapeshellarg($log)) ?: '');
Response::success(['log' => $raw, 'lines' => $lines]);
break;
// ── UFW: raw command (admin escape hatch) ───────────────────────────── // ── UFW: raw command (admin escape hatch) ─────────────────────────────
case 'raw': case 'raw':
$cmd = trim($body['cmd'] ?? ''); $cmd = trim($body['cmd'] ?? '');
+249 -32
View File
@@ -442,44 +442,130 @@ BASH;
$value = $body['value'] ?? ''; $value = $body['value'] ?? '';
$allowed = ['web_server','mail_server','ftp_server','dns_server','whmcs_api_key','whmcs_enabled','ns1_hostname','ns2_hostname']; $allowed = ['web_server','mail_server','ftp_server','dns_server','whmcs_api_key','whmcs_enabled','ns1_hostname','ns2_hostname'];
if (!in_array($key, $allowed)) Response::error("Invalid setting key: $key"); if (!in_array($key, $allowed)) Response::error("Invalid setting key: $key");
$db->execute("INSERT INTO settings (`key`,`value`) VALUES (?,?) ON DUPLICATE KEY UPDATE `value`=VALUES(`value`)", [$key, $value]);
audit("settings.{$key}", $value);
Response::success(null, "Setting saved: {$key} = {$value}");
})(),
// Save before switching so the new value is in DB // Streaming service switch for web/mail/ftp/dns server changes
'service-switch' => (function() use ($db, $body) {
Auth::getInstance()->require('admin');
$key = $body['key'] ?? '';
$value = $body['value'] ?? '';
$serviceKeys = ['web_server','mail_server','ftp_server','dns_server'];
if (!in_array($key, $serviceKeys)) Response::error("Invalid service key: $key");
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');
while (ob_get_level()) ob_end_clean();
$sse = function(string $line) { echo 'data: ' . json_encode(['line' => $line]) . "\n\n"; flush(); };
$run = function(string $cmd) use ($sse): int {
$proc = proc_open($cmd, [1 => ['pipe','w'], 2 => ['pipe','w']], $pipes);
if (!$proc) { $sse(" [failed to start]\n"); return 1; }
while (!feof($pipes[1])) {
$line = fgets($pipes[1]);
if ($line !== false && $line !== '') $sse($line);
}
while (!feof($pipes[2])) {
$line = fgets($pipes[2]);
if ($line !== false && $line !== '') $sse(" " . $line);
}
fclose($pipes[1]); fclose($pipes[2]);
return proc_close($proc);
};
// Persist selection
$db->execute("INSERT INTO settings (`key`,`value`) VALUES (?,?) ON DUPLICATE KEY UPDATE `value`=VALUES(`value`)", [$key, $value]); $db->execute("INSERT INTO settings (`key`,`value`) VALUES (?,?) ON DUPLICATE KEY UPDATE `value`=VALUES(`value`)", [$key, $value]);
// Sync config.ini so PHP constants reflect the change immediately on next request // Sync config.ini
$configFile = '/etc/novacpx/config.ini'; $configFile = '/etc/novacpx/config.ini';
if (in_array($key, ['web_server','ftp_server','dns_server']) && file_exists($configFile)) { if (file_exists($configFile)) {
$ini = file_get_contents($configFile); $ini = file_get_contents($configFile);
if ($key === 'web_server') { if ($key === 'web_server') $ini = preg_replace('/^server\s*=\s*.*/m', "server = $value", $ini);
$ini = preg_replace('/^server\s*=\s*.*/m', "server = $value", $ini);
}
file_put_contents($configFile, $ini); file_put_contents($configFile, $ini);
} }
// Inline service switching
// NOTE: Apache is NEVER stopped — it always serves the panel on ports 8880-8883.
// The web_server switch only controls which server owns ports 80/443 for customer hosting.
if ($key === 'web_server') { if ($key === 'web_server') {
$target = in_array($value, ['nginx']) ? 'nginx' : 'apache'; $target = ($value === 'nginx') ? 'nginx' : 'apache';
$out = shell_exec("sudo /usr/local/bin/novacpx-webserver-switch " . escapeshellarg($target) . " 2>&1"); $sse("▶ Switching web server to {$target}\n");
novacpx_log('info', "web_server switched to $target: $out"); if (file_exists('/usr/local/bin/novacpx-webserver-switch')) {
} elseif ($key === 'ftp_server') { $run("sudo /usr/local/bin/novacpx-webserver-switch " . escapeshellarg($target) . " 2>&1");
foreach (['proftpd','vsftpd','pure-ftpd'] as $s) { shell_exec("sudo systemctl stop $s 2>/dev/null; sudo systemctl disable $s 2>/dev/null"); } } else {
$startSvc = match($value) { 'vsftpd' => 'vsftpd', 'pureftpd' => 'pure-ftpd', default => 'proftpd' }; // Fallback: manage services directly
if (trim(shell_exec("dpkg -l $startSvc 2>/dev/null | grep -c '^ii'") ?: '0') > 0) { if ($target === 'nginx') {
shell_exec("sudo systemctl enable $startSvc 2>/dev/null && sudo systemctl start $startSvc 2>/dev/null"); $sse(" Stopping Apache on ports 80/443…\n");
$run("sudo systemctl stop apache2 2>&1");
$sse(" Starting Nginx…\n");
$run("sudo systemctl enable nginx 2>&1 && sudo systemctl start nginx 2>&1");
} else {
$sse(" Stopping Nginx…\n");
$run("sudo systemctl stop nginx 2>&1");
$sse(" Starting Apache…\n");
$run("sudo systemctl enable apache2 2>&1 && sudo systemctl start apache2 2>&1");
} }
}
$sse(" ✓ Web server switched to {$target}\n");
} elseif ($key === 'mail_server') {
$sse("▶ Updating mail server config to {$value}\n");
if ($value === 'postfix-dovecot-rspamd') {
$installed = trim(shell_exec("dpkg -l rspamd 2>/dev/null | grep -c '^ii'") ?: '0');
if ($installed === '0') {
$sse(" Installing Rspamd (this may take 12 minutes)…\n");
$run("sudo apt-get install -y rspamd 2>&1");
}
$sse(" Enabling Rspamd…\n");
$run("sudo systemctl enable rspamd 2>&1 && sudo systemctl start rspamd 2>&1");
$sse(" ✓ Rspamd enabled\n");
} else {
$run("sudo systemctl stop rspamd 2>/dev/null; sudo systemctl disable rspamd 2>/dev/null; true");
$sse(" ✓ Rspamd disabled\n");
}
// Postfix + Dovecot always run
$run("sudo systemctl is-active postfix || sudo systemctl start postfix 2>&1");
$run("sudo systemctl is-active dovecot || sudo systemctl start dovecot 2>&1");
$sse(" ✓ Mail stack: postfix + dovecot running\n");
} elseif ($key === 'ftp_server') {
$sse("▶ Switching FTP server to {$value}\n");
foreach (['proftpd','vsftpd','pure-ftpd'] as $s) {
$run("sudo systemctl stop $s 2>/dev/null; sudo systemctl disable $s 2>/dev/null; true");
}
$startSvc = match($value) { 'vsftpd' => 'vsftpd', 'pureftpd' => 'pure-ftpd', default => 'proftpd' };
$installed = trim(shell_exec("dpkg -l $startSvc 2>/dev/null | grep -c '^ii'") ?: '0');
if ($installed === '0') {
$sse(" Installing {$startSvc}\n");
$run("sudo apt-get install -y $startSvc 2>&1");
}
$sse(" Starting {$startSvc}\n");
$run("sudo systemctl enable $startSvc 2>&1 && sudo systemctl start $startSvc 2>&1");
$sse(" ✓ FTP server switched to {$startSvc}\n");
} elseif ($key === 'dns_server') { } elseif ($key === 'dns_server') {
foreach (['named','bind9','pdns','nsd'] as $s) { shell_exec("sudo systemctl stop $s 2>/dev/null; sudo systemctl disable $s 2>/dev/null"); } $sse("▶ Switching DNS server to {$value}\n");
foreach (['named','bind9','pdns','nsd'] as $s) {
$run("sudo systemctl stop $s 2>/dev/null; sudo systemctl disable $s 2>/dev/null; true");
}
if ($value !== 'none') { if ($value !== 'none') {
$startSvc = match($value) { 'powerdns' => 'pdns', 'nsd' => 'nsd', default => 'named' }; $startSvc = match($value) { 'powerdns' => 'pdns', 'nsd' => 'nsd', default => 'named' };
shell_exec("sudo systemctl enable $startSvc 2>/dev/null && sudo systemctl start $startSvc 2>/dev/null"); $installed = trim(shell_exec("dpkg -l $startSvc 2>/dev/null | grep -c '^ii'") ?: '0');
if ($installed === '0') {
$sse(" Installing {$startSvc}\n");
$run("sudo apt-get install -y $startSvc 2>&1");
}
$sse(" Starting {$startSvc}\n");
$run("sudo systemctl enable $startSvc 2>&1 && sudo systemctl start $startSvc 2>&1");
$sse(" ✓ DNS server switched to {$startSvc}\n");
} else {
$sse(" ✓ All local DNS servers stopped\n");
} }
} }
// mail_server: postfix + dovecot are always running; mail_server setting controls config template only
audit("settings.{$key}", $value); audit("settings.{$key}", $value);
Response::success(null, "Setting saved: {$key} = {$value}"); echo 'data: ' . json_encode(['done' => true]) . "\n\n"; flush();
exit;
})(), })(),
// ── Notification settings (#25) ─────────────────────────────────────────── // ── Notification settings (#25) ───────────────────────────────────────────
@@ -594,15 +680,7 @@ BASH;
if (!in_array($engine, ['mysql','mariadb','postgresql'])) Response::error("Invalid engine"); if (!in_array($engine, ['mysql','mariadb','postgresql'])) Response::error("Invalid engine");
if (!in_array($action, ['install','remove','start','stop','restart','set-active'])) Response::error("Invalid action"); if (!in_array($action, ['install','remove','start','stop','restart','set-active'])) Response::error("Invalid action");
// Safety: never remove the engine that is currently hosting the NovaCPX panel DB // Panel DB is SQLite — no MySQL engine hosts it, so any MySQL/MariaDB/PG can be removed freely
if ($action === 'remove') {
$activeConn = strtolower(DB_HOST === 'localhost' ? 'mysql' : '');
$isMariaDB = str_contains(strtolower(shell_exec("mysql --version 2>/dev/null") ?: ''), 'mariadb');
$runningEngine = $isMariaDB ? 'mariadb' : 'mysql';
if ($engine === $runningEngine || ($engine === 'mysql' && $isMariaDB) || ($engine === 'mariadb' && !$isMariaDB)) {
Response::error("Cannot remove $engine — it is currently hosting the NovaCPX panel database. Migrate first.", 409);
}
}
$out = ''; $out = '';
if ($action === 'install') { if ($action === 'install') {
@@ -611,7 +689,7 @@ BASH;
'mariadb' => 'mariadb-server', 'mariadb' => 'mariadb-server',
'postgresql' => 'postgresql postgresql-contrib', 'postgresql' => 'postgresql postgresql-contrib',
}; };
$out = shell_exec("DEBIAN_FRONTEND=noninteractive sudo apt-get install -y $pkg 2>&1"); $out = shell_exec("sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y $pkg 2>&1");
shell_exec("sudo systemctl enable $engine 2>/dev/null && sudo systemctl start $engine 2>/dev/null"); shell_exec("sudo systemctl enable $engine 2>/dev/null && sudo systemctl start $engine 2>/dev/null");
} elseif ($action === 'remove') { } elseif ($action === 'remove') {
$pkg = match($engine) { $pkg = match($engine) {
@@ -620,7 +698,7 @@ BASH;
'postgresql' => 'postgresql postgresql-contrib', 'postgresql' => 'postgresql postgresql-contrib',
}; };
shell_exec("sudo systemctl stop $engine 2>/dev/null || true"); shell_exec("sudo systemctl stop $engine 2>/dev/null || true");
$out = shell_exec("DEBIAN_FRONTEND=noninteractive sudo apt-get remove -y $pkg 2>&1"); $out = shell_exec("sudo env DEBIAN_FRONTEND=noninteractive apt-get remove -y $pkg 2>&1");
} elseif ($action === 'set-active') { } elseif ($action === 'set-active') {
$db->execute("INSERT INTO settings (`key`,`value`) VALUES ('active_db_engine',?) ON DUPLICATE KEY UPDATE `value`=VALUES(`value`)", [$engine]); $db->execute("INSERT INTO settings (`key`,`value`) VALUES ('active_db_engine',?) ON DUPLICATE KEY UPDATE `value`=VALUES(`value`)", [$engine]);
audit('settings.active_db_engine', $engine); audit('settings.active_db_engine', $engine);
@@ -632,5 +710,144 @@ BASH;
Response::success(['output' => substr($out ?: '', -1000)], ucfirst($action) . " $engine done"); Response::success(['output' => substr($out ?: '', -1000)], ucfirst($action) . " $engine done");
})(), })(),
'db-tools' => (function() {
Auth::getInstance()->require('admin');
$pmaInstalled = (int)trim(shell_exec("dpkg -l phpmyadmin 2>/dev/null | grep -c '^ii'") ?: '0') > 0
|| is_dir('/usr/share/phpmyadmin');
$pgaInstalled = (int)trim(shell_exec("dpkg -l pgadmin4 2>/dev/null | grep -c '^ii'") ?: '0') > 0
|| is_file('/usr/pgadmin4/bin/pgadmin4') || is_dir('/usr/pgadmin4');
$pmaVer = $pmaInstalled
? trim(shell_exec("dpkg -l phpmyadmin 2>/dev/null | awk '/^ii/{print $3}' | head -1") ?: '')
: '';
$pgaVer = $pgaInstalled
? trim(shell_exec("pgadmin4 --version 2>/dev/null | grep -oP '[0-9]+\\.[0-9]+' | head -1") ?: '')
: '';
Response::success([
'phpmyadmin' => ['installed' => $pmaInstalled, 'version' => $pmaVer],
'pgadmin' => ['installed' => $pgaInstalled, 'version' => $pgaVer],
]);
})(),
'db-tools-stream' => (function() use ($body) {
Auth::getInstance()->require('admin');
$tool = $body['tool'] ?? '';
$action = $body['action'] ?? '';
if (!in_array($tool, ['phpmyadmin','pgadmin'])) { echo 'data:'.json_encode(['error'=>'Invalid tool'])."\n\n"; exit; }
if (!in_array($action, ['install','reinstall','remove'])) { echo 'data:'.json_encode(['error'=>'Invalid action'])."\n\n"; exit; }
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');
ob_implicit_flush(true);
while (ob_get_level() > 0) ob_end_flush();
set_time_limit(300);
$sse = function(string $line) { echo 'data: ' . json_encode(['line' => $line]) . "\n\n"; flush(); };
$run = function(string $cmd) use ($sse): int {
$proc = proc_open($cmd, [1 => ['pipe','w'], 2 => ['pipe','w']], $pipes);
if (!$proc) { $sse(" [failed to start process]\n"); return 1; }
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
while (true) {
$r = [$pipes[1], $pipes[2]]; $w = $e = [];
if (stream_select($r, $w, $e, 1) > 0) {
foreach ($r as $s) {
$line = fgets($s, 4096);
if ($line !== false && $line !== '') $sse($line);
}
}
if (feof($pipes[1]) && feof($pipes[2])) break;
}
fclose($pipes[1]); fclose($pipes[2]);
return proc_close($proc);
};
$env = 'sudo env DEBIAN_FRONTEND=noninteractive';
if ($tool === 'phpmyadmin') {
if ($action === 'remove') {
$sse("▶ Removing phpMyAdmin…\n");
$run("$env apt-get remove -y phpmyadmin 2>&1");
} else {
if ($action === 'reinstall') {
$sse("▶ Removing existing phpMyAdmin installation…\n");
$run("$env apt-get remove -y phpmyadmin 2>&1");
}
$sse("▶ Pre-configuring debconf answers…\n");
shell_exec("echo 'phpmyadmin phpmyadmin/reconfigure-webserver multiselect apache2' | sudo debconf-set-selections 2>/dev/null");
shell_exec("echo 'phpmyadmin phpmyadmin/dbconfig-install boolean true' | sudo debconf-set-selections 2>/dev/null");
$sse(" ✓ Done\n");
$sse("▶ Installing phpMyAdmin (this takes 2060 seconds)…\n");
$rc = $run("$env apt-get install -y phpmyadmin php8.3-mbstring php8.3-xml php8.3-zip 2>&1");
if ($rc !== 0) { echo 'data:'.json_encode(['error'=>'apt-get failed (see output above)'])."\n\n"; flush(); exit; }
$sse("▶ Configuring web server alias…\n");
if (!is_link('/etc/apache2/conf-enabled/phpmyadmin.conf') && is_file('/etc/phpmyadmin/apache.conf')) {
shell_exec("sudo ln -sf /etc/phpmyadmin/apache.conf /etc/apache2/conf-enabled/phpmyadmin.conf 2>/dev/null");
shell_exec("sudo systemctl reload apache2 2>/dev/null || true");
$sse(" ✓ Apache alias enabled\n");
}
if (is_dir('/etc/nginx') && !is_file('/etc/nginx/conf.d/phpmyadmin.conf')) {
$nginxConf = "location /phpmyadmin {\n root /usr/share/;\n index index.php;\n location ~ ^/phpmyadmin/(.+\\.php)\$ {\n root /usr/share/;\n fastcgi_pass unix:/run/php/php8.3-fpm.sock;\n fastcgi_index index.php;\n include fastcgi.conf;\n }\n}\n";
shell_exec("echo " . escapeshellarg($nginxConf) . " | sudo tee /etc/nginx/conf.d/phpmyadmin.conf > /dev/null");
shell_exec("sudo systemctl reload nginx 2>/dev/null || true");
$sse(" ✓ Nginx alias created\n");
}
}
} else {
// pgAdmin4
if ($action === 'remove') {
$sse("▶ Removing pgAdmin 4…\n");
$run("$env apt-get remove -y pgadmin4 pgadmin4-web 2>&1");
} else {
if ($action === 'reinstall') {
$sse("▶ Removing existing pgAdmin 4 installation…\n");
$run("$env apt-get remove -y pgadmin4 pgadmin4-web 2>&1");
}
if (!is_file('/etc/apt/sources.list.d/pgadmin4.list')) {
$sse("▶ Adding pgAdmin apt repository…\n");
$distro = trim(shell_exec("lsb_release -cs 2>/dev/null") ?: 'jammy');
$run("sudo curl -fsS https://www.pgadmin.org/static/packages_pgadmin_org.pub | sudo gpg --dearmor -o /usr/share/keyrings/pgadmin4.gpg 2>&1");
$repoLine = "deb [signed-by=/usr/share/keyrings/pgadmin4.gpg] https://ftp.postgresql.org/pub/pgadmin/pgadmin4/apt/{$distro} pgadmin4 main\n";
shell_exec("echo " . escapeshellarg($repoLine) . " | sudo tee /etc/apt/sources.list.d/pgadmin4.list > /dev/null");
$sse("▶ Updating package lists…\n");
$run("sudo apt-get update 2>&1");
}
$sse("▶ Installing pgAdmin 4 web (this takes 13 minutes)…\n");
$rc = $run("$env apt-get install -y pgadmin4-web 2>&1");
if ($rc !== 0) { echo 'data:'.json_encode(['error'=>'apt-get failed (see output above)'])."\n\n"; flush(); exit; }
// Enable Apache config
$sse("▶ Enabling Apache configuration…\n");
shell_exec("sudo a2enconf pgadmin4 2>/dev/null");
shell_exec("sudo systemctl reload apache2 2>/dev/null");
$sse(" ✓ Apache config enabled\n");
// Initialise pgAdmin DB with provided credentials
$pgaEmail = $body['pga_email'] ?? '';
$pgaPass = $body['pga_pass'] ?? '';
if ($pgaEmail && $pgaPass) {
$sse("▶ Initialising pgAdmin database and admin user…\n");
// Wipe any broken DB from prior attempts
shell_exec("sudo rm -f /var/lib/pgadmin/pgadmin4.db /var/lib/pgadmin/pgadmin4.db.* 2>/dev/null");
$setupCmd = "sudo env"
. " PGADMIN_SETUP_EMAIL=" . escapeshellarg($pgaEmail)
. " PGADMIN_SETUP_PASSWORD=" . escapeshellarg($pgaPass)
. " /usr/pgadmin4/venv/bin/python3 /usr/pgadmin4/web/setup.py setup-db 2>&1";
$run($setupCmd);
// Fix ownership so Apache/www-data can read the DB and log
shell_exec("sudo mkdir -p /var/log/pgadmin && sudo chown -R www-data:www-data /var/lib/pgadmin /var/log/pgadmin 2>/dev/null");
shell_exec("sudo systemctl reload apache2 2>/dev/null");
} else {
$sse(" ⚠ No credentials provided — run Reinstall to set up admin user\n");
}
}
}
audit("db-tools.$action", $tool);
$verb = ['install'=>'Installed','reinstall'=>'Reinstalled','remove'=>'Removed'][$action];
$sse("{$verb}!\n");
echo 'data: ' . json_encode(['done' => true, 'message' => "$verb $tool"]) . "\n\n";
flush();
exit;
})(),
default => Response::error("Unknown system action: $action", 404), default => Response::error("Unknown system action: $action", 404),
}; };
+33
View File
@@ -27,6 +27,39 @@ match ($action) {
Response::success($result, 'WordPress installed successfully'); Response::success($result, 'WordPress installed successfully');
})(), })(),
'install-stream' => (function() use ($wp, $body, $accountId) {
$required = ['domain','path','admin_user','admin_email','admin_pass','site_title'];
foreach ($required as $f) if (empty($body[$f])) {
header('Content-Type: text/event-stream');
echo 'data: ' . json_encode(['error' => "Missing: {$f}"]) . "\n\n";
flush(); exit;
}
if (!$accountId) {
header('Content-Type: text/event-stream');
echo 'data: ' . json_encode(['error' => 'account_id required']) . "\n\n";
flush(); exit;
}
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');
ob_implicit_flush(true);
while (ob_get_level() > 0) ob_end_flush();
try {
foreach ($wp->installStream($accountId, $body['domain'], $body['path'],
$body['admin_user'], $body['admin_email'], $body['admin_pass'],
$body['site_title']) as $line) {
echo 'data: ' . json_encode(['line' => $line]) . "\n\n";
flush();
}
} catch (Throwable $e) {
echo 'data: ' . json_encode(['error' => $e->getMessage()]) . "\n\n";
flush();
}
echo 'data: ' . json_encode(['done' => true]) . "\n\n";
flush();
exit;
})(),
'update-core' => (function() use ($wp, $body) { 'update-core' => (function() use ($wp, $body) {
$id = (int)($body['id'] ?? 0); $id = (int)($body['id'] ?? 0);
if (!$id) Response::error('id required'); if (!$id) Response::error('id required');
+8 -4
View File
@@ -34,11 +34,12 @@ class DB {
function (array $m): string { function (array $m): string {
$pairs = preg_split('/,\s*/', trim($m[1])); $pairs = preg_split('/,\s*/', trim($m[1]));
$sets = array_map(function (string $pair): string { $sets = array_map(function (string $pair): string {
if (preg_match('/(\w+)\s*=\s*VALUES\s*\(\s*(\w+)\s*\)/i', $pair, $pm)) { // Match plain or backtick-quoted column names: `col`=VALUES(`col`) or col=VALUES(col)
return "{$pm[1]}=excluded.{$pm[2]}"; if (preg_match('/`?(\w+)`?\s*=\s*VALUES\s*\(\s*`?(\w+)`?\s*\)/i', $pair, $pm)) {
return "\"{$pm[1]}\"=excluded.\"{$pm[2]}\"";
} }
// col=? or col=expr — keep as-is // col=? or col=expr — strip backticks, keep as-is
return $pair; return preg_replace('/`(\w+)`/', '"$1"', $pair);
}, $pairs); }, $pairs);
return 'ON CONFLICT DO UPDATE SET ' . implode(', ', $sets); return 'ON CONFLICT DO UPDATE SET ' . implode(', ', $sets);
}, },
@@ -95,6 +96,9 @@ class DB {
// IFNULL → COALESCE (SQLite supports both but be safe) // IFNULL → COALESCE (SQLite supports both but be safe)
$sql = preg_replace('/\bIFNULL\s*\(/i', 'COALESCE(', $sql); $sql = preg_replace('/\bIFNULL\s*\(/i', 'COALESCE(', $sql);
// Backtick identifier quoting → double-quote (SQLite standard)
$sql = preg_replace('/`(\w+)`/', '"$1"', $sql);
return $sql; return $sql;
} }
+82 -8
View File
@@ -1,13 +1,38 @@
<?php <?php
class WordPressManager { class WordPressManager {
private \PDO $db; private \PDO $db;
private \PDO $provDb;
private string $wpcli = '/usr/local/bin/wp'; private string $wpcli = '/usr/local/bin/wp';
public function __construct() { public function __construct() {
$this->db = DB::getInstance()->pdo(); $this->db = DB::getInstance()->pdo();
// Separate privileged connection for CREATE DATABASE / CREATE USER / GRANT
$this->provDb = $this->makeProvPdo();
$this->ensureWpCli(); $this->ensureWpCli();
} }
private function makeProvPdo(): \PDO {
$wpUser = DB_WP_USER;
$wpPass = DB_WP_PASS;
if ($wpUser) {
try {
return new \PDO(
'mysql:host=' . DB_HOST . ';charset=utf8mb4',
$wpUser, $wpPass,
[\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]
);
} catch (\PDOException $e) {
// Fall through to root socket attempt
}
}
// Fallback: root via Unix socket (works on fresh installs where root has no password)
return new \PDO(
'mysql:host=localhost;unix_socket=/var/run/mysqld/mysqld.sock;charset=utf8mb4',
'root', '',
[\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]
);
}
// ── Install ─────────────────────────────────────────────────────────────── // ── Install ───────────────────────────────────────────────────────────────
public function install(int $accountId, string $domain, string $path, public function install(int $accountId, string $domain, string $path,
string $adminUser, string $adminEmail, string $adminPass, string $adminUser, string $adminEmail, string $adminPass,
@@ -19,9 +44,9 @@ class WordPressManager {
$dbUser = substr($dbName, 0, 32); $dbUser = substr($dbName, 0, 32);
// Create DB // Create DB
$this->db->exec("CREATE DATABASE IF NOT EXISTS `{$dbName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); $this->provDb->exec("CREATE DATABASE IF NOT EXISTS `{$dbName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
$this->db->exec("CREATE USER IF NOT EXISTS '{$dbUser}'@'localhost' IDENTIFIED BY '{$dbPass}'"); $this->provDb->exec("CREATE USER IF NOT EXISTS '{$dbUser}'@'localhost' IDENTIFIED BY '{$dbPass}'");
$this->db->exec("GRANT ALL ON `{$dbName}`.* TO '{$dbUser}'@'localhost'"); $this->provDb->exec("GRANT ALL ON `{$dbName}`.* TO '{$dbUser}'@'localhost'");
// Download WP + install // Download WP + install
$sysUser = $account['system_user'] ?? 'www-data'; $sysUser = $account['system_user'] ?? 'www-data';
@@ -86,9 +111,9 @@ class WordPressManager {
// Clone DB // Clone DB
$stagingDb = $install['db_name'] . '_staging'; $stagingDb = $install['db_name'] . '_staging';
$stagingDbPw = bin2hex(random_bytes(8)); $stagingDbPw = bin2hex(random_bytes(8));
$this->db->exec("CREATE DATABASE IF NOT EXISTS `{$stagingDb}`"); $this->provDb->exec("CREATE DATABASE IF NOT EXISTS `{$stagingDb}`");
$this->db->exec("CREATE USER IF NOT EXISTS '{$stagingDb}'@'localhost' IDENTIFIED BY '{$stagingDbPw}'"); $this->provDb->exec("CREATE USER IF NOT EXISTS '{$stagingDb}'@'localhost' IDENTIFIED BY '{$stagingDbPw}'");
$this->db->exec("GRANT ALL ON `{$stagingDb}`.* TO '{$stagingDb}'@'localhost'"); $this->provDb->exec("GRANT ALL ON `{$stagingDb}`.* TO '{$stagingDb}'@'localhost'");
$this->exec("mysqldump {$install['db_name']} | mysql {$stagingDb}"); $this->exec("mysqldump {$install['db_name']} | mysql {$stagingDb}");
// Update staging wp-config // Update staging wp-config
@@ -112,8 +137,8 @@ class WordPressManager {
public function delete(int $id): bool { public function delete(int $id): bool {
[$install, $sysUser, $docRoot] = $this->resolve($id); [$install, $sysUser, $docRoot] = $this->resolve($id);
$this->exec("rm -rf {$docRoot}"); $this->exec("rm -rf {$docRoot}");
$this->db->exec("DROP DATABASE IF EXISTS `{$install['db_name']}`"); $this->provDb->exec("DROP DATABASE IF EXISTS `{$install['db_name']}`");
$this->db->exec("DROP USER IF EXISTS '{$install['db_user']}'@'localhost'"); $this->provDb->exec("DROP USER IF EXISTS '{$install['db_user']}'@'localhost'");
$this->db->prepare("DELETE FROM wordpress_installs WHERE id=?")->execute([$id]); $this->db->prepare("DELETE FROM wordpress_installs WHERE id=?")->execute([$id]);
return true; return true;
} }
@@ -164,6 +189,55 @@ class WordPressManager {
return $stmt->fetch(PDO::FETCH_ASSOC) ?: throw new RuntimeException("Account #{$id} not found"); return $stmt->fetch(PDO::FETCH_ASSOC) ?: throw new RuntimeException("Account #{$id} not found");
} }
// ── Streaming install (yields progress lines for SSE) ─────────────────────
public function installStream(int $accountId, string $domain, string $path,
string $adminUser, string $adminEmail, string $adminPass,
string $siteTitle): \Generator {
yield "▶ Resolving account...\n";
$account = $this->getAccount($accountId);
$docRoot = $account['document_root'] . rtrim($path, '/');
$dbName = 'wp_' . preg_replace('/[^a-z0-9]/', '_', strtolower($account['username'])) . '_' . substr(md5($domain), 0, 6);
$dbPass = bin2hex(random_bytes(12));
$dbUser = substr($dbName, 0, 32);
$sysUser = $account['system_user'] ?? 'www-data';
yield "▶ Creating MySQL database ({$dbName})...\n";
$this->provDb->exec("CREATE DATABASE IF NOT EXISTS `{$dbName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
$this->provDb->exec("CREATE USER IF NOT EXISTS '{$dbUser}'@'localhost' IDENTIFIED BY '{$dbPass}'");
$this->provDb->exec("GRANT ALL ON `{$dbName}`.* TO '{$dbUser}'@'localhost'");
yield " ✓ Database ready\n";
yield "▶ Downloading WordPress core (this takes 20-40 seconds)...\n";
$out = $this->wp($docRoot, "core download --locale=en_US", $sysUser);
yield $out ? " " . trim($out) . "\n" : " ✓ Core downloaded\n";
yield "▶ Creating wp-config.php...\n";
$out = $this->wp($docRoot, "config create --dbname={$dbName} --dbuser={$dbUser} --dbpass={$dbPass} --dbhost=localhost --skip-check", $sysUser);
yield $out ? " " . trim($out) . "\n" : " ✓ wp-config.php created\n";
yield "▶ Running WordPress installer...\n";
$out = $this->wp($docRoot, sprintf(
'core install --url=https://%s --title=%s --admin_user=%s --admin_password=%s --admin_email=%s --skip-email',
escapeshellarg($domain . $path),
escapeshellarg($siteTitle),
escapeshellarg($adminUser),
escapeshellarg($adminPass),
escapeshellarg($adminEmail)
), $sysUser);
yield $out ? " " . trim($out) . "\n" : " ✓ WordPress installed\n";
yield "▶ Saving installation record...\n";
$stmt = $this->db->prepare("INSERT INTO wordpress_installs
(account_id, domain, path, db_name, db_user, db_pass, admin_user, admin_email, wp_version, status)
VALUES (?,?,?,?,?,?,?,?,?,?)");
$stmt->execute([$accountId, $domain, $path, $dbName, $dbUser, $dbPass, $adminUser, $adminEmail,
$this->getVersion($docRoot, $sysUser), 'active']);
$id = (int)$this->db->lastInsertId();
yield " ✓ Done — WordPress ID #{$id}\n";
yield '__DONE__' . json_encode(['id' => $id, 'admin_user' => $adminUser, 'admin_pass' => $adminPass, 'domain' => $domain]) . "\n";
}
private function ensureWpCli(): void { private function ensureWpCli(): void {
if (!file_exists($this->wpcli)) { if (!file_exists($this->wpcli)) {
file_put_contents('/tmp/wp-cli.phar', file_get_contents('https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar')); file_put_contents('/tmp/wp-cli.phar', file_get_contents('https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar'));
+5 -1
View File
@@ -113,7 +113,11 @@ require_once dirname(__DIR__) . '/_branding.php';
</a> </a>
<a href="#" class="sidebar-link" data-page="firewall"> <a href="#" class="sidebar-link" data-page="firewall">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Firewall / Fail2Ban Firewall (UFW)
</a>
<a href="#" class="sidebar-link" data-page="fail2ban">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Fail2Ban
</a> </a>
<a href="#" class="sidebar-link" data-page="audit-log"> <a href="#" class="sidebar-link" data-page="audit-log">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
+558 -28
View File
@@ -93,6 +93,7 @@
docker, docker,
'ssl-manager': sslManager, 'ssl-manager': sslManager,
firewall, firewall,
fail2ban,
'audit-log': auditLog, 'audit-log': auditLog,
twofa, twofa,
updates, updates,
@@ -1519,6 +1520,227 @@
</div>`; </div>`;
} }
// ── Fail2Ban Manager ────────────────────────────────────────────────────────
async function fail2ban() {
const [f2bRes, cfgRes, ignoreipRes] = await Promise.all([
Nova.api('firewall', 'f2b-status'),
Nova.api('firewall', 'f2b-config-get'),
Nova.api('firewall', 'f2b-ignoreip-list'),
]);
const jails = f2bRes?.data?.jails || [];
const cfg = cfgRes?.data || { bantime: 3600, findtime: 600, maxretry: 5 };
const ignoreips = ignoreipRes?.data?.ignoreip || ignoreipRes?.data?.detected || [];
const totalBanned = jails.reduce((s, j) => s + (j.currently_banned || 0), 0);
return `
<div class="page-header mb-3">
<h2 class="page-title">Fail2Ban</h2>
<div style="display:flex;gap:.5rem;align-items:center">
${totalBanned > 0 ? Nova.badge(totalBanned + ' banned', 'red') : Nova.badge('Clean', 'green')}
<button class="btn btn-sm btn-ghost" onclick="adminPage('fail2ban')"> Refresh</button>
<button class="btn btn-sm btn-ghost" onclick="f2bReloadCfg()">Reload Config</button>
<button class="btn btn-sm btn-ghost" onclick="f2bRestartSvc()">Restart Service</button>
</div>
</div>
<!-- Global Settings -->
<div class="card mb-3">
<div class="card-header"><span class="card-title">Global Settings</span></div>
<div class="card-body">
<div style="display:flex;gap:1.5rem;flex-wrap:wrap;align-items:flex-end">
<div class="form-group mb-0">
<label class="form-label">Ban Time (seconds)</label>
<input id="f2b-bantime" type="number" class="form-control" value="${Nova.escHtml(cfg.bantime)}" style="width:130px" min="60">
<small class="text-muted">How long IPs stay banned</small>
</div>
<div class="form-group mb-0">
<label class="form-label">Find Time (seconds)</label>
<input id="f2b-findtime" type="number" class="form-control" value="${Nova.escHtml(cfg.findtime)}" style="width:130px" min="60">
<small class="text-muted">Window to count failures</small>
</div>
<div class="form-group mb-0">
<label class="form-label">Max Retry</label>
<input id="f2b-maxretry" type="number" class="form-control" value="${Nova.escHtml(cfg.maxretry)}" style="width:100px" min="1">
<small class="text-muted">Failures before ban</small>
</div>
<button class="btn btn-primary mb-0" onclick="f2bSaveCfg()">Save Settings</button>
</div>
</div>
</div>
<!-- Jails -->
<div class="card mb-3">
<div class="card-header">
<span class="card-title">Active Jails</span>
<span class="text-muted text-sm ml-2">${jails.length} jail${jails.length !== 1 ? 's' : ''}</span>
</div>
${jails.length ? `
<div class="table-wrap">
<table>
<thead><tr><th>Jail</th><th>Currently Banned</th><th>Total Banned</th><th>Failed</th><th></th></tr></thead>
<tbody>
${jails.map(j => `<tr>
<td><strong>${Nova.escHtml(j.jail)}</strong></td>
<td>${j.currently_banned > 0
? `<span style="color:var(--red);font-weight:600">${j.currently_banned}</span>`
: '<span class="text-muted">0</span>'}</td>
<td class="text-muted">${j.total_banned}</td>
<td class="text-muted">${j.currently_failed}</td>
<td style="display:flex;gap:.35rem;flex-wrap:wrap">
${j.currently_banned > 0
? `<button class="btn btn-xs btn-ghost" onclick="f2bViewJail('${Nova.escHtml(j.jail)}')">View Banned</button>`
: ''}
<button class="btn btn-xs btn-ghost" onclick="f2bBanModal('${Nova.escHtml(j.jail)}')">Ban IP</button>
</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : `<div class="card-body"><p class="text-muted">Fail2Ban not running or no jails active.</p></div>`}
</div>
<!-- Whitelist -->
<div class="card mb-3">
<div class="card-header">
<span class="card-title">Whitelist (Never Ban)</span>
<span class="text-muted text-sm ml-2">${ignoreips.length} entr${ignoreips.length !== 1 ? 'ies' : 'y'}</span>
<button class="btn btn-xs btn-ghost ml-auto" onclick="fwIgnoreipReset()">Reset to Defaults</button>
</div>
<div class="card-body">
<div style="display:flex;gap:.5rem;margin-bottom:.75rem">
<input id="f2b-ignoreip-input" class="form-control form-control-sm"
placeholder="IP or CIDR — e.g. 203.0.113.5 or 192.168.1.0/24" style="flex:1">
<button class="btn btn-sm btn-primary" onclick="f2bWhitelistAdd()">Add</button>
</div>
<div id="f2b-ignoreip-chips" style="display:flex;flex-wrap:wrap;gap:.35rem">
${ignoreips.map(ip => `<span class="badge badge-green" style="cursor:pointer" title="Click to remove"
onclick="f2bWhitelistRemove('${Nova.escHtml(ip)}')">${Nova.escHtml(ip)} ×</span>`).join('')}
</div>
<p class="text-muted text-sm mt-2" style="font-size:.75rem">Your own IP/subnet should always be whitelisted.</p>
</div>
</div>
<!-- Log Viewer -->
<div class="card">
<div class="card-header">
<span class="card-title">Log Viewer</span>
<div style="display:flex;gap:.5rem;align-items:center;margin-left:auto">
<select id="f2b-log-lines" class="form-control form-control-sm" style="width:auto">
<option value="50">Last 50</option>
<option value="100" selected>Last 100</option>
<option value="250">Last 250</option>
<option value="500">Last 500</option>
</select>
<button class="btn btn-sm btn-ghost" onclick="f2bLoadLog()">Load Log</button>
</div>
</div>
<div class="card-body p-0">
<div id="f2b-log-content" style="background:#0d1117;color:#c9d1d9;font-family:monospace;font-size:.75rem;padding:1rem;max-height:400px;overflow-y:auto;border-radius:0 0 8px 8px">
<span class="text-muted">Click "Load Log" to view Fail2Ban activity.</span>
</div>
</div>
</div>`;
}
window.f2bSaveCfg = async () => {
const bantime = document.getElementById('f2b-bantime')?.value;
const findtime = document.getElementById('f2b-findtime')?.value;
const maxretry = document.getElementById('f2b-maxretry')?.value;
const r = await Nova.api('firewall', 'f2b-config-save', { method: 'POST', body: { bantime, findtime, maxretry } });
Nova.toast(r?.message || (r?.success ? 'Saved' : 'Failed'), r?.success ? 'success' : 'error');
};
window.f2bReloadCfg = async () => {
const r = await Nova.api('firewall', 'f2b-reload', { method: 'POST' });
Nova.toast(r?.message || (r?.success ? 'Reloaded' : 'Failed'), r?.success ? 'success' : 'error');
};
window.f2bRestartSvc = async () => {
const r = await Nova.api('firewall', 'f2b-restart', { method: 'POST' });
Nova.toast(r?.message || (r?.success ? 'Restarted' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('fail2ban');
};
window.f2bViewJail = async (jail) => {
const r = await Nova.api('firewall', 'f2b-jail', { method: 'POST', body: { jail } });
const d = r?.data || {};
const ips = d.banned_ips || [];
Nova.modal(`Jail: ${jail}`,
`<div style="display:flex;gap:2rem;margin-bottom:1rem">
<div><p class="text-muted text-sm">Currently Banned</p><p class="font-bold">${d.currently_banned ?? 0}</p></div>
<div><p class="text-muted text-sm">Total Banned</p><p class="font-bold">${d.total_banned ?? 0}</p></div>
<div><p class="text-muted text-sm">Currently Failed</p><p class="font-bold">${d.currently_failed ?? 0}</p></div>
</div>
${ips.length ? `<table style="width:100%"><thead><tr><th>IP Address</th><th></th></tr></thead><tbody>
${ips.map(ip => `<tr>
<td><code>${Nova.escHtml(ip)}</code></td>
<td><button class="btn btn-xs" style="color:var(--red)" onclick="f2bUnban('${Nova.escHtml(ip)}','${Nova.escHtml(jail)}')">Unban</button></td>
</tr>`).join('')}
</tbody></table>` : '<p class="text-muted">No IPs currently banned.</p>'}`
);
};
window.f2bBanModal = (jail) => {
Nova.modal(`Ban IP in jail: ${jail}`,
`<div class="form-group">
<label class="form-label">IP Address to Ban</label>
<input id="f2b-ban-ip" class="form-control" placeholder="1.2.3.4">
</div>`,
`<button class="btn btn-primary" onclick="f2bBanSubmit('${Nova.escHtml(jail)}')">Ban IP</button>`
);
};
window.f2bBanSubmit = async (jail) => {
const ip = document.getElementById('f2b-ban-ip')?.value?.trim();
if (!ip) return;
document.querySelector('.modal-overlay')?.remove();
const r = await Nova.api('firewall', 'f2b-ban', { method: 'POST', body: { ip, jail } });
Nova.toast(r?.message || (r?.success ? 'Banned' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('fail2ban');
};
window.f2bUnban = async (ip, jail) => {
document.querySelector('.modal-overlay')?.remove();
const r = await Nova.api('firewall', 'f2b-unban', { method: 'POST', body: { ip, jail } });
Nova.toast(r?.message || (r?.success ? 'Unbanned' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('fail2ban');
};
window.f2bWhitelistAdd = async () => {
const ip = document.getElementById('f2b-ignoreip-input')?.value?.trim();
if (!ip) return;
const r = await Nova.api('firewall', 'f2b-ignoreip-add', { method: 'POST', body: { ip } });
Nova.toast(r?.message || (r?.success ? 'Added' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('fail2ban');
};
window.f2bWhitelistRemove = async (ip) => {
const r = await Nova.api('firewall', 'f2b-ignoreip-remove', { method: 'POST', body: { ip } });
Nova.toast(r?.message || (r?.success ? 'Removed' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('fail2ban');
};
window.f2bLoadLog = async () => {
const lines = document.getElementById('f2b-log-lines')?.value || 100;
const el = document.getElementById('f2b-log-content');
if (el) el.innerHTML = '<span class="text-muted">Loading…</span>';
const r = await Nova.api('firewall', 'f2b-log', { method: 'POST', body: { lines: parseInt(lines) } });
if (el) {
if (r?.success && r.data?.log) {
// Colorize: NOTICE=green, WARNING=yellow, ERROR/BAN=red, UNBAN=blue
const colored = Nova.escHtml(r.data.log)
.replace(/(NOTICE)/g, '<span style="color:#58a6ff">$1</span>')
.replace(/(WARNING)/g, '<span style="color:#e3b341">$1</span>')
.replace(/\b(BAN)\b/g, '<span style="color:#f85149">$1</span>')
.replace(/\b(UNBAN)\b/g, '<span style="color:#3fb950">$1</span>')
.replace(/(ERROR)/g, '<span style="color:#f85149;font-weight:bold">$1</span>');
el.innerHTML = colored;
el.scrollTop = el.scrollHeight;
} else {
el.innerHTML = '<span style="color:var(--red)">Failed to load log.</span>';
}
}
};
function fwActionBadge(action) { function fwActionBadge(action) {
const a = (action||'').toLowerCase(); const a = (action||'').toLowerCase();
if (a.includes('allow')) return Nova.badge('ALLOW','green'); if (a.includes('allow')) return Nova.badge('ALLOW','green');
@@ -1758,13 +1980,15 @@ ${ips.length ? `
// ── MySQL/DB Manager ─────────────────────────────────────────────────────── // ── MySQL/DB Manager ───────────────────────────────────────────────────────
async function mysqlManager() { async function mysqlManager() {
const [engRes, dbRes] = await Promise.all([ const [engRes, dbRes, toolsRes] = await Promise.all([
Nova.api('system','db-engines'), Nova.api('system','db-engines'),
Nova.api('databases','list',{params:{account_id:0}}), Nova.api('databases','list',{params:{account_id:0}}),
Nova.api('system','db-tools'),
]); ]);
const eng = engRes?.data?.engines || {}; const eng = engRes?.data?.engines || {};
const actE = engRes?.data?.active_engine || 'mysql'; const actE = engRes?.data?.active_engine || 'mysql';
const dbs = dbRes?.data || []; const dbs = dbRes?.data || [];
const tools = toolsRes?.data || {};
const engineCard = (id, label, icon) => { const engineCard = (id, label, icon) => {
const e = eng[id] || {}; const e = eng[id] || {};
@@ -1778,7 +2002,7 @@ ${ips.length ? `
${e.version ? `<span class="text-muted" style="font-size:.8rem;margin-left:.5rem">v${e.version}</span>` : ''} ${e.version ? `<span class="text-muted" style="font-size:.8rem;margin-left:.5rem">v${e.version}</span>` : ''}
</div> </div>
<div class="card-body"> <div class="card-body">
<div style="display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:.75rem"> <div style="display:flex;flex-wrap:wrap;gap:.4rem">
${!e.installed ${!e.installed
? `<button class="btn btn-xs btn-primary" onclick="dbEngineAction('${id}','install')">Install</button>` ? `<button class="btn btn-xs btn-primary" onclick="dbEngineAction('${id}','install')">Install</button>`
: ` : `
@@ -1788,8 +2012,31 @@ ${ips.length ? `
<button class="btn btn-xs btn-danger" onclick="dbEngineAction('${id}','remove')">Remove</button>` <button class="btn btn-xs btn-danger" onclick="dbEngineAction('${id}','remove')">Remove</button>`
} }
</div> </div>
${e.installed && id !== 'postgresql' ? `<a href="http://${location.hostname}/phpmyadmin" target="_blank" class="btn btn-xs btn-ghost">phpMyAdmin ↗</a>` : ''} </div>
${e.installed && id === 'postgresql' ? `<a href="http://${location.hostname}/pgadmin" target="_blank" class="btn btn-xs btn-ghost">pgAdmin ↗</a>` : ''} </div>`;
};
const toolCard = (id, label, icon, url) => {
const t = tools[id] || {};
const statusColor = t.installed ? 'green' : 'default';
const statusText = t.installed ? 'Installed' : 'Not Installed';
return `
<div class="card">
<div class="card-header">
<span class="card-title">${icon} ${label}</span>
${Nova.badge(statusText, statusColor)}
${t.version ? `<span class="text-muted" style="font-size:.8rem;margin-left:.5rem">v${t.version}</span>` : ''}
</div>
<div class="card-body">
<div style="display:flex;flex-wrap:wrap;gap:.4rem">
${!t.installed
? `<button class="btn btn-xs btn-primary" onclick="dbToolAction('${id}','install')">Install</button>`
: `
<button class="btn btn-xs" onclick="dbToolAction('${id}','reinstall')">Reinstall</button>
<button class="btn btn-xs btn-danger" onclick="dbToolAction('${id}','remove')">Remove</button>
<a href="${url}" target="_blank" class="btn btn-xs btn-ghost">Open </a>`
}
</div>
</div> </div>
</div>`; </div>`;
}; };
@@ -1827,6 +2074,16 @@ ${dbs.map(d=>`<tr>
</div> </div>
</div> </div>
<div class="card" style="margin-bottom:1.5rem">
<div class="card-header"><span class="card-title">Database Admin Tools</span></div>
<div class="card-body" style="padding-bottom:.5rem">
<div class="grid-2 gap-2">
${toolCard('phpmyadmin', 'phpMyAdmin', '🛢', `http://${location.hostname}/phpmyadmin`)}
${toolCard('pgadmin', 'pgAdmin 4', '🐘', `http://${location.hostname}/pgadmin4`)}
</div>
</div>
</div>
<div class="card"> <div class="card">
<div class="card-header"><span class="card-title">All Databases</span><span class="text-muted" style="font-size:.8rem">${dbs.length} total</span></div> <div class="card-header"><span class="card-title">All Databases</span><span class="text-muted" style="font-size:.8rem">${dbs.length} total</span></div>
${dbTable} ${dbTable}
@@ -1861,6 +2118,142 @@ ${dbs.map(d=>`<tr>
if (r?.success) adminPage('mysql-manager'); if (r?.success) adminPage('mysql-manager');
}; };
window.dbToolAction = (tool, action) => {
const names = { phpmyadmin: 'phpMyAdmin', pgadmin: 'pgAdmin 4' };
const name = names[tool] || tool;
const msgs = {
install: `Install ${name}?`,
reinstall: `Reinstall ${name}? The existing installation will be removed first.`,
remove: `Remove ${name}?`,
};
// pgAdmin needs an admin account — collect credentials before install/reinstall
if (tool === 'pgadmin' && action !== 'remove') {
Nova.modal(`${action === 'reinstall' ? 'Reinstall' : 'Install'} pgAdmin 4`, `
<p class="text-muted text-sm mb-2">pgAdmin requires an admin account to be created on first run.</p>
<div class="form-group"><label>Admin Email</label><input id="pga-email" class="form-control" type="email" placeholder="admin@example.com"></div>
<div class="form-group"><label>Admin Password</label><input id="pga-pass" class="form-control" type="password" placeholder="Strong password"></div>`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" onclick="dbToolRunInstall('pgadmin','${action}')">Continue</button>`);
return;
}
const openTerminal = (extra = {}) => {
document.querySelector('.modal-overlay')?.remove();
const termId = 'dbt-term-' + Date.now();
const verb = action === 'remove' ? 'Removing' : action === 'reinstall' ? 'Reinstalling' : 'Installing';
Nova.modal(`${verb} ${name}`, `
<div id="${termId}" style="background:#1a1a2e;color:#e0e0e0;font-family:monospace;font-size:.82rem;
padding:1rem;border-radius:6px;height:280px;overflow-y:auto;white-space:pre-wrap;line-height:1.5">
<span style="color:#7ec8e3">Starting</span>\n
</div>`,
`<button class="btn btn-ghost" id="dbt-term-close" onclick="this.closest('.modal-overlay').remove()">Close</button>`);
const term = document.getElementById(termId);
const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; };
fetch('/api/system/db-tools-stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tool, action, ...extra }),
credentials: 'same-origin',
}).then(resp => {
if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; }
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = '';
const read = () => reader.read().then(({ done, value }) => {
if (done) { append('\n[stream closed]'); return; }
buf += dec.decode(value, { stream: true });
const parts = buf.split('\n\n');
buf = parts.pop();
for (const part of parts) {
const m = part.match(/^data: (.+)$/m);
if (!m) continue;
try {
const obj = JSON.parse(m[1]);
if (obj.line) { append(obj.line); }
else if (obj.error) { append(`\n${obj.error}\n`); }
else if (obj.done) {
const btn = document.getElementById('dbt-term-close');
if (btn) {
btn.textContent = 'Done';
btn.className = 'btn btn-primary';
btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); adminPage('mysql-manager'); };
}
}
} catch(e) {}
}
read();
}).catch(err => append(`\n[error: ${err.message}]`));
read();
}).catch(err => append(`\nFetch error: ${err.message}`));
};
Nova.confirm(msgs[action], () => openTerminal(), action === 'remove');
};
window.dbToolRunInstall = (tool, action) => {
const email = document.getElementById('pga-email')?.value?.trim();
const pass = document.getElementById('pga-pass')?.value;
if (!email) { Nova.toast('Email is required','error'); return; }
if (!pass) { Nova.toast('Password is required','error'); return; }
// Re-invoke with credentials — openTerminal will close the form modal first
const names = { phpmyadmin: 'phpMyAdmin', pgadmin: 'pgAdmin 4' };
const name = names[tool] || tool;
const msgs = { install: `Install ${name}?`, reinstall: `Reinstall ${name}?` };
const doOpen = () => {
document.querySelector('.modal-overlay')?.remove();
const termId = 'dbt-term-' + Date.now();
const verb = action === 'reinstall' ? 'Reinstalling' : 'Installing';
Nova.modal(`${verb} ${name}`, `
<div id="${termId}" style="background:#1a1a2e;color:#e0e0e0;font-family:monospace;font-size:.82rem;
padding:1rem;border-radius:6px;height:280px;overflow-y:auto;white-space:pre-wrap;line-height:1.5">
<span style="color:#7ec8e3">Starting</span>\n
</div>`,
`<button class="btn btn-ghost" id="dbt-term-close" onclick="this.closest('.modal-overlay').remove()">Close</button>`);
const term = document.getElementById(termId);
const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; };
fetch('/api/system/db-tools-stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tool, action, pga_email: email, pga_pass: pass }),
credentials: 'same-origin',
}).then(resp => {
if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; }
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = '';
const read = () => reader.read().then(({ done, value }) => {
if (done) { append('\n[stream closed]'); return; }
buf += dec.decode(value, { stream: true });
const parts = buf.split('\n\n');
buf = parts.pop();
for (const part of parts) {
const m = part.match(/^data: (.+)$/m);
if (!m) continue;
try {
const obj = JSON.parse(m[1]);
if (obj.line) { append(obj.line); }
else if (obj.error) { append(`\n${obj.error}\n`); }
else if (obj.done) {
const btn = document.getElementById('dbt-term-close');
if (btn) {
btn.textContent = 'Done';
btn.className = 'btn btn-primary';
btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); adminPage('mysql-manager'); };
}
}
} catch(e) {}
}
read();
}).catch(err => append(`\n[error: ${err.message}]`));
read();
}).catch(err => append(`\nFetch error: ${err.message}`));
};
doOpen();
};
// ── Mail Server ──────────────────────────────────────────────────────────── // ── Mail Server ────────────────────────────────────────────────────────────
async function mailServer() { async function mailServer() {
const r = await Nova.api('system','stats'); const r = await Nova.api('system','stats');
@@ -2128,32 +2521,129 @@ window.wpInstallModal = () => {
Nova.modal('Install WordPress', ` Nova.modal('Install WordPress', `
<div class="form-group"><label>Account</label><select id="wp-acct" class="form-control">${opts}</select></div> <div class="form-group"><label>Account</label><select id="wp-acct" class="form-control">${opts}</select></div>
<div class="form-group"><label>Domain</label><input id="wp-domain" class="form-control" placeholder="example.com"></div> <div class="form-group"><label>Domain</label><input id="wp-domain" class="form-control" placeholder="example.com"></div>
<div class="form-group"><label>Path (leave / for root)</label><input id="wp-path" class="form-control" value="/"></div> <div class="form-group">
<label>Install Path</label>
<div style="display:flex;gap:.5rem;align-items:center">
<select id="wp-path-preset" class="form-control" style="flex:0 0 auto;width:auto" onchange="wpPathPreset(this)">
<option value="/">/ (site root)</option>
<option value="/blog">/blog</option>
<option value="/wp">/wp</option>
<option value="/wordpress">/wordpress</option>
<option value="/cms">/cms</option>
<option value="__custom">Custom</option>
</select>
<input id="wp-path" class="form-control" value="/" placeholder="/path" style="display:none">
</div>
<p class="text-muted text-sm mt-1">Install at site root (/) unless you want WordPress at a subdirectory.</p>
</div>
<div class="form-group"><label>Site Title</label><input id="wp-title" class="form-control" placeholder="My WordPress Site"></div> <div class="form-group"><label>Site Title</label><input id="wp-title" class="form-control" placeholder="My WordPress Site"></div>
<div class="form-group"><label>WP Admin Username</label><input id="wp-admin" class="form-control" value="admin"></div> <div class="form-group"><label>WP Admin Username</label><input id="wp-admin" class="form-control" value="admin"></div>
<div class="form-group"><label>WP Admin Password</label><input id="wp-adminpass" type="password" class="form-control"></div> <div class="form-group"><label>WP Admin Password</label><input id="wp-adminpass" type="password" class="form-control"></div>
<div class="form-group"><label>WP Admin Email</label><input id="wp-email" type="email" class="form-control"></div> <div class="form-group"><label>WP Admin Email</label><input id="wp-email" type="email" class="form-control"></div>
<p class="text-muted text-sm">wp-cli will be downloaded automatically if not installed. This may take 1-2 minutes.</p>`, <p class="text-muted text-sm">wp-cli will be downloaded automatically if not installed. Installation takes 1-2 minutes a live terminal will show progress.</p>`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button> `<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" id="wp-install-btn" onclick="wpSubmitInstall()">Install</button>`); <button class="btn btn-primary" id="wp-install-btn" onclick="wpSubmitInstall()">Install</button>`);
}; };
window.wpSubmitInstall = async () => { window.wpPathPreset = (sel) => {
const btn = document.getElementById('wp-install-btn'); const pathInput = document.getElementById('wp-path');
if (btn) { btn.disabled = true; btn.textContent = 'Installing…'; } if (sel.value === '__custom') {
Nova.toast('Installing WordPress — this may take 1-2 minutes…', 'info', 90000); pathInput.style.display = '';
const res = await Nova.api('wordpress','install',{method:'POST',body:{ pathInput.value = '/';
account_id: +document.getElementById('wp-acct')?.value, pathInput.focus();
domain: document.getElementById('wp-domain')?.value, } else {
path: document.getElementById('wp-path')?.value || '/', pathInput.style.display = 'none';
site_title: document.getElementById('wp-title')?.value, pathInput.value = sel.value;
admin_user: document.getElementById('wp-admin')?.value, }
admin_pass: document.getElementById('wp-adminpass')?.value, };
admin_email:document.getElementById('wp-email')?.value,
}}); window.wpSubmitInstall = () => {
const acctId = +document.getElementById('wp-acct')?.value;
const domain = document.getElementById('wp-domain')?.value?.trim();
const preset = document.getElementById('wp-path-preset')?.value;
const path = preset === '__custom'
? (document.getElementById('wp-path')?.value?.trim() || '/')
: (preset || '/');
const title = document.getElementById('wp-title')?.value?.trim();
const admin = document.getElementById('wp-admin')?.value?.trim();
const pass = document.getElementById('wp-adminpass')?.value;
const email = document.getElementById('wp-email')?.value?.trim();
if (!domain) { Nova.toast('Domain is required','error'); return; }
if (!title) { Nova.toast('Site title is required','error'); return; }
if (!admin) { Nova.toast('Admin username is required','error'); return; }
if (!pass) { Nova.toast('Admin password is required','error'); return; }
if (!email) { Nova.toast('Admin email is required','error'); return; }
// Close form modal, open terminal modal
document.querySelector('.modal-overlay')?.remove(); document.querySelector('.modal-overlay')?.remove();
if (res?.success) { Nova.toast('WordPress installed!','success'); adminPage('wordpress'); }
else Nova.toast(res?.message || 'Install failed','error'); const termId = 'wp-term-' + Date.now();
Nova.modal('Installing WordPress', `
<div id="${termId}" style="background:#1a1a2e;color:#e0e0e0;font-family:monospace;font-size:.82rem;
padding:1rem;border-radius:6px;height:280px;overflow-y:auto;white-space:pre-wrap;line-height:1.5">
<span style="color:#7ec8e3">Connecting to server</span>\n
</div>`,
`<button class="btn btn-ghost" id="wp-term-cancel" onclick="this.closest('.modal-overlay').remove()">Close</button>`);
const term = document.getElementById(termId);
const append = (text) => {
term.textContent += text;
term.scrollTop = term.scrollHeight;
};
const body = JSON.stringify({ account_id: acctId, domain, path, site_title: title,
admin_user: admin, admin_pass: pass, admin_email: email });
fetch(`/api/wordpress/install-stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
credentials: 'same-origin',
}).then(resp => {
if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; }
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = '';
const read = () => reader.read().then(({ done, value }) => {
if (done) { append('\n[stream closed]'); return; }
buf += dec.decode(value, { stream: true });
const parts = buf.split('\n\n');
buf = parts.pop();
for (const part of parts) {
const m = part.match(/^data: (.+)$/m);
if (!m) continue;
try {
const obj = JSON.parse(m[1]);
if (obj.line) {
// Check for __DONE__ sentinel
if (obj.line.startsWith('__DONE__')) {
try {
const result = JSON.parse(obj.line.slice(8));
append(`\n✓ WordPress installed! Admin: ${result.admin_user} | ID #${result.id}\n`);
} catch(e) { append('\n✓ WordPress installed!\n'); }
} else {
append(obj.line);
}
} else if (obj.error) {
append(`\n✗ Error: ${obj.error}\n`);
} else if (obj.done) {
const cancelBtn = document.getElementById('wp-term-cancel');
if (cancelBtn) {
cancelBtn.textContent = 'Done';
cancelBtn.className = 'btn btn-primary';
cancelBtn.onclick = () => {
document.querySelector('.modal-overlay')?.remove();
adminPage('wordpress');
};
}
}
} catch(e) {}
}
read();
}).catch(err => append(`\n[connection error: ${err.message}]`));
read();
}).catch(err => append(`\nFetch error: ${err.message}`));
}; };
window.wpUpdate = async (id, type) => { window.wpUpdate = async (id, type) => {
@@ -3628,15 +4118,55 @@ async function serverOptions() {
</div>`; </div>`;
} }
window.soSave = async (key, inputId, label) => { window.soSave = (key, inputId, label) => {
const val = document.getElementById(inputId)?.value; const val = document.getElementById(inputId)?.value;
if (!val) return; if (!val) return;
Nova.confirm(`Switch ${label} to "${val}"? This will stop the current service and start the new one.`, async () => { Nova.confirm(`Switch ${label} to "${val}"? This will stop the current service and start the new one.`, () => {
Nova.loading(`Switching ${label} to ${val}`); const termId = 'so-term-' + Date.now();
const r = await Nova.api('system', 'save-option', { method:'POST', body:{ key, value: val } }); Nova.modal(`Switching ${label} to ${val}`, `
Nova.loadingDone(); <div id="${termId}" style="background:#1a1a2e;color:#e0e0e0;font-family:monospace;font-size:.82rem;
Nova.toast(r?.success ? `${label} switched to ${val}` : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); padding:1rem;border-radius:6px;height:260px;overflow-y:auto;white-space:pre-wrap;line-height:1.5">
if (r?.success) adminPage('server-options'); <span style="color:#7ec8e3">Starting</span>\n
</div>`,
`<button class="btn btn-ghost" id="so-term-close" onclick="this.closest('.modal-overlay').remove()">Close</button>`);
const term = document.getElementById(termId);
const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; };
fetch('/api/system/service-switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value: val }),
credentials: 'same-origin',
}).then(resp => {
if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; }
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = '';
const read = () => reader.read().then(({ done, value }) => {
if (done) { append('\n[stream closed]'); return; }
buf += dec.decode(value, { stream: true });
const parts = buf.split('\n\n');
buf = parts.pop();
for (const part of parts) {
const m = part.match(/^data: (.+)$/m);
if (!m) continue;
try {
const obj = JSON.parse(m[1]);
if (obj.line) { append(obj.line); }
else if (obj.error) { append(`\n${obj.error}\n`); }
else if (obj.done) {
const btn = document.getElementById('so-term-close');
if (btn) {
btn.textContent = 'Done';
btn.className = 'btn btn-primary';
btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); adminPage('server-options'); };
}
}
} catch(e) {}
}
read();
}).catch(err => append(`\n[error: ${err.message}]`));
read();
}).catch(err => append(`\nFetch error: ${err.message}`));
}, true); }, true);
}; };