From 7aa33defa2830cf2ca93fc54af36029c205c56d2 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Tue, 9 Jun 2026 16:18:07 +0000 Subject: [PATCH] Fix SQLite backtick translation, add service-switch SSE streaming, Fail2Ban management page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- install.sh | 51 ++- panel/api/endpoints/firewall.php | 46 +++ panel/api/endpoints/system.php | 281 ++++++++++++-- panel/api/endpoints/wordpress.php | 33 ++ panel/lib/DB.php | 12 +- panel/lib/WordPressManager.php | 90 ++++- panel/public/admin/index.php | 6 +- panel/public/assets/js/admin.js | 592 ++++++++++++++++++++++++++++-- 8 files changed, 1019 insertions(+), 92 deletions(-) diff --git a/install.sh b/install.sh index 5f00979..b64c758 100644 --- a/install.sh +++ b/install.sh @@ -10,8 +10,7 @@ NOVACPX_VERSION="1.0.0" PANEL_DIR="/opt/novacpx" WEB_ROOT="/srv/novacpx/public" LOG="/var/log/novacpx-install.log" -DB_NAME="novacpx" -DB_USER="novacpx_user" +DB_PATH="/var/lib/novacpx/panel.db" PHP_DEFAULT="8.3" # ── Panel ports (each tier has its own port) ────────────────────────────────── @@ -105,7 +104,8 @@ log "RAM: ${TOTAL_RAM}MB | Free disk: ${TOTAL_DISK}GB" # ── Generate secrets ────────────────────────────────────────────────────────── 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) SECRET_KEY=$(openssl rand -hex 32) 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} Admin User: admin Admin Pass: $ADMIN_PASS -DB Name: $DB_NAME -DB User: $DB_USER -DB Pass: $DB_PASS +Panel DB: ${DB_PATH} (SQLite — no credentials needed) +DB WP User: $DB_WP_USER +DB WP Pass: $DB_WP_PASS ========================================== SAVE THIS FILE. It will not be shown again. CREDS @@ -134,7 +134,7 @@ apt-get update -qq >> "$LOG" 2>&1 apt-get upgrade -y -qq >> "$LOG" 2>&1 apt-get install -y -qq curl wget gnupg2 lsb-release ca-certificates \ 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" # ── PHP multi-version setup ─────────────────────────────────────────────────── @@ -305,6 +305,9 @@ fi # Enable PHP-FPM services for VER in "${PHP_VERSIONS[@]}"; do 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 # ── 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 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 + # 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 log "MySQL installed and database created" fi @@ -485,10 +492,9 @@ fi mkdir -p /etc/novacpx cat > /etc/novacpx/config.ini <> "$LOG" 2>&1 + sqlite3 "$DB_PATH" < /opt/novacpx-src/db/schema.sql >> "$LOG" 2>&1 # Create admin user 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 - 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 - log "Database schema imported and admin user created" + sqlite3 "$DB_PATH" "INSERT OR IGNORE INTO settings (key, value) VALUES ('proxy_mode','disabled'),('proxy_apache_port','80');" >> "$LOG" 2>&1 + log "SQLite panel database created and admin user seeded" fi +chown www-data:www-data "$DB_PATH" +chmod 660 "$DB_PATH" # Set permissions 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 reload apache2 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 chmod 440 /etc/sudoers.d/novacpx-firewall log "Sudoers rules installed" diff --git a/panel/api/endpoints/firewall.php b/panel/api/endpoints/firewall.php index 925cd3f..a6fb914 100644 --- a/panel/api/endpoints/firewall.php +++ b/panel/api/endpoints/firewall.php @@ -420,6 +420,52 @@ switch ($action) { Response::success(['ignoreip' => $defaults], 'Whitelist reset to server defaults'); 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) ───────────────────────────── case 'raw': $cmd = trim($body['cmd'] ?? ''); diff --git a/panel/api/endpoints/system.php b/panel/api/endpoints/system.php index 86eddf7..bfa4dd1 100644 --- a/panel/api/endpoints/system.php +++ b/panel/api/endpoints/system.php @@ -442,44 +442,130 @@ BASH; $value = $body['value'] ?? ''; $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"); + $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]); - // Sync config.ini so PHP constants reflect the change immediately on next request + // Sync 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); - if ($key === 'web_server') { - $ini = preg_replace('/^server\s*=\s*.*/m', "server = $value", $ini); - } + if ($key === 'web_server') $ini = preg_replace('/^server\s*=\s*.*/m', "server = $value", $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') { - $target = in_array($value, ['nginx']) ? 'nginx' : 'apache'; - $out = shell_exec("sudo /usr/local/bin/novacpx-webserver-switch " . escapeshellarg($target) . " 2>&1"); - novacpx_log('info', "web_server switched to $target: $out"); - } elseif ($key === 'ftp_server') { - foreach (['proftpd','vsftpd','pure-ftpd'] as $s) { shell_exec("sudo systemctl stop $s 2>/dev/null; sudo systemctl disable $s 2>/dev/null"); } - $startSvc = match($value) { 'vsftpd' => 'vsftpd', 'pureftpd' => 'pure-ftpd', default => 'proftpd' }; - if (trim(shell_exec("dpkg -l $startSvc 2>/dev/null | grep -c '^ii'") ?: '0') > 0) { - shell_exec("sudo systemctl enable $startSvc 2>/dev/null && sudo systemctl start $startSvc 2>/dev/null"); + $target = ($value === 'nginx') ? 'nginx' : 'apache'; + $sse("▶ Switching web server to {$target}…\n"); + if (file_exists('/usr/local/bin/novacpx-webserver-switch')) { + $run("sudo /usr/local/bin/novacpx-webserver-switch " . escapeshellarg($target) . " 2>&1"); + } else { + // Fallback: manage services directly + if ($target === 'nginx') { + $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 1–2 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') { - 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') { $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); - Response::success(null, "Setting saved: {$key} = {$value}"); + echo 'data: ' . json_encode(['done' => true]) . "\n\n"; flush(); + exit; })(), // ── Notification settings (#25) ─────────────────────────────────────────── @@ -594,15 +680,7 @@ BASH; 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"); - // Safety: never remove the engine that is currently hosting the NovaCPX panel DB - 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); - } - } + // Panel DB is SQLite — no MySQL engine hosts it, so any MySQL/MariaDB/PG can be removed freely $out = ''; if ($action === 'install') { @@ -611,7 +689,7 @@ BASH; 'mariadb' => 'mariadb-server', '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"); } elseif ($action === 'remove') { $pkg = match($engine) { @@ -620,7 +698,7 @@ BASH; 'postgresql' => 'postgresql postgresql-contrib', }; 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') { $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); @@ -632,5 +710,144 @@ BASH; 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 20–60 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 1–3 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), }; diff --git a/panel/api/endpoints/wordpress.php b/panel/api/endpoints/wordpress.php index 6bd3f32..ee1837f 100644 --- a/panel/api/endpoints/wordpress.php +++ b/panel/api/endpoints/wordpress.php @@ -27,6 +27,39 @@ match ($action) { 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) { $id = (int)($body['id'] ?? 0); if (!$id) Response::error('id required'); diff --git a/panel/lib/DB.php b/panel/lib/DB.php index 6070358..f2622e6 100644 --- a/panel/lib/DB.php +++ b/panel/lib/DB.php @@ -34,11 +34,12 @@ class DB { function (array $m): string { $pairs = preg_split('/,\s*/', trim($m[1])); $sets = array_map(function (string $pair): string { - if (preg_match('/(\w+)\s*=\s*VALUES\s*\(\s*(\w+)\s*\)/i', $pair, $pm)) { - return "{$pm[1]}=excluded.{$pm[2]}"; + // Match plain or backtick-quoted column names: `col`=VALUES(`col`) or col=VALUES(col) + 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 - return $pair; + // col=? or col=expr — strip backticks, keep as-is + return preg_replace('/`(\w+)`/', '"$1"', $pair); }, $pairs); return 'ON CONFLICT DO UPDATE SET ' . implode(', ', $sets); }, @@ -95,6 +96,9 @@ class DB { // IFNULL → COALESCE (SQLite supports both but be safe) $sql = preg_replace('/\bIFNULL\s*\(/i', 'COALESCE(', $sql); + // Backtick identifier quoting → double-quote (SQLite standard) + $sql = preg_replace('/`(\w+)`/', '"$1"', $sql); + return $sql; } diff --git a/panel/lib/WordPressManager.php b/panel/lib/WordPressManager.php index f0a71ca..e29beaa 100644 --- a/panel/lib/WordPressManager.php +++ b/panel/lib/WordPressManager.php @@ -1,13 +1,38 @@ db = DB::getInstance()->pdo(); + // Separate privileged connection for CREATE DATABASE / CREATE USER / GRANT + $this->provDb = $this->makeProvPdo(); $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 ─────────────────────────────────────────────────────────────── public function install(int $accountId, string $domain, string $path, string $adminUser, string $adminEmail, string $adminPass, @@ -19,9 +44,9 @@ class WordPressManager { $dbUser = substr($dbName, 0, 32); // Create DB - $this->db->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->db->exec("GRANT ALL ON `{$dbName}`.* TO '{$dbUser}'@'localhost'"); + $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'"); // Download WP + install $sysUser = $account['system_user'] ?? 'www-data'; @@ -86,9 +111,9 @@ class WordPressManager { // Clone DB $stagingDb = $install['db_name'] . '_staging'; $stagingDbPw = bin2hex(random_bytes(8)); - $this->db->exec("CREATE DATABASE IF NOT EXISTS `{$stagingDb}`"); - $this->db->exec("CREATE USER IF NOT EXISTS '{$stagingDb}'@'localhost' IDENTIFIED BY '{$stagingDbPw}'"); - $this->db->exec("GRANT ALL ON `{$stagingDb}`.* TO '{$stagingDb}'@'localhost'"); + $this->provDb->exec("CREATE DATABASE IF NOT EXISTS `{$stagingDb}`"); + $this->provDb->exec("CREATE USER IF NOT EXISTS '{$stagingDb}'@'localhost' IDENTIFIED BY '{$stagingDbPw}'"); + $this->provDb->exec("GRANT ALL ON `{$stagingDb}`.* TO '{$stagingDb}'@'localhost'"); $this->exec("mysqldump {$install['db_name']} | mysql {$stagingDb}"); // Update staging wp-config @@ -112,8 +137,8 @@ class WordPressManager { public function delete(int $id): bool { [$install, $sysUser, $docRoot] = $this->resolve($id); $this->exec("rm -rf {$docRoot}"); - $this->db->exec("DROP DATABASE IF EXISTS `{$install['db_name']}`"); - $this->db->exec("DROP USER IF EXISTS '{$install['db_user']}'@'localhost'"); + $this->provDb->exec("DROP DATABASE IF EXISTS `{$install['db_name']}`"); + $this->provDb->exec("DROP USER IF EXISTS '{$install['db_user']}'@'localhost'"); $this->db->prepare("DELETE FROM wordpress_installs WHERE id=?")->execute([$id]); return true; } @@ -164,6 +189,55 @@ class WordPressManager { 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 { 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')); diff --git a/panel/public/admin/index.php b/panel/public/admin/index.php index 40ae8bd..9c9a225 100644 --- a/panel/public/admin/index.php +++ b/panel/public/admin/index.php @@ -113,7 +113,11 @@ require_once dirname(__DIR__) . '/_branding.php'; - Firewall / Fail2Ban + Firewall (UFW) + + + + Fail2Ban diff --git a/panel/public/assets/js/admin.js b/panel/public/assets/js/admin.js index 946bfad..1e3deee 100644 --- a/panel/public/assets/js/admin.js +++ b/panel/public/assets/js/admin.js @@ -93,6 +93,7 @@ docker, 'ssl-manager': sslManager, firewall, + fail2ban, 'audit-log': auditLog, twofa, updates, @@ -1519,6 +1520,227 @@ `; } + // ── 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 ` + + + +
+
Global Settings
+
+
+
+ + + How long IPs stay banned +
+
+ + + Window to count failures +
+
+ + + Failures before ban +
+ +
+
+
+ + +
+
+ Active Jails + ${jails.length} jail${jails.length !== 1 ? 's' : ''} +
+ ${jails.length ? ` +
+ + + + ${jails.map(j => ` + + + + + + `).join('')} + +
JailCurrently BannedTotal BannedFailed
${Nova.escHtml(j.jail)}${j.currently_banned > 0 + ? `${j.currently_banned}` + : '0'}${j.total_banned}${j.currently_failed} + ${j.currently_banned > 0 + ? `` + : ''} + +
+
` : `

Fail2Ban not running or no jails active.

`} +
+ + +
+
+ Whitelist (Never Ban) + ${ignoreips.length} entr${ignoreips.length !== 1 ? 'ies' : 'y'} + +
+
+
+ + +
+
+ ${ignoreips.map(ip => `${Nova.escHtml(ip)} ×`).join('')} +
+

Your own IP/subnet should always be whitelisted.

+
+
+ + +
+
+ Log Viewer +
+ + +
+
+
+
+ Click "Load Log" to view Fail2Ban activity. +
+
+
`; + } + + 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}`, + `
+

Currently Banned

${d.currently_banned ?? 0}

+

Total Banned

${d.total_banned ?? 0}

+

Currently Failed

${d.currently_failed ?? 0}

+
+ ${ips.length ? ` + ${ips.map(ip => ` + + + `).join('')} +
IP Address
${Nova.escHtml(ip)}
` : '

No IPs currently banned.

'}` + ); + }; + + window.f2bBanModal = (jail) => { + Nova.modal(`Ban IP in jail: ${jail}`, + `
+ + +
`, + `` + ); + }; + + 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 = 'Loading…'; + 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, '$1') + .replace(/(WARNING)/g, '$1') + .replace(/\b(BAN)\b/g, '$1') + .replace(/\b(UNBAN)\b/g, '$1') + .replace(/(ERROR)/g, '$1'); + el.innerHTML = colored; + el.scrollTop = el.scrollHeight; + } else { + el.innerHTML = 'Failed to load log.'; + } + } + }; + function fwActionBadge(action) { const a = (action||'').toLowerCase(); if (a.includes('allow')) return Nova.badge('ALLOW','green'); @@ -1758,13 +1980,15 @@ ${ips.length ? ` // ── MySQL/DB Manager ─────────────────────────────────────────────────────── async function mysqlManager() { - const [engRes, dbRes] = await Promise.all([ + const [engRes, dbRes, toolsRes] = await Promise.all([ Nova.api('system','db-engines'), Nova.api('databases','list',{params:{account_id:0}}), + Nova.api('system','db-tools'), ]); - const eng = engRes?.data?.engines || {}; - const actE = engRes?.data?.active_engine || 'mysql'; - const dbs = dbRes?.data || []; + const eng = engRes?.data?.engines || {}; + const actE = engRes?.data?.active_engine || 'mysql'; + const dbs = dbRes?.data || []; + const tools = toolsRes?.data || {}; const engineCard = (id, label, icon) => { const e = eng[id] || {}; @@ -1778,7 +2002,7 @@ ${ips.length ? ` ${e.version ? `v${e.version}` : ''}
`; + }; + + const toolCard = (id, label, icon, url) => { + const t = tools[id] || {}; + const statusColor = t.installed ? 'green' : 'default'; + const statusText = t.installed ? 'Installed' : 'Not Installed'; + return ` +
+
+ ${icon} ${label} + ${Nova.badge(statusText, statusColor)} + ${t.version ? `v${t.version}` : ''} +
+
+
+ ${!t.installed + ? `` + : ` + + + Open ↗` + } +
`; }; @@ -1827,6 +2074,16 @@ ${dbs.map(d=>` +
+
Database Admin Tools
+
+
+ ${toolCard('phpmyadmin', 'phpMyAdmin', '🛢', `http://${location.hostname}/phpmyadmin`)} + ${toolCard('pgadmin', 'pgAdmin 4', '🐘', `http://${location.hostname}/pgadmin4`)} +
+
+
+
All Databases${dbs.length} total
${dbTable} @@ -1861,6 +2118,142 @@ ${dbs.map(d=>` 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`, ` +

pgAdmin requires an admin account to be created on first run.

+
+
`, + ` + `); + 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}`, ` +
+ Starting…\n +
`, + ``); + + 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}`, ` +
+ Starting…\n +
`, + ``); + 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 ──────────────────────────────────────────────────────────── async function mailServer() { const r = await Nova.api('system','stats'); @@ -2128,32 +2521,129 @@ window.wpInstallModal = () => { Nova.modal('Install WordPress', `
-
+
+ +
+ + +
+

Install at site root (/) unless you want WordPress at a subdirectory.

+
-

wp-cli will be downloaded automatically if not installed. This may take 1-2 minutes.

`, +

wp-cli will be downloaded automatically if not installed. Installation takes 1-2 minutes — a live terminal will show progress.

`, ` `); }; -window.wpSubmitInstall = async () => { - const btn = document.getElementById('wp-install-btn'); - if (btn) { btn.disabled = true; btn.textContent = 'Installing…'; } - Nova.toast('Installing WordPress — this may take 1-2 minutes…', 'info', 90000); - const res = await Nova.api('wordpress','install',{method:'POST',body:{ - account_id: +document.getElementById('wp-acct')?.value, - domain: document.getElementById('wp-domain')?.value, - path: document.getElementById('wp-path')?.value || '/', - site_title: document.getElementById('wp-title')?.value, - admin_user: document.getElementById('wp-admin')?.value, - admin_pass: document.getElementById('wp-adminpass')?.value, - admin_email:document.getElementById('wp-email')?.value, - }}); +window.wpPathPreset = (sel) => { + const pathInput = document.getElementById('wp-path'); + if (sel.value === '__custom') { + pathInput.style.display = ''; + pathInput.value = '/'; + pathInput.focus(); + } else { + pathInput.style.display = 'none'; + pathInput.value = sel.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(); - 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', ` +
+ Connecting to server…\n +
`, + ``); + + 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) => { @@ -3628,15 +4118,55 @@ async function serverOptions() {
`; } -window.soSave = async (key, inputId, label) => { +window.soSave = (key, inputId, label) => { const val = document.getElementById(inputId)?.value; if (!val) return; - Nova.confirm(`Switch ${label} to "${val}"? This will stop the current service and start the new one.`, async () => { - Nova.loading(`Switching ${label} to ${val}…`); - const r = await Nova.api('system', 'save-option', { method:'POST', body:{ key, value: val } }); - Nova.loadingDone(); - Nova.toast(r?.success ? `${label} switched to ${val}` : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); - if (r?.success) adminPage('server-options'); + Nova.confirm(`Switch ${label} to "${val}"? This will stop the current service and start the new one.`, () => { + const termId = 'so-term-' + Date.now(); + Nova.modal(`Switching ${label} to ${val}`, ` +
+ Starting…\n +
`, + ``); + 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); };