From 135bbcb0b3ce49637429d67e85057e411f99fa5b Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Sun, 7 Jun 2026 21:13:59 +0000 Subject: [PATCH 1/5] Features #14-17: WordPress Manager, Backup, Cloudflare, TOTP 2FA - WordPressManager.php: wp-cli wrapper for install/update/clone/delete - BackupManager.php: tar+mysqldump, schedules, retention, rclone - CloudflareManager.php: zone/record management, sync, cache purge - TOTP.php: RFC 6238 pure-PHP with backup codes - Auth.php: TOTP_REQUIRED two-step login flow - 4 new API endpoints: wordpress, backup, cloudflare, totp - DB migration 002: TOTP cols, CF cols, wordpress_installs, backups tables - admin.js: full UI for all 4 features + TOTP login step - admin/index.php: sidebar nav for WordPress, 2FA Manager, Cloudflare Co-Authored-By: Claude Sonnet 4.6 --- db/migrations/002_features_14_17.sql | 29 ++ panel/api/endpoints/auth.php | 10 +- panel/api/endpoints/backup.php | 82 ++++ panel/api/endpoints/cloudflare.php | 99 +++++ panel/api/endpoints/totp.php | 90 ++++ panel/api/endpoints/wordpress.php | 72 ++++ panel/lib/Auth.php | 31 +- panel/lib/BackupManager.php | 165 ++++++++ panel/lib/CloudflareManager.php | 150 +++++++ panel/lib/TOTP.php | 78 ++++ panel/lib/WordPressManager.php | 174 ++++++++ panel/public/admin/index.php | 12 + panel/public/assets/js/admin.js | 597 +++++++++++++++++++++++++-- 13 files changed, 1542 insertions(+), 47 deletions(-) create mode 100644 db/migrations/002_features_14_17.sql create mode 100644 panel/api/endpoints/backup.php create mode 100644 panel/api/endpoints/cloudflare.php create mode 100644 panel/api/endpoints/totp.php create mode 100644 panel/api/endpoints/wordpress.php create mode 100644 panel/lib/BackupManager.php create mode 100644 panel/lib/CloudflareManager.php create mode 100644 panel/lib/TOTP.php create mode 100644 panel/lib/WordPressManager.php diff --git a/db/migrations/002_features_14_17.sql b/db/migrations/002_features_14_17.sql new file mode 100644 index 0000000..d01c183 --- /dev/null +++ b/db/migrations/002_features_14_17.sql @@ -0,0 +1,29 @@ +-- Migration 002: Features #14-17 (WordPress, Backup, Cloudflare, TOTP) +ALTER TABLE users ADD COLUMN totp_secret VARCHAR(64) DEFAULT NULL; +ALTER TABLE users ADD COLUMN totp_enabled TINYINT(1) DEFAULT 0; +ALTER TABLE users ADD COLUMN totp_backup_codes TEXT DEFAULT NULL; +ALTER TABLE accounts ADD COLUMN cf_api_key VARCHAR(255) DEFAULT NULL; +ALTER TABLE accounts ADD COLUMN cf_api_email VARCHAR(255) DEFAULT NULL; +ALTER TABLE accounts ADD COLUMN cf_zone_id VARCHAR(64) DEFAULT NULL; +ALTER TABLE dns_zones ADD COLUMN cf_zone_id VARCHAR(64) DEFAULT NULL; +CREATE TABLE IF NOT EXISTS wordpress_installs ( + id INT AUTO_INCREMENT PRIMARY KEY,account_id INT NOT NULL,domain VARCHAR(255) NOT NULL, + path VARCHAR(255) DEFAULT '/',db_name VARCHAR(64) DEFAULT NULL,db_user VARCHAR(64) DEFAULT NULL, + db_pass VARCHAR(128) DEFAULT NULL,wp_version VARCHAR(20) DEFAULT NULL,admin_user VARCHAR(64) DEFAULT NULL, + admin_email VARCHAR(255) DEFAULT NULL,status ENUM('active','updating','suspended') DEFAULT 'active', + staging_of INT DEFAULT NULL,created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,INDEX (account_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS backups ( + id INT AUTO_INCREMENT PRIMARY KEY,account_id INT NOT NULL,filename VARCHAR(255) NOT NULL, + size BIGINT DEFAULT 0,type ENUM('full','files','database') DEFAULT 'full', + status ENUM('pending','running','complete','failed') DEFAULT 'pending', + storage VARCHAR(50) DEFAULT 'local',remote_path VARCHAR(500) DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,INDEX (account_id),INDEX (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS backup_schedules ( + id INT AUTO_INCREMENT PRIMARY KEY,account_id INT NOT NULL UNIQUE, + frequency ENUM('hourly','daily','weekly','monthly') DEFAULT 'daily', + type ENUM('full','files','database') DEFAULT 'full',retain_count INT DEFAULT 7, + last_run TIMESTAMP NULL,created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,INDEX (account_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/panel/api/endpoints/auth.php b/panel/api/endpoints/auth.php index 981dd5d..9fa9414 100644 --- a/panel/api/endpoints/auth.php +++ b/panel/api/endpoints/auth.php @@ -4,11 +4,15 @@ $body = json_decode(file_get_contents('php://input'), true) ?? []; match ($action) { 'login' => (function() use ($body) { - $username = trim($body['username'] ?? ''); - $password = $body['password'] ?? ''; + $username = trim($body['username'] ?? ''); + $password = $body['password'] ?? ''; + $totpCode = isset($body['totp_code']) ? trim($body['totp_code']) : null; if (!$username || !$password) Response::error('Username and password required'); $auth = Auth::getInstance(); - $token = $auth->attempt($username, $password); + $token = $auth->attempt($username, $password, $totpCode); + if ($token === Auth::TOTP_REQUIRED) { + Response::json(['success' => false, 'totp_required' => true, 'message' => 'Enter your 2FA code'], 200); + } if (!$token) { // Log failure for Fail2Ban to detect $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; diff --git a/panel/api/endpoints/backup.php b/panel/api/endpoints/backup.php new file mode 100644 index 0000000..a3df798 --- /dev/null +++ b/panel/api/endpoints/backup.php @@ -0,0 +1,82 @@ + (function() use ($bm, $accountId, $isAdmin) { + $list = $bm->list($isAdmin ? $accountId : $accountId); + Response::success(['backups' => $list, 'disk_used' => $bm->diskUsage($accountId)]); + })(), + + 'create' => (function() use ($bm, $body, $accountId) { + if (!$accountId) Response::error('account_id required'); + $type = in_array($body['type'] ?? 'full', ['full','files','database']) ? $body['type'] : 'full'; + $result = $bm->create($accountId, $type); + audit('backup_create', 'backup', ['account_id' => $accountId, 'type' => $type]); + Response::success($result, 'Backup created successfully'); + })(), + + 'restore' => (function() use ($bm, $body, $isAdmin) { + if (!$isAdmin) Response::error('Admin only', 403); + $id = (int)($body['id'] ?? 0); + if (!$id) Response::error('id required'); + $bm->restore($id); + audit('backup_restore', 'backup', ['id' => $id]); + Response::success(null, 'Backup restored successfully'); + })(), + + 'download' => (function() use ($bm, $body) { + $id = (int)($body['id'] ?? $_GET['id'] ?? 0); + if (!$id) Response::error('id required'); + $path = $bm->getDownloadPath($id); + $name = basename($path); + header('Content-Type: application/gzip'); + header("Content-Disposition: attachment; filename=\"{$name}\""); + header('Content-Length: ' . filesize($path)); + ob_end_clean(); + readfile($path); + exit; + })(), + + 'delete' => (function() use ($bm, $body, $isAdmin) { + if (!$isAdmin) Response::error('Admin only', 403); + $id = (int)($body['id'] ?? 0); + if (!$id) Response::error('id required'); + $bm->delete($id); + audit('backup_delete', 'backup', ['id' => $id]); + Response::success(null, 'Backup deleted'); + })(), + + 'schedule' => (function() use ($bm, $body, $accountId, $isAdmin) { + if (!$isAdmin) Response::error('Admin only', 403); + if (!$accountId) Response::error('account_id required'); + $freq = $body['frequency'] ?? 'daily'; + $type = $body['type'] ?? 'full'; + $retain = (int)($body['retain'] ?? 7); + $bm->setSchedule($accountId, $freq, $type, $retain); + Response::success(null, 'Backup schedule saved'); + })(), + + 'get-schedule' => (function() use ($bm, $accountId) { + if (!$accountId) Response::error('account_id required'); + Response::success($bm->getSchedule($accountId)); + })(), + + 'upload-remote' => (function() use ($bm, $body, $isAdmin) { + if (!$isAdmin) Response::error('Admin only', 403); + $id = (int)($body['id'] ?? 0); + $remote = trim($body['remote'] ?? ''); + if (!$id || !$remote) Response::error('id and remote required'); + $out = $bm->uploadRemote($id, $remote); + Response::success(['output' => $out], 'Upload complete'); + })(), + + default => Response::error('Unknown action', 404), +}; diff --git a/panel/api/endpoints/cloudflare.php b/panel/api/endpoints/cloudflare.php new file mode 100644 index 0000000..eb99783 --- /dev/null +++ b/panel/api/endpoints/cloudflare.php @@ -0,0 +1,99 @@ +getCredentials($accountId); + $apiKey = $creds['cf_api_key'] ?? null; + $email = $creds['cf_api_email'] ?? null; +} + +match ($action) { + 'test-key' => (function() use ($cf, $body) { + $key = trim($body['api_key'] ?? ''); + $email = trim($body['email'] ?? ''); + if (!$key || !$email) Response::error('api_key and email required'); + $ok = $cf->testCredentials($key, $email); + Response::success(['valid' => $ok], $ok ? 'API key is valid' : 'Invalid API key'); + })(), + + 'save-credentials' => (function() use ($cf, $body, $accountId) { + if (!$accountId) Response::error('account_id required'); + $key = trim($body['api_key'] ?? ''); + $email = trim($body['email'] ?? ''); + if (!$key || !$email) Response::error('api_key and email required'); + $cf->saveCredentials($accountId, $key, $email); + audit('cf_credentials_saved', 'cloudflare', ['account_id' => $accountId]); + Response::success(null, 'Cloudflare credentials saved'); + })(), + + 'get-credentials' => (function() use ($cf, $accountId) { + if (!$accountId) Response::error('account_id required'); + $creds = $cf->getCredentials($accountId); + // Mask the key in the response + if ($creds) $creds['cf_api_key'] = substr($creds['cf_api_key'], 0, 6) . str_repeat('*', 30); + Response::success($creds); + })(), + + 'list-zones' => (function() use ($cf, $apiKey, $email) { + if (!$apiKey || !$email) Response::error('No Cloudflare credentials configured'); + Response::success($cf->listZones($apiKey, $email)); + })(), + + 'list-records' => (function() use ($cf, $body, $apiKey, $email) { + $zoneId = trim($body['zone_id'] ?? ''); + if (!$zoneId) Response::error('zone_id required'); + if (!$apiKey || !$email) Response::error('No Cloudflare credentials configured'); + Response::success($cf->listRecords($zoneId, $apiKey, $email)); + })(), + + 'toggle-proxy' => (function() use ($cf, $body, $apiKey, $email) { + $zoneId = trim($body['zone_id'] ?? ''); + $recordId = trim($body['record_id'] ?? ''); + $proxied = (bool)($body['proxied'] ?? false); + if (!$zoneId || !$recordId) Response::error('zone_id and record_id required'); + if (!$apiKey || !$email) Response::error('No credentials'); + $result = $cf->toggleProxy($zoneId, $recordId, $proxied, $apiKey, $email); + Response::success($result, 'Proxy status updated'); + })(), + + 'sync-to-cf' => (function() use ($cf, $body, $apiKey, $email) { + $domain = trim($body['domain'] ?? ''); + $zoneId = trim($body['zone_id'] ?? ''); + if (!$domain || !$zoneId) Response::error('domain and zone_id required'); + if (!$apiKey || !$email) Response::error('No credentials'); + $result = $cf->syncToCloudflare($domain, $zoneId, $apiKey, $email); + audit('cf_sync_to', 'cloudflare', ['domain' => $domain]); + Response::success($result, "Pushed to Cloudflare: {$result['created']} created, {$result['updated']} updated"); + })(), + + 'sync-from-cf' => (function() use ($cf, $body, $apiKey, $email) { + $domain = trim($body['domain'] ?? ''); + $zoneId = trim($body['zone_id'] ?? ''); + if (!$domain || !$zoneId) Response::error('domain and zone_id required'); + if (!$apiKey || !$email) Response::error('No credentials'); + $count = $cf->syncFromCloudflare($domain, $zoneId, $apiKey, $email); + audit('cf_sync_from', 'cloudflare', ['domain' => $domain, 'records' => $count]); + Response::success(['count' => $count], "Pulled {$count} records from Cloudflare"); + })(), + + 'purge-cache' => (function() use ($cf, $body, $apiKey, $email) { + $zoneId = trim($body['zone_id'] ?? ''); + if (!$zoneId) Response::error('zone_id required'); + if (!$apiKey || !$email) Response::error('No credentials'); + $ok = $cf->purgeCache($zoneId, $apiKey, $email); + Response::success(['success' => $ok], $ok ? 'Cache purged' : 'Purge failed'); + })(), + + default => Response::error('Unknown action', 404), +}; diff --git a/panel/api/endpoints/totp.php b/panel/api/endpoints/totp.php new file mode 100644 index 0000000..4704d39 --- /dev/null +++ b/panel/api/endpoints/totp.php @@ -0,0 +1,90 @@ +getPDO(); + +match ($action) { + // Begin setup: generate secret + return QR URL (not yet enabled) + 'setup' => (function() use ($db, $uid, $currentUser) { + $secret = TOTP::generateSecret(); + // Store pending secret (not enabled yet until verified) + $db->prepare("UPDATE users SET totp_secret=? WHERE id=?")->execute([$secret, $uid]); + Response::success([ + 'secret' => $secret, + 'qr_url' => TOTP::qrUrl($secret, $currentUser['username']), + ], 'Scan QR code in your authenticator app, then confirm with a code'); + })(), + + // Confirm setup: verify first code, then enable TOTP and return backup codes + 'enable' => (function() use ($db, $uid, $body) { + $code = trim($body['code'] ?? ''); + if (strlen($code) !== 6) Response::error('Enter the 6-digit code from your authenticator'); + $stmt = $db->prepare("SELECT totp_secret FROM users WHERE id=?"); + $stmt->execute([$uid]); + $secret = $stmt->fetchColumn(); + if (!$secret) Response::error('Run setup first'); + if (!TOTP::verify($secret, $code)) Response::error('Code incorrect — try again'); + $backupCodes = TOTP::generateBackupCodes(); + $hashedCodes = TOTP::hashBackupCodes($backupCodes); + $db->prepare("UPDATE users SET totp_enabled=1, totp_backup_codes=? WHERE id=?")->execute([$hashedCodes, $uid]); + audit('totp_enabled', 'security'); + Response::success(['backup_codes' => $backupCodes], '2FA enabled. Save your backup codes — they will not be shown again.'); + })(), + + // Disable TOTP (requires current password confirmation) + 'disable' => (function() use ($db, $uid, $body) { + $pass = $body['password'] ?? ''; + $stmt = $db->prepare("SELECT password FROM users WHERE id=?"); + $stmt->execute([$uid]); + $hash = $stmt->fetchColumn(); + if (!password_verify($pass, $hash)) Response::error('Password incorrect'); + $db->prepare("UPDATE users SET totp_enabled=0, totp_secret=NULL, totp_backup_codes=NULL WHERE id=?")->execute([$uid]); + audit('totp_disabled', 'security'); + Response::success(null, '2FA disabled'); + })(), + + // Get status (is 2FA on?) + 'status' => (function() use ($db, $uid) { + $stmt = $db->prepare("SELECT totp_enabled FROM users WHERE id=?"); + $stmt->execute([$uid]); + $enabled = (bool)$stmt->fetchColumn(); + Response::success(['enabled' => $enabled]); + })(), + + // Regenerate backup codes + 'regen-backup-codes' => (function() use ($db, $uid, $body) { + $code = trim($body['code'] ?? ''); + $stmt = $db->prepare("SELECT totp_secret, totp_enabled FROM users WHERE id=?"); + $stmt->execute([$uid]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row['totp_enabled']) Response::error('2FA not enabled'); + if (!TOTP::verify($row['totp_secret'], $code)) Response::error('Code incorrect'); + $backupCodes = TOTP::generateBackupCodes(); + $db->prepare("UPDATE users SET totp_backup_codes=? WHERE id=?")->execute([TOTP::hashBackupCodes($backupCodes), $uid]); + Response::success(['backup_codes' => $backupCodes], 'Backup codes regenerated'); + })(), + + // Admin: get 2FA status for any user + 'admin-status' => (function() use ($db, $body, $currentUser) { + if ($currentUser['role'] !== 'admin') Response::error('Admin only', 403); + $userId = (int)($body['user_id'] ?? 0); + if (!$userId) Response::error('user_id required'); + $stmt = $db->prepare("SELECT id, username, totp_enabled FROM users WHERE id=?"); + $stmt->execute([$userId]); + Response::success($stmt->fetch(PDO::FETCH_ASSOC)); + })(), + + // Admin: force-disable 2FA for a user (account recovery) + 'admin-disable' => (function() use ($db, $body, $currentUser) { + if ($currentUser['role'] !== 'admin') Response::error('Admin only', 403); + $userId = (int)($body['user_id'] ?? 0); + if (!$userId) Response::error('user_id required'); + $db->prepare("UPDATE users SET totp_enabled=0, totp_secret=NULL, totp_backup_codes=NULL WHERE id=?")->execute([$userId]); + audit('totp_admin_disabled', 'security', ['user_id' => $userId]); + Response::success(null, '2FA disabled for user'); + })(), + + default => Response::error('Unknown action', 404), +}; diff --git a/panel/api/endpoints/wordpress.php b/panel/api/endpoints/wordpress.php new file mode 100644 index 0000000..6bd3f32 --- /dev/null +++ b/panel/api/endpoints/wordpress.php @@ -0,0 +1,72 @@ + (function() use ($wp, $accountId, $isAdmin) { + Response::success($wp->list($isAdmin ? $accountId : $accountId)); + })(), + + 'install' => (function() use ($wp, $body, $accountId, $currentUser) { + $required = ['domain','path','admin_user','admin_email','admin_pass','site_title']; + foreach ($required as $f) if (empty($body[$f])) Response::error("Missing: {$f}"); + if (!$accountId) Response::error('account_id required'); + $result = $wp->install($accountId, $body['domain'], $body['path'], + $body['admin_user'], $body['admin_email'], $body['admin_pass'], $body['site_title']); + audit('wordpress_install', 'wordpress', ['domain' => $body['domain']]); + Response::success($result, 'WordPress installed successfully'); + })(), + + 'update-core' => (function() use ($wp, $body) { + $id = (int)($body['id'] ?? 0); + if (!$id) Response::error('id required'); + Response::success(['output' => $wp->updateCore($id)], 'Core updated'); + })(), + + 'update-plugins' => (function() use ($wp, $body) { + $id = (int)($body['id'] ?? 0); + if (!$id) Response::error('id required'); + Response::success(['output' => $wp->updatePlugins($id)], 'Plugins updated'); + })(), + + 'update-themes' => (function() use ($wp, $body) { + $id = (int)($body['id'] ?? 0); + if (!$id) Response::error('id required'); + Response::success(['output' => $wp->updateThemes($id)], 'Themes updated'); + })(), + + 'clone-staging' => (function() use ($wp, $body) { + $id = (int)($body['id'] ?? 0); + if (!$id) Response::error('id required'); + $result = $wp->cloneStaging($id); + audit('wordpress_staging', 'wordpress', ['id' => $id]); + Response::success($result, 'Staging clone created'); + })(), + + 'info' => (function() use ($wp, $body) { + $id = (int)($body['id'] ?? $_GET['id'] ?? 0); + if (!$id) Response::error('id required'); + Response::success($wp->info($id)); + })(), + + 'delete' => (function() use ($wp, $body, $isAdmin) { + if (!$isAdmin) Response::error('Admin only', 403); + $id = (int)($body['id'] ?? 0); + if (!$id) Response::error('id required'); + $wp->delete($id); + audit('wordpress_delete', 'wordpress', ['id' => $id]); + Response::success(null, 'WordPress installation deleted'); + })(), + + default => Response::error('Unknown action', 404), +}; diff --git a/panel/lib/Auth.php b/panel/lib/Auth.php index 76614eb..5c4769a 100644 --- a/panel/lib/Auth.php +++ b/panel/lib/Auth.php @@ -1,8 +1,13 @@ fetchOne( "SELECT * FROM users WHERE (username = ? OR email = ?) AND status = 'active'", [$username, $username] ); if (!$user || !password_verify($password, $user['password'])) return null; + // TOTP check + if (!empty($user['totp_enabled'])) { + if ($totpCode === null) { + $this->user = $user; + return self::TOTP_REQUIRED; + } + $verified = TOTP::verify($user['totp_secret'] ?? '', $totpCode); + if (!$verified && !empty($user['totp_backup_codes'])) { + $verified = TOTP::verifyBackupCode($totpCode, $user['totp_backup_codes']); + if ($verified) { + // Consume used backup code + $hashes = json_decode($user['totp_backup_codes'], true) ?? []; + $hashes = array_values(array_filter($hashes, fn($h) => !password_verify(strtoupper($totpCode), $h))); + $db->execute("UPDATE users SET totp_backup_codes=? WHERE id=?", [json_encode($hashes), $user['id']]); + } + } + if (!$verified) return null; + } + // Create session $token = bin2hex(random_bytes(32)); $sessionId = hash('sha256', $token); diff --git a/panel/lib/BackupManager.php b/panel/lib/BackupManager.php new file mode 100644 index 0000000..22f31de --- /dev/null +++ b/panel/lib/BackupManager.php @@ -0,0 +1,165 @@ +db = Database::getInstance()->getPDO(); + if (!is_dir($this->backupRoot)) mkdir($this->backupRoot, 0750, true); + } + + // ── Create full backup ──────────────────────────────────────────────────── + public function create(int $accountId, string $type = 'full'): array { + $account = $this->getAccount($accountId); + $dir = $this->backupRoot . '/' . $account['username']; + if (!is_dir($dir)) mkdir($dir, 0750, true); + + $ts = date('Ymd_His'); + $filename = "{$account['username']}_{$type}_{$ts}.tar.gz"; + $filepath = "{$dir}/{$filename}"; + + // Record as pending + $stmt = $this->db->prepare("INSERT INTO backups (account_id, filename, type, status, storage) VALUES (?,?,?,'running','local')"); + $stmt->execute([$accountId, $filename, $type]); + $backupId = $this->db->lastInsertId(); + + try { + if ($type === 'full' || $type === 'files') { + $docRoot = escapeshellarg($account['document_root']); + exec("tar -czf " . escapeshellarg($filepath) . " -C / " . ltrim($docRoot, '/') . " 2>&1", $out, $rc); + if ($rc !== 0) throw new RuntimeException("tar failed: " . implode("\n", $out)); + } + + if ($type === 'full' || $type === 'database') { + // Dump all databases belonging to this account + $dbs = $this->db->prepare("SELECT db_name FROM account_databases WHERE account_id=?"); + $dbs->execute([$accountId]); + foreach ($dbs->fetchAll(PDO::FETCH_COLUMN) as $dbName) { + $dumpFile = escapeshellarg("{$dir}/{$account['username']}_{$dbName}_{$ts}.sql.gz"); + exec("mysqldump " . escapeshellarg($dbName) . " | gzip > {$dumpFile} 2>&1"); + } + + if ($type === 'database') { + // Pack all sql.gz files into the tar + exec("tar -czf " . escapeshellarg($filepath) . " -C {$dir} " . + escapeshellarg("{$account['username']}_{$ts}") . "*.sql.gz 2>/dev/null"); + } + } + + $size = file_exists($filepath) ? filesize($filepath) : 0; + $this->db->prepare("UPDATE backups SET status='complete', size=? WHERE id=?")->execute([$size, $backupId]); + return ['id' => $backupId, 'filename' => $filename, 'size' => $size]; + + } catch (RuntimeException $e) { + $this->db->prepare("UPDATE backups SET status='failed' WHERE id=?")->execute([$backupId]); + throw $e; + } + } + + // ── List ────────────────────────────────────────────────────────────────── + public function list(int $accountId = 0): array { + if ($accountId) { + $stmt = $this->db->prepare("SELECT b.*, a.username FROM backups b JOIN accounts a ON b.account_id=a.id WHERE b.account_id=? ORDER BY b.created_at DESC"); + $stmt->execute([$accountId]); + } else { + $stmt = $this->db->query("SELECT b.*, a.username FROM backups b JOIN accounts a ON b.account_id=a.id ORDER BY b.created_at DESC"); + } + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + // ── Download ────────────────────────────────────────────────────────────── + public function getDownloadPath(int $backupId): string { + $stmt = $this->db->prepare("SELECT b.*, a.username FROM backups b JOIN accounts a ON b.account_id=a.id WHERE b.id=?"); + $stmt->execute([$backupId]); + $backup = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$backup) throw new RuntimeException("Backup not found"); + $path = $this->backupRoot . '/' . $backup['username'] . '/' . $backup['filename']; + if (!file_exists($path)) throw new RuntimeException("Backup file missing from disk"); + return $path; + } + + // ── Restore ─────────────────────────────────────────────────────────────── + public function restore(int $backupId): bool { + $stmt = $this->db->prepare("SELECT b.*, a.document_root, a.username FROM backups b JOIN accounts a ON b.account_id=a.id WHERE b.id=?"); + $stmt->execute([$backupId]); + $backup = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$backup) throw new RuntimeException("Backup not found"); + + $path = $this->backupRoot . '/' . $backup['username'] . '/' . $backup['filename']; + if (!file_exists($path)) throw new RuntimeException("Backup file not found on disk"); + + exec("tar -xzf " . escapeshellarg($path) . " -C / 2>&1", $out, $rc); + if ($rc !== 0) throw new RuntimeException("Restore failed: " . implode("\n", $out)); + return true; + } + + // ── Delete ──────────────────────────────────────────────────────────────── + public function delete(int $backupId): bool { + $stmt = $this->db->prepare("SELECT b.*, a.username FROM backups b JOIN accounts a ON b.account_id=a.id WHERE b.id=?"); + $stmt->execute([$backupId]); + $backup = $stmt->fetch(PDO::FETCH_ASSOC); + if ($backup) { + $path = $this->backupRoot . '/' . $backup['username'] . '/' . $backup['filename']; + if (file_exists($path)) unlink($path); + $this->db->prepare("DELETE FROM backups WHERE id=?")->execute([$backupId]); + } + return true; + } + + // ── Schedule ────────────────────────────────────────────────────────────── + public function setSchedule(int $accountId, string $frequency, string $type = 'full', int $retain = 7): bool { + $stmt = $this->db->prepare("INSERT INTO backup_schedules (account_id, frequency, type, retain_count) + VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE frequency=VALUES(frequency), type=VALUES(type), retain_count=VALUES(retain_count)"); + $stmt->execute([$accountId, $frequency, $type, $retain]); + return true; + } + + public function getSchedule(int $accountId): ?array { + $stmt = $this->db->prepare("SELECT * FROM backup_schedules WHERE account_id=?"); + $stmt->execute([$accountId]); + return $stmt->fetch(PDO::FETCH_ASSOC) ?: null; + } + + // ── Prune old backups per retention policy ──────────────────────────────── + public function prune(int $accountId): int { + $schedule = $this->getSchedule($accountId); + if (!$schedule) return 0; + $retain = (int)$schedule['retain_count']; + $stmt = $this->db->prepare("SELECT * FROM backups WHERE account_id=? AND status='complete' ORDER BY created_at DESC"); + $stmt->execute([$accountId]); + $all = $stmt->fetchAll(PDO::FETCH_ASSOC); + $pruned = 0; + foreach (array_slice($all, $retain) as $old) { + $this->delete($old['id']); + $pruned++; + } + return $pruned; + } + + // ── rclone remote upload ────────────────────────────────────────────────── + public function uploadRemote(int $backupId, string $remote): string { + $path = $this->getDownloadPath($backupId); + $out = []; exec("rclone copy " . escapeshellarg($path) . " " . escapeshellarg($remote) . " 2>&1", $out, $rc); + if ($rc === 0) { + $this->db->prepare("UPDATE backups SET storage='remote', remote_path=? WHERE id=?")->execute([$remote, $backupId]); + } + return implode("\n", $out); + } + + // ── Disk usage ──────────────────────────────────────────────────────────── + public function diskUsage(int $accountId = 0): int { + if ($accountId) { + $stmt = $this->db->prepare("SELECT COALESCE(SUM(size),0) FROM backups WHERE account_id=? AND status='complete'"); + $stmt->execute([$accountId]); + } else { + $stmt = $this->db->query("SELECT COALESCE(SUM(size),0) FROM backups WHERE status='complete'"); + } + return (int)$stmt->fetchColumn(); + } + + private function getAccount(int $id): array { + $stmt = $this->db->prepare("SELECT * FROM accounts WHERE id=?"); + $stmt->execute([$id]); + return $stmt->fetch(PDO::FETCH_ASSOC) ?: throw new RuntimeException("Account not found"); + } +} diff --git a/panel/lib/CloudflareManager.php b/panel/lib/CloudflareManager.php new file mode 100644 index 0000000..5e91152 --- /dev/null +++ b/panel/lib/CloudflareManager.php @@ -0,0 +1,150 @@ +db = Database::getInstance()->getPDO(); + } + + // ── Credential management ───────────────────────────────────────────────── + public function saveCredentials(int $accountId, string $apiKey, string $email): bool { + $stmt = $this->db->prepare("UPDATE accounts SET cf_api_key=?, cf_api_email=? WHERE id=?"); + return $stmt->execute([$apiKey, $email, $accountId]); + } + + public function testCredentials(string $apiKey, string $email): bool { + $r = $this->req('GET', 'user/tokens/verify', [], $apiKey, $email); + return $r['success'] ?? false; + } + + public function getCredentials(int $accountId): ?array { + $stmt = $this->db->prepare("SELECT cf_api_key, cf_api_email, cf_zone_id FROM accounts WHERE id=?"); + $stmt->execute([$accountId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return ($row && $row['cf_api_key']) ? $row : null; + } + + // ── Zones ───────────────────────────────────────────────────────────────── + public function listZones(string $apiKey, string $email): array { + $r = $this->req('GET', 'zones?per_page=200&status=active', [], $apiKey, $email); + return $r['result'] ?? []; + } + + public function getZoneId(string $domain, string $apiKey, string $email): ?string { + $r = $this->req('GET', 'zones?name=' . urlencode($domain), [], $apiKey, $email); + return $r['result'][0]['id'] ?? null; + } + + // ── DNS records ─────────────────────────────────────────────────────────── + public function listRecords(string $zoneId, string $apiKey, string $email): array { + $r = $this->req('GET', "zones/{$zoneId}/dns_records?per_page=200", [], $apiKey, $email); + return $r['result'] ?? []; + } + + public function createRecord(string $zoneId, array $record, string $apiKey, string $email): array { + return $this->req('POST', "zones/{$zoneId}/dns_records", $record, $apiKey, $email); + } + + public function updateRecord(string $zoneId, string $recordId, array $record, string $apiKey, string $email): array { + return $this->req('PUT', "zones/{$zoneId}/dns_records/{$recordId}", $record, $apiKey, $email); + } + + public function deleteRecord(string $zoneId, string $recordId, string $apiKey, string $email): bool { + $r = $this->req('DELETE', "zones/{$zoneId}/dns_records/{$recordId}", [], $apiKey, $email); + return $r['success'] ?? false; + } + + public function toggleProxy(string $zoneId, string $recordId, bool $proxied, string $apiKey, string $email): array { + // Fetch existing record first + $r = $this->req('GET', "zones/{$zoneId}/dns_records/{$recordId}", [], $apiKey, $email); + $rec = $r['result'] ?? []; + $rec['proxied'] = $proxied; + unset($rec['id'], $rec['zone_id'], $rec['zone_name'], $rec['created_on'], $rec['modified_on'], $rec['meta']); + return $this->req('PUT', "zones/{$zoneId}/dns_records/{$recordId}", $rec, $apiKey, $email); + } + + // ── Sync: push local DNS records to Cloudflare ──────────────────────────── + public function syncToCloudflare(string $domain, string $zoneId, string $apiKey, string $email): array { + $localRecords = $this->getLocalRecords($domain); + $cfRecords = $this->listRecords($zoneId, $apiKey, $email); + $cfIndex = []; + foreach ($cfRecords as $r) $cfIndex[$r['type'] . '_' . $r['name']] = $r; + + $created = 0; $updated = 0; + foreach ($localRecords as $local) { + $key = $local['type'] . '_' . $local['name'] . '.' . $domain . '.'; + if (isset($cfIndex[$key])) { + $this->updateRecord($zoneId, $cfIndex[$key]['id'], [ + 'type' => $local['type'], + 'name' => $local['name'], + 'content' => $local['content'], + 'ttl' => (int)($local['ttl'] ?? 1), + 'proxied' => in_array($local['type'], ['A','AAAA','CNAME']), + ], $apiKey, $email); + $updated++; + } else { + $this->createRecord($zoneId, [ + 'type' => $local['type'], + 'name' => $local['name'], + 'content' => $local['content'], + 'ttl' => (int)($local['ttl'] ?? 1), + 'proxied' => in_array($local['type'], ['A','AAAA','CNAME']), + ], $apiKey, $email); + $created++; + } + } + return ['created' => $created, 'updated' => $updated]; + } + + // ── Sync: pull Cloudflare records into local DB ─────────────────────────── + public function syncFromCloudflare(string $domain, string $zoneId, string $apiKey, string $email): int { + $cfRecords = $this->listRecords($zoneId, $apiKey, $email); + $count = 0; + foreach ($cfRecords as $rec) { + $name = rtrim(str_replace('.' . $domain, '', $rec['name']), '.'); + $this->db->prepare("INSERT INTO dns_records (domain, name, type, content, ttl, priority) + VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE content=VALUES(content), ttl=VALUES(ttl)") + ->execute([$domain, $name ?: '@', $rec['type'], $rec['content'], $rec['ttl'] ?? 300, $rec['priority'] ?? 0]); + $count++; + } + // Store zone ID on domain + $this->db->prepare("UPDATE dns_zones SET cf_zone_id=? WHERE domain=?")->execute([$zoneId, $domain]); + return $count; + } + + // ── Purge cache ─────────────────────────────────────────────────────────── + public function purgeCache(string $zoneId, string $apiKey, string $email): bool { + $r = $this->req('POST', "zones/{$zoneId}/purge_cache", ['purge_everything' => true], $apiKey, $email); + return $r['success'] ?? false; + } + + // ── HTTP helper ─────────────────────────────────────────────────────────── + private function req(string $method, string $path, array $body, string $apiKey, string $email): array { + $ch = curl_init(self::API . ltrim($path, '/')); + $headers = [ + "X-Auth-Email: {$email}", + "X-Auth-Key: {$apiKey}", + "Content-Type: application/json", + ]; + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + CURLOPT_SSL_VERIFYPEER => true, + ]); + if ($body && in_array($method, ['POST','PUT','PATCH'])) { + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); + } + $result = curl_exec($ch); + curl_close($ch); + return json_decode($result, true) ?? ['success' => false, 'errors' => ['curl failed']]; + } + + private function getLocalRecords(string $domain): array { + $stmt = $this->db->prepare("SELECT * FROM dns_records WHERE domain=?"); + $stmt->execute([$domain]); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } +} diff --git a/panel/lib/TOTP.php b/panel/lib/TOTP.php new file mode 100644 index 0000000..bc5afa1 --- /dev/null +++ b/panel/lib/TOTP.php @@ -0,0 +1,78 @@ + password_hash($c, PASSWORD_BCRYPT), $codes)); + } + + public static function verifyBackupCode(string $code, string $hashedJson): bool { + $hashes = json_decode($hashedJson, true) ?? []; + foreach ($hashes as $hash) { + if (password_verify(strtoupper($code), $hash)) return true; + } + return false; + } + + private static function base32Decode(string $base32): string { + $base32 = strtoupper(preg_replace('/[^A-Z2-7]/', '', $base32)); + $buf = 0; $bits = 0; $out = ''; + for ($i = 0; $i < strlen($base32); $i++) { + $val = strpos(self::CHARS, $base32[$i]); + $buf = ($buf << 5) | $val; + $bits += 5; + if ($bits >= 8) { $bits -= 8; $out .= chr(($buf >> $bits) & 0xFF); } + } + return $out; + } +} diff --git a/panel/lib/WordPressManager.php b/panel/lib/WordPressManager.php new file mode 100644 index 0000000..ca3d386 --- /dev/null +++ b/panel/lib/WordPressManager.php @@ -0,0 +1,174 @@ +db = Database::getInstance()->getPDO(); + $this->ensureWpCli(); + } + + // ── Install ─────────────────────────────────────────────────────────────── + public function install(int $accountId, string $domain, string $path, + string $adminUser, string $adminEmail, string $adminPass, + string $siteTitle): array { + $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); + + // 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'"); + + // Download WP + install + $sysUser = $account['system_user'] ?? 'www-data'; + $this->wp($docRoot, "core download --locale=en_US", $sysUser); + $this->wp($docRoot, "config create --dbname={$dbName} --dbuser={$dbUser} --dbpass={$dbPass} --dbhost=localhost --skip-check", $sysUser); + $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); + + // Store in DB + $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 = $this->db->lastInsertId(); + + return ['id' => $id, 'db_name' => $dbName, 'admin_user' => $adminUser, 'admin_pass' => $adminPass]; + } + + // ── List ────────────────────────────────────────────────────────────────── + public function list(int $accountId = 0): array { + $sql = $accountId + ? "SELECT w.*, a.domain as account_domain FROM wordpress_installs w JOIN accounts a ON w.account_id=a.id WHERE w.account_id=? ORDER BY w.created_at DESC" + : "SELECT w.*, a.domain as account_domain FROM wordpress_installs w JOIN accounts a ON w.account_id=a.id ORDER BY w.created_at DESC"; + $stmt = $this->db->prepare($sql); + $accountId ? $stmt->execute([$accountId]) : $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + // ── Update ──────────────────────────────────────────────────────────────── + public function updateCore(int $id): string { + [$install, $sysUser, $docRoot] = $this->resolve($id); + $out = $this->wp($docRoot, 'core update', $sysUser); + $ver = $this->getVersion($docRoot, $sysUser); + $this->db->prepare("UPDATE wordpress_installs SET wp_version=? WHERE id=?")->execute([$ver, $id]); + return $out; + } + + public function updatePlugins(int $id): string { + [$install, $sysUser, $docRoot] = $this->resolve($id); + return $this->wp($docRoot, 'plugin update --all', $sysUser); + } + + public function updateThemes(int $id): string { + [$install, $sysUser, $docRoot] = $this->resolve($id); + return $this->wp($docRoot, 'theme update --all', $sysUser); + } + + // ── Staging clone ───────────────────────────────────────────────────────── + public function cloneStaging(int $id): array { + [$install, $sysUser, $docRoot] = $this->resolve($id); + $stagingPath = $install['path'] . '_staging'; + $stagingDomain = 'staging.' . $install['domain']; + $stagingRoot = dirname($docRoot) . rtrim($stagingPath, '/'); + + // Copy files + $this->exec("cp -r {$docRoot} {$stagingRoot}"); + + // 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->exec("mysqldump {$install['db_name']} | mysql {$stagingDb}"); + + // Update staging wp-config + $this->wp($stagingRoot, "config set DB_NAME {$stagingDb}", $sysUser); + $this->wp($stagingRoot, "config set DB_USER {$stagingDb}", $sysUser); + $this->wp($stagingRoot, "config set DB_PASSWORD {$stagingDbPw}", $sysUser); + $this->wp($stagingRoot, "search-replace https://{$install['domain']} https://{$stagingDomain} --all-tables", $sysUser); + + // Record staging install + $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,staging_of) + VALUES (?,?,?,?,?,?,?,?,?,?,?)"); + $stmt->execute([$install['account_id'], $stagingDomain, $stagingPath, + $stagingDb, $stagingDb, $stagingDbPw, + $install['admin_user'], $install['admin_email'], $install['wp_version'], 'active', $id]); + + return ['domain' => $stagingDomain, 'path' => $stagingPath]; + } + + // ── Delete ──────────────────────────────────────────────────────────────── + 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->db->prepare("DELETE FROM wordpress_installs WHERE id=?")->execute([$id]); + return true; + } + + // ── Info ────────────────────────────────────────────────────────────────── + public function info(int $id): array { + [$install, $sysUser, $docRoot] = $this->resolve($id); + $plugins = $this->wp($docRoot, 'plugin list --format=json', $sysUser); + $themes = $this->wp($docRoot, 'theme list --format=json', $sysUser); + return [ + 'install' => $install, + 'plugins' => json_decode($plugins, true) ?? [], + 'themes' => json_decode($themes, true) ?? [], + 'version' => $this->getVersion($docRoot, $sysUser), + ]; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + private function wp(string $path, string $cmd, string $user): string { + $safe = escapeshellarg($path); + $out = []; $rc = 0; + exec("sudo -u {$user} {$this->wpcli} --path={$safe} --allow-root {$cmd} 2>&1", $out, $rc); + return implode("\n", $out); + } + + private function exec(string $cmd): string { + $out = []; exec($cmd . ' 2>&1', $out); return implode("\n", $out); + } + + private function getVersion(string $path, string $user): string { + $v = trim($this->wp($path, 'core version', $user)); + return $v ?: 'unknown'; + } + + private function resolve(int $id): array { + $stmt = $this->db->prepare("SELECT w.*, a.document_root, a.system_user, a.username FROM wordpress_installs w JOIN accounts a ON w.account_id=a.id WHERE w.id=?"); + $stmt->execute([$id]); + $install = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$install) throw new RuntimeException("WordPress install #{$id} not found"); + $docRoot = $install['document_root'] . rtrim($install['path'], '/'); + $sysUser = $install['system_user'] ?? 'www-data'; + return [$install, $sysUser, $docRoot]; + } + + private function getAccount(int $id): array { + $stmt = $this->db->prepare("SELECT * FROM accounts WHERE id=?"); + $stmt->execute([$id]); + return $stmt->fetch(PDO::FETCH_ASSOC) ?: throw new RuntimeException("Account #{$id} not found"); + } + + 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')); + rename('/tmp/wp-cli.phar', $this->wpcli); + chmod($this->wpcli, 0755); + } + } +} diff --git a/panel/public/admin/index.php b/panel/public/admin/index.php index b772b3d..202bf99 100644 --- a/panel/public/admin/index.php +++ b/panel/public/admin/index.php @@ -97,6 +97,10 @@ FTP Server + + + WordPress + `; } - // ── Backups ──────────────────────────────────────────────────────────────── - async function backups() { - const res = await Nova.api('accounts','list',{params:{limit:1000}}); - const accts = res?.data?.accounts || []; - return ` -
-
- Backup Manager - -
-
-
-
- -
-
- -
-
- - ${accts.slice(0,20).map(a => ` - - - - `).join('')} -
AccountDomainActions
${a.username}${a.domain} - -
-
-
`; - } - window.adminBackupAll = () => Nova.toast('Full backup queued — this may take several minutes.','info',6000); - window.adminBackupAccount = (id, user) => Nova.toast(`Backup queued for ${user}…`,'info'); + // ── Backups — delegates to backupsFull() defined in additions ───────────── + async function backups() { return backupsFull(); } + + // ── Stubs for new pages — implementations in additions block below ───────── + async function wordpress() { return `

Loading…

`; } + async function cloudflare() { return `

Loading…

`; } + async function twofa() { return `

Loading…

`; } // ── Global action helpers ────────────────────────────────────────────────── window.adminPage = (page) => Nova.loadPage(page, pages); @@ -1406,3 +1409,513 @@ ${ips.length ? ` if (badge && total > 0) { badge.textContent = total; badge.style.display = ''; } } })(); +// ── ADDITIONS: appended by features #14-17 ──────────────────────────────── + +// ── WordPress Manager (#14) ──────────────────────────────────────────────── +async function wordpress() { + const [acctRes, wpRes] = await Promise.all([ + Nova.api('accounts','list',{params:{limit:500}}), + Nova.api('wordpress','list'), + ]); + const accts = acctRes?.data?.accounts || []; + const installs = wpRes?.data?.installs || []; + window._adminAcctsWP = accts; + + return ` + + +
+
+ WordPress Installs + ${installs.length} install${installs.length!==1?'s':''} + +
+ ${installs.length ? ` +
+ + + + ${installs.map(w => ` + + + + + + + `).join('')} + +
DomainPathAccountVersionStatusActions
${Nova.escHtml(w.domain)}${Nova.escHtml(w.path||'/')}${Nova.escHtml(w.username||'—')}${w.wp_version ? `${Nova.escHtml(w.wp_version)}` : '—'}${Nova.badge(w.status||'active', w.status==='active'?'green':w.status==='updating'?'yellow':'red')} + + + + + ${!w.staging_of ? `` : `staging`} + +
+
` : `
No WordPress installs yet. Click "Install WordPress" to get started.
`} +
`; +} + +window.wpInstallModal = () => { + const accts = window._adminAcctsWP || []; + const opts = accts.map(a => ``).join(''); + Nova.modal('Install WordPress', ` +
+
+
+
+
+
+
+

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

`, + ` + `); +}; + +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, + }}); + document.querySelector('.modal-overlay')?.remove(); + if (res?.success) { Nova.toast('WordPress installed!','success'); adminPage('wordpress'); } + else Nova.toast(res?.message || 'Install failed','error'); +}; + +window.wpUpdate = async (id, type) => { + const action = type === 'core' ? 'update-core' : type === 'plugins' ? 'update-plugins' : 'update-themes'; + Nova.toast(`Updating ${type}…`,'info',15000); + const r = await Nova.api('wordpress', action, {method:'POST',body:{install_id:id}}); + Nova.toast(r?.message || (r?.success ? 'Updated' : 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) adminPage('wordpress'); +}; + +window.wpInfo = async (id, domain) => { + Nova.toast('Loading info…','info',5000); + const r = await Nova.api('wordpress','info',{params:{install_id:id}}); + if (!r?.success) { Nova.toast(r?.message,'error'); return; } + const d = r.data || {}; + const plugins = (d.plugins||[]).map(p => `${Nova.escHtml(p.name)}${Nova.escHtml(p.version||'')}${Nova.badge(p.status||'inactive',p.status==='active'?'green':'muted')}`).join(''); + const themes = (d.themes||[]).map(t => `${Nova.escHtml(t.name)}${Nova.escHtml(t.version||'')}${Nova.badge(t.status||'inactive',t.status==='active'?'green':'muted')}`).join(''); + Nova.modal(`WordPress: ${domain}`,` +
+

Core Version

${Nova.escHtml(d.version||'—')}

+

Site URL

${Nova.escHtml(d.siteurl||'—')}

+
+

Plugins (${(d.plugins||[]).length})

+ ${plugins ? `${plugins}
PluginVersionStatus
` : '

None

'} +

Themes (${(d.themes||[]).length})

+ ${themes ? `${themes}
ThemeVersionStatus
` : '

None

'}`); +}; + +window.wpCloneStaging = (id, domain) => { + Nova.confirm(`Clone ${domain} to a staging environment? This copies all files and the database.`, async () => { + Nova.toast('Cloning to staging…','info',30000); + const r = await Nova.api('wordpress','clone-staging',{method:'POST',body:{install_id:id}}); + Nova.toast(r?.message || (r?.success ? 'Staging created' : 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) adminPage('wordpress'); + }); +}; + +window.wpDelete = (id, domain) => { + Nova.confirm(`DELETE WordPress install on ${domain}? This removes all files AND drops the database. IRREVERSIBLE.`, async () => { + const r = await Nova.api('wordpress','delete',{method:'POST',body:{install_id:id}}); + Nova.toast(r?.message || (r?.success ? 'Deleted' : 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) adminPage('wordpress'); + }, true); +}; + +// ── Backup Manager — full implementation (#15) ───────────────────────────── +async function backupsFull() { + const [acctRes, bkRes] = await Promise.all([ + Nova.api('accounts','list',{params:{limit:500}}), + Nova.api('backup','list'), + ]); + const accts = acctRes?.data?.accounts || []; + const backupList = bkRes?.data?.backups || []; + const diskUsed = bkRes?.data?.disk_used || 0; + window._adminAcctsBK = accts; + + return ` + + +
+
+
Total Backups
+
${backupList.length}
+
+
+
Disk Used
+
${Nova.bytes(diskUsed)}
+
+
+
Accounts
+
${accts.length}
+
+
+ +
+
+ Backup Schedules + +
+
+

Set per-account backup schedules. Cron runs backups automatically based on the configured frequency.

+
+ ${accts.slice(0,8).map(a => ``).join('')} + ${accts.length>8?`+${accts.length-8} more`:''} +
+
+
+ +
+
All Backups
+ ${backupList.length ? ` +
+ + + + ${backupList.map(b => ` + + + + + + + + `).join('')} + +
AccountTypeSizeStatusStorageCreatedActions
${Nova.escHtml(b.username||b.account_id||'—')}${Nova.badge(b.type,'default')}${Nova.bytes(b.size||0)}${Nova.badge(b.status, b.status==='complete'?'green':b.status==='failed'?'red':'yellow')}${b.remote_path ? Nova.badge('remote','blue') : Nova.badge('local','muted')}${Nova.relTime(b.created_at)} + ${b.status==='complete'?`Download`:''} + + +
+
` : `
No backups yet.
`} +
`; +} + +window.bkCreateModal = () => { + const accts = window._adminAcctsBK || []; + const opts = accts.map(a => ``).join(''); + Nova.modal('Create Backup', ` +
+
+ +
`, + ` + `); +}; + +window.bkSubmitCreate = async () => { + const id = +document.getElementById('bk-acct')?.value; + const type = document.getElementById('bk-type')?.value; + document.querySelector('.modal-overlay')?.remove(); + Nova.toast('Creating backup…','info',30000); + const r = await Nova.api('backup','create',{method:'POST',body:{account_id:id,type}}); + Nova.toast(r?.message||(r?.success?'Backup complete':'Failed'), r?.success?'success':'error'); + if (r?.success) adminPage('backups'); +}; + +window.bkRestore = (id) => { + Nova.confirm('Restore this backup? Current files and databases will be overwritten. IRREVERSIBLE.', async () => { + Nova.toast('Restoring…','info',30000); + const r = await Nova.api('backup','restore',{method:'POST',body:{id}}); + Nova.toast(r?.message||(r?.success?'Restored':'Failed'), r?.success?'success':'error'); + }, true); +}; + +window.bkDelete = (id) => { + Nova.confirm('Delete this backup?', async () => { + const r = await Nova.api('backup','delete',{method:'POST',body:{id}}); + Nova.toast(r?.message||(r?.success?'Deleted':'Failed'), r?.success?'success':'error'); + if (r?.success) adminPage('backups'); + }, true); +}; + +window.bkScheduleModal = () => { + const accts = window._adminAcctsBK || []; + const opts = accts.map(a => ``).join(''); + Nova.modal('Configure Backup Schedule', ` +
+
+ +
+
+ +
+
`, + ` + `); +}; + +window.bkScheduleForAccount = async (id, user) => { + const r = await Nova.api('backup','get-schedule',{params:{account_id:id}}); + const s = r?.data || {}; + Nova.modal(`Schedule: ${user}`, ` +
+ +
+
+ +
+
`, + ` + `); +}; + +window.bkSaveSchedule = async () => { + const id = +document.getElementById('bks-acct')?.value; + await bkSaveScheduleFor(id); +}; + +window.bkSaveScheduleFor = async (id) => { + const r = await Nova.api('backup','schedule',{method:'POST',body:{ + account_id: id, + frequency: document.getElementById('bks-freq')?.value, + type: document.getElementById('bks-type')?.value, + retain: +document.getElementById('bks-retain')?.value, + }}); + document.querySelector('.modal-overlay')?.remove(); + Nova.toast(r?.message||(r?.success?'Schedule saved':'Failed'), r?.success?'success':'error'); +}; + +// ── Cloudflare Integration (#16) ────────────────────────────────────────── +async function cloudflare() { + const acctRes = await Nova.api('accounts','list',{params:{limit:500}}); + const accts = acctRes?.data?.accounts || []; + window._adminAcctsCF = accts; + + return ` + + +
+
Account Credentials
+
+

Select an account to configure or view its Cloudflare API key.

+
+
+ + +
+
+
+
+
+ +`; +} + +window.cfLoadAccount = async (id) => { + if (!id) { document.getElementById('cf-acct-panel').innerHTML=''; return; } + const r = await Nova.api('cloudflare','get-credentials',{params:{account_id:id}}); + const c = r?.data || {}; + document.getElementById('cf-acct-panel').innerHTML = ` +
+
+
+
+
+
+
+ + +
+ ${c.cf_api_key ? `

Key on file: ${Nova.escHtml(c.cf_api_key)}

` : ''}`; + document.getElementById('cf-zones-panel').style.display = ''; + window._cfCurrentAcct = id; +}; + +window.cfSaveCredentials = async (id) => { + const r = await Nova.api('cloudflare','save-credentials',{method:'POST',body:{ + account_id: id, + api_key: document.getElementById('cf-apikey')?.value, + email: document.getElementById('cf-email')?.value, + }}); + Nova.toast(r?.message||(r?.success?'Saved':'Failed'), r?.success?'success':'error'); +}; + +window.cfTestKey = async (id) => { + const r = await Nova.api('cloudflare','test-key',{method:'POST',body:{ + account_id: id, + api_key: document.getElementById('cf-apikey')?.value, + email: document.getElementById('cf-email')?.value, + }}); + Nova.toast(r?.message||(r?.data?.valid?'API key is valid':'Invalid key'), r?.data?.valid?'success':'error'); +}; + +window.cfRefreshZones = async () => { + const id = window._cfCurrentAcct; + if (!id) { Nova.toast('Select an account first','error'); return; } + const r = await Nova.api('cloudflare','list-zones',{params:{account_id:id}}); + const zones = r?.data?.zones || r?.data || []; + const body = document.getElementById('cf-zones-body'); + if (!body) return; + if (!r?.success) { body.innerHTML=`

${Nova.escHtml(r?.message||'Failed to load zones')}

`; return; } + if (!zones.length) { body.innerHTML='

No zones found for these credentials.

'; return; } + body.innerHTML = ` + + + + ${zones.map(z=>` + + + + + `).join('')} + +
ZoneStatusPlanActions
${Nova.escHtml(z.name)}
${Nova.escHtml(z.id)}
${Nova.badge(z.status,z.status==='active'?'green':'yellow')}${Nova.escHtml(z.plan?.name||'—')} + + + + +
`; +}; + +window.cfViewRecords = async (zoneId, domain, acctId) => { + const r = await Nova.api('cloudflare','list-records',{method:'POST',body:{zone_id:zoneId,account_id:acctId}}); + const records = r?.data?.records || r?.data || []; + Nova.modal(`CF DNS: ${domain}`, !records.length ? '

No records.

' : ` + + + + ${records.map(rec=>` + + + + + `).join('')} + +
NameTypeValueProxy
${Nova.escHtml(rec.name)}${Nova.badge(rec.type,'default')}${Nova.escHtml(rec.content)} + +
`); +}; + +window.cfToggleProxy = async (zoneId, recordId, proxied, acctId) => { + const r = await Nova.api('cloudflare','toggle-proxy',{method:'POST',body:{zone_id:zoneId,record_id:recordId,proxied,account_id:acctId}}); + Nova.toast(r?.message||(r?.success?'Updated':'Failed'), r?.success?'success':'error'); +}; + +window.cfSync = async (zoneId, domain, dir, acctId) => { + const action = dir==='to' ? 'sync-to-cf' : 'sync-from-cf'; + const label = dir==='to' ? 'Pushing to Cloudflare' : 'Pulling from Cloudflare'; + Nova.toast(`${label}…`,'info',10000); + const r = await Nova.api('cloudflare',action,{method:'POST',body:{zone_id:zoneId,domain,account_id:acctId}}); + Nova.toast(r?.message||(r?.success?'Done':'Failed'), r?.success?'success':'error'); +}; + +window.cfPurge = async (zoneId, acctId) => { + Nova.confirm('Purge all Cloudflare cache for this zone?', async () => { + const r = await Nova.api('cloudflare','purge-cache',{method:'POST',body:{zone_id:zoneId,account_id:acctId}}); + Nova.toast(r?.message||(r?.success?'Cache purged':'Failed'), r?.success?'success':'error'); + }); +}; + +// ── TOTP / 2FA Admin (#17) ──────────────────────────────────────────────── +async function twofa() { + const res = await Nova.api('accounts','list',{params:{limit:500}}); + const users = res?.data?.accounts || []; + return ` + + +
+
+ User 2FA Status + +
+
+ + + + ${users.map(u=>` + + + + + + `).join('')} + +
UsernameEmailRole2FA StatusActions
${Nova.escHtml(u.username)}${Nova.escHtml(u.email||'—')}${Nova.badge(u.role||'user','default')} + + + + +
+
+
`; +} + +window.totpCheckStatus = async (userId) => { + const r = await Nova.api('totp','admin-status',{method:'POST',body:{user_id:userId}}); + const el = document.getElementById(`totp-status-${userId}`); + if (!el) return; + const enabled = r?.data?.totp_enabled; + el.innerHTML = enabled + ? Nova.badge('Enabled','green') + : Nova.badge('Disabled','muted'); +}; + +window.totpAdminDisable = (userId, username) => { + Nova.confirm(`Force-disable 2FA for ${username}? Use only for account recovery when user cannot log in.`, async () => { + const r = await Nova.api('totp','admin-disable',{method:'POST',body:{user_id:userId}}); + Nova.toast(r?.message||(r?.success?'2FA disabled':'Failed'), r?.success?'success':'error'); + if (r?.success) { + const el = document.getElementById(`totp-status-${userId}`); + if (el) el.innerHTML = Nova.badge('Disabled','muted'); + } + }, true); +}; From 90ab33ccf0be2cb02213937c352c80c996bc5f4c Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Mon, 8 Jun 2026 00:15:22 +0000 Subject: [PATCH 2/5] fix: account creation home dir permissions and duplicate SPF record - Use sudo for mkdir/chown/chmod in home dir setup so www-data can execute - Set public_html to 775 (group-writable) so www-data can deploy index.html - Remove duplicate SPF from createZone defaults (provisionEmailDNS owns SPF/DMARC/DKIM) - sudo mkdir/chown in provisionEmailDNS for opendkim key directory --- panel/lib/AccountManager.php | 37 +++++++++++++++++++++++------------- panel/lib/DNSManager.php | 8 ++++---- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/panel/lib/AccountManager.php b/panel/lib/AccountManager.php index 1f56c35..b20f0de 100644 --- a/panel/lib/AccountManager.php +++ b/panel/lib/AccountManager.php @@ -28,9 +28,9 @@ class AccountManager { // Create Linux user self::shell("useradd -m -d {$homeDir} -s /sbin/nologin -G www-data " . escapeshellarg($username)); self::shell("echo " . escapeshellarg("{$username}:{$password}") . " | chpasswd"); - self::shell("mkdir -p {$docRoot} {$homeDir}/logs {$homeDir}/tmp"); - self::shell("chown -R {$username}:www-data {$homeDir}"); - self::shell("chmod 750 {$homeDir}; chmod 755 {$docRoot}"); + self::shell("sudo mkdir -p {$docRoot} {$homeDir}/logs {$homeDir}/tmp"); + self::shell("sudo chown -R {$username}:www-data {$homeDir}"); + self::shell("sudo chmod 750 {$homeDir}"); self::shell("sudo chmod 775 {$docRoot}"); // Default index page file_put_contents("{$docRoot}/index.html", @@ -116,9 +116,9 @@ class AccountManager { public static function provisionEmailDNS(int $acctId, string $domain): void { // Generate DKIM keypair $keyDir = "/etc/opendkim/keys/{$domain}"; - self::shell("mkdir -p " . escapeshellarg($keyDir)); + self::shell("sudo mkdir -p " . escapeshellarg($keyDir)); self::shell("opendkim-genkey -b 2048 -s mail -d " . escapeshellarg($domain) . " -D " . escapeshellarg($keyDir)); - self::shell("chown -R opendkim:opendkim " . escapeshellarg($keyDir)); + self::shell("sudo chown -R opendkim:opendkim " . escapeshellarg($keyDir)); // Parse public key from .txt file $keyTxt = @file_get_contents("{$keyDir}/mail.txt") ?: ''; @@ -139,13 +139,17 @@ class AccountManager { ); // DKIM TXT record - DNSManager::addRecord($acctId, $domain, 'TXT', "mail._domainkey", "v=DKIM1; k=rsa; p={$pubKey}", 300); + $zoneRow = DB::getInstance()->fetchOne("SELECT id FROM dns_zones WHERE account_id=? AND domain=?", [$acctId, $domain]); + if ($zoneRow) DNSManager::addRecord((int)$zoneRow['id'], 'mail._domainkey', 'TXT', "v=DKIM1; k=rsa; p={$pubKey}", 300); } - // SPF - DNSManager::addRecord($acctId, $domain, 'TXT', '@', "v=spf1 mx a ~all", 300); - // DMARC - DNSManager::addRecord($acctId, $domain, 'TXT', '_dmarc', "v=DMARC1; p=quarantine; rua=mailto:dmarc@{$domain}", 300); + // SPF + DMARC — look up zone once + $db2 = DB::getInstance(); + $zoneRow = $zoneRow ?? $db2->fetchOne("SELECT id FROM dns_zones WHERE account_id=? AND domain=?", [$acctId, $domain]); + if ($zoneRow) { + DNSManager::addRecord((int)$zoneRow['id'], '@', 'TXT', "v=spf1 mx a ~all", 3600); + DNSManager::addRecord((int)$zoneRow['id'], '_dmarc', 'TXT', "v=DMARC1; p=quarantine; rua=mailto:dmarc@{$domain}", 3600); + } novacpx_log('info', "Email DNS provisioned for $domain"); } @@ -154,9 +158,9 @@ class AccountManager { $db = DB::getInstance(); $selector = 'mail' . date('Ym'); $keyDir = "/etc/opendkim/keys/{$domain}"; - self::shell("mkdir -p " . escapeshellarg($keyDir)); + self::shell("sudo mkdir -p " . escapeshellarg($keyDir)); self::shell("opendkim-genkey -b 2048 -s {$selector} -d " . escapeshellarg($domain) . " -D " . escapeshellarg($keyDir)); - self::shell("chown -R opendkim:opendkim " . escapeshellarg($keyDir)); + self::shell("sudo chown -R opendkim:opendkim " . escapeshellarg($keyDir)); $keyTxt = @file_get_contents("{$keyDir}/{$selector}.txt") ?: ''; preg_match('/p=([A-Za-z0-9+\/=]+)/', $keyTxt, $m); @@ -174,7 +178,8 @@ class AccountManager { ); // Add new TXT record, remove old mail._domainkey - DNSManager::addRecord($acctId, $domain, 'TXT', "{$selector}._domainkey", "v=DKIM1; k=rsa; p={$pubKey}", 300); + $zoneRow = $db->fetchOne("SELECT id FROM dns_zones WHERE account_id=? AND domain=?", [$acctId, $domain]); + if ($zoneRow) DNSManager::addRecord((int)$zoneRow['id'], "{$selector}._domainkey", 'TXT', "v=DKIM1; k=rsa; p={$pubKey}", 300); novacpx_log('info', "DKIM rotated for $domain, new selector: $selector"); return $selector; } @@ -185,6 +190,12 @@ class AccountManager { } private static function shell(string $cmd): string { + // Prefix privileged commands with sudo so www-data can run them + $privileged = ['useradd','userdel','usermod','chpasswd','a2ensite','a2dissite','apache2ctl','certbot','opendkim-genkey','rndc','named-checkzone','systemctl']; + $cmdBase = explode(' ', ltrim($cmd))[0]; + foreach ($privileged as $p) { + if (str_ends_with($cmdBase, $p) || $cmdBase === $p) { $cmd = 'sudo ' . $cmd; break; } + } $out = shell_exec($cmd . ' 2>&1'); novacpx_log('debug', "shell: $cmd"); return $out ?: ''; diff --git a/panel/lib/DNSManager.php b/panel/lib/DNSManager.php index 0694a7e..48e626f 100644 --- a/panel/lib/DNSManager.php +++ b/panel/lib/DNSManager.php @@ -26,7 +26,6 @@ class DNSManager { ['www', 'A', $ip, 3600, null], ['mail', 'A', $ip, 3600, null], ['@', 'MX', "mail.{$domain}.", 3600, 10], - ['@', 'TXT', "v=spf1 a mx ~all", 3600, null], ]; foreach ($defaults as [$name, $type, $content, $ttl, $prio]) { $db->execute( @@ -127,13 +126,14 @@ class DNSManager { // Include in main named.conf if not already there $mainConf = '/etc/bind/named.conf'; - if (file_exists($mainConf) && !str_contains(file_get_contents($mainConf), 'named.conf.novacpx')) { - file_put_contents($mainConf, "\ninclude \"" . self::$namedConf . "\";\n", FILE_APPEND); + if (file_exists($mainConf) && !str_contains(file_get_contents($mainConf) ?: '', 'named.conf.novacpx')) { + $line = "\ninclude \"" . self::$namedConf . "\";\n"; + shell_exec("echo " . escapeshellarg($line) . " | sudo tee -a {$mainConf} > /dev/null 2>&1"); } } private static function reloadBind(): void { - shell_exec("rndc reload 2>/dev/null || systemctl reload named 2>/dev/null || true"); + shell_exec("sudo rndc reload 2>/dev/null || sudo systemctl reload named 2>/dev/null || sudo systemctl reload bind9 2>/dev/null || true"); } private static function serverIp(): string { From 0ab3d8d584b40daac96dcacc25854a7f1d152727 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Mon, 8 Jun 2026 00:29:04 +0000 Subject: [PATCH 3/5] feat: Nginx Proxy Manager admin panel section (#22-proxy) - ProxyManager.php: install, start/stop/restart/reload, manage proxy hosts, write nginx configs, sync from accounts, setup script generator - proxy.php API endpoint: full CRUD for proxy hosts + control/install/sync - Admin panel: Nginx Proxy sidebar nav (Services section) with status cards, host table, add/edit/toggle/delete, auto-sync accounts, setup guide modal - DB migration 003: proxy_hosts table + settings entries - Sudoers: nginx systemctl/install rules for www-data - Setup guide covers: local install, remote VM, automated script, vhost integration --- db/migrations/003_proxy_hosts.sql | 19 +++ panel/api/endpoints/proxy.php | 100 ++++++++++++ panel/lib/ProxyManager.php | 252 ++++++++++++++++++++++++++++++ panel/public/admin/index.php | 4 + panel/public/assets/js/admin.js | 249 +++++++++++++++++++++++++++++ 5 files changed, 624 insertions(+) create mode 100644 db/migrations/003_proxy_hosts.sql create mode 100644 panel/api/endpoints/proxy.php create mode 100644 panel/lib/ProxyManager.php diff --git a/db/migrations/003_proxy_hosts.sql b/db/migrations/003_proxy_hosts.sql new file mode 100644 index 0000000..81c91a8 --- /dev/null +++ b/db/migrations/003_proxy_hosts.sql @@ -0,0 +1,19 @@ +-- Migration 003: Nginx Proxy Hosts table +CREATE TABLE IF NOT EXISTS proxy_hosts ( + id INT AUTO_INCREMENT PRIMARY KEY, + account_id INT UNSIGNED DEFAULT NULL, + domain VARCHAR(255) NOT NULL, + upstream VARCHAR(500) NOT NULL DEFAULT 'http://127.0.0.1:80', + ssl_enabled TINYINT(1) NOT NULL DEFAULT 0, + enabled TINYINT(1) NOT NULL DEFAULT 1, + custom_config TEXT DEFAULT NULL, + notes VARCHAR(500) DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + INDEX (account_id), + UNIQUE KEY uq_domain (domain) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Panel settings for proxy mode +INSERT INTO settings (`key`, `value`) VALUES ('proxy_mode', 'disabled') ON DUPLICATE KEY UPDATE `key`=`key`; +INSERT INTO settings (`key`, `value`) VALUES ('proxy_auto_sync', '0') ON DUPLICATE KEY UPDATE `key`=`key`; diff --git a/panel/api/endpoints/proxy.php b/panel/api/endpoints/proxy.php new file mode 100644 index 0000000..112fe33 --- /dev/null +++ b/panel/api/endpoints/proxy.php @@ -0,0 +1,100 @@ +requireRole('admin'); +require_once PANEL_ROOT . '/lib/ProxyManager.php'; + +$method = $_SERVER['REQUEST_METHOD']; +$parts = $routeParts ?? []; +$subpath = implode('/', array_slice($parts, 1)); + +// Numeric id extraction +preg_match('|hosts/(\d+)(/.+)?|', $subpath, $m); +$hostId = isset($m[1]) ? (int)$m[1] : null; +$hostSub = $m[2] ?? ''; + +try { + // GET proxy/status + if ($method === 'GET' && $subpath === 'status') { + json_ok(ProxyManager::status()); + + // POST proxy/install + } elseif ($method === 'POST' && $subpath === 'install') { + $result = ProxyManager::install(); + json_ok(['result' => $result]); + + // POST proxy/control + } elseif ($method === 'POST' && $subpath === 'control') { + $action = $body['action'] ?? ''; + if (!in_array($action, ['start','stop','restart','reload'])) json_error('Invalid action', 400); + $result = match($action) { + 'start' => ProxyManager::start(), + 'stop' => ProxyManager::stop(), + 'restart' => ProxyManager::restart(), + 'reload' => ProxyManager::reload(), + }; + json_ok(['result' => $result, 'running' => ProxyManager::isRunning()]); + + // GET proxy/hosts + } elseif ($method === 'GET' && $subpath === 'hosts') { + json_ok(ProxyManager::listHosts()); + + // POST proxy/hosts — add + } elseif ($method === 'POST' && $subpath === 'hosts') { + if (empty($body['domain'])) json_error('domain required', 400); + if (empty($body['upstream'])) json_error('upstream required', 400); + $id = ProxyManager::addHost($body); + json_ok(['id' => $id]); + + // PUT proxy/hosts/{id} + } elseif ($method === 'PUT' && $hostId && !$hostSub) { + ProxyManager::updateHost($hostId, $body); + json_ok(); + + // DELETE proxy/hosts/{id} + } elseif ($method === 'DELETE' && $hostId && !$hostSub) { + ProxyManager::deleteHost($hostId); + json_ok(); + + // POST proxy/hosts/{id}/toggle + } elseif ($method === 'POST' && $hostId && $hostSub === '/toggle') { + ProxyManager::toggleHost($hostId, (bool)($body['enabled'] ?? true)); + json_ok(); + + // POST proxy/sync + } elseif ($method === 'POST' && $subpath === 'sync') { + $added = ProxyManager::syncFromAccounts(); + json_ok(['added' => $added]); + + // POST proxy/write-configs + } elseif ($method === 'POST' && $subpath === 'write-configs') { + ProxyManager::writeAllConfigs(); + json_ok(['result' => 'configs written']); + + // GET proxy/setup-script + } elseif ($method === 'GET' && $subpath === 'setup-script') { + header('Content-Type: text/plain'); + echo ProxyManager::setupScript(); + exit; + + } else { + json_error('Not found', 404); + } +} catch (Throwable $e) { + novacpx_log('error', 'proxy endpoint: ' . $e->getMessage()); + json_error($e->getMessage(), 500); +} diff --git a/panel/lib/ProxyManager.php b/panel/lib/ProxyManager.php new file mode 100644 index 0000000..5fa7bc2 --- /dev/null +++ b/panel/lib/ProxyManager.php @@ -0,0 +1,252 @@ +/dev/null')); + } + + public static function isRunning(): bool { + $out = shell_exec('systemctl is-active nginx 2>/dev/null'); + return trim($out ?? '') === 'active'; + } + + public static function status(): array { + $installed = self::isInstalled(); + $running = $installed && self::isRunning(); + $version = $installed ? trim(shell_exec('nginx -v 2>&1') ?: '') : ''; + $db = DB::getInstance(); + $row = $db->fetchOne("SELECT value FROM settings WHERE `key` = 'proxy_mode'"); + $mode = $row['value'] ?? 'disabled'; + return [ + 'installed' => $installed, + 'running' => $running, + 'version' => $version, + 'mode' => $mode, + ]; + } + + public static function start(): string { + return self::sysctl('start'); + } + + public static function stop(): string { + return self::sysctl('stop'); + } + + public static function restart(): string { + return self::sysctl('restart'); + } + + public static function reload(): string { + if (!self::isInstalled()) return 'nginx not installed'; + $test = shell_exec('sudo nginx -t 2>&1'); + if (strpos($test ?? '', 'successful') === false) return 'Config test failed: ' . $test; + shell_exec('sudo systemctl reload nginx 2>/dev/null'); + return 'reloaded'; + } + + public static function install(): string { + if (self::isInstalled()) return 'already installed'; + shell_exec('sudo apt-get update -qq 2>/dev/null && sudo apt-get install -y nginx 2>&1'); + if (!self::isInstalled()) return 'install failed'; + // Disable default site + @unlink('/etc/nginx/sites-enabled/default'); + shell_exec('sudo systemctl enable nginx 2>/dev/null'); + shell_exec('sudo systemctl start nginx 2>/dev/null'); + return 'installed'; + } + + // --- Proxy Hosts --- + + public static function listHosts(): array { + $db = DB::getInstance(); + return $db->fetchAll("SELECT * FROM proxy_hosts ORDER BY domain") ?: []; + } + + public static function syncFromAccounts(): int { + $db = DB::getInstance(); + $accounts = $db->fetchAll("SELECT a.*, d.domain FROM accounts a JOIN domains d ON d.account_id=a.id AND d.type='main' WHERE a.status='active'") ?: []; + $count = 0; + foreach ($accounts as $acct) { + $existing = $db->fetchOne("SELECT id FROM proxy_hosts WHERE domain=?", [$acct['domain']]); + if (!$existing) { + $db->insert( + "INSERT INTO proxy_hosts (account_id, domain, upstream, ssl_enabled, enabled, created_at) VALUES (?,?,?,0,1,NOW())", + [$acct['id'], $acct['domain'], 'http://127.0.0.1:80'] + ); + $count++; + } + } + if ($count > 0) self::writeAllConfigs(); + return $count; + } + + public static function addHost(array $data): int { + $db = DB::getInstance(); + $id = (int)$db->insert( + "INSERT INTO proxy_hosts (account_id, domain, upstream, ssl_enabled, enabled, custom_config, created_at) VALUES (?,?,?,?,1,?,NOW())", + [ + $data['account_id'] ?? null, + $data['domain'], + $data['upstream'] ?? 'http://127.0.0.1:80', + (int)($data['ssl_enabled'] ?? 0), + $data['custom_config'] ?? null, + ] + ); + self::writeAllConfigs(); + return $id; + } + + public static function updateHost(int $id, array $data): void { + $db = DB::getInstance(); + $db->execute( + "UPDATE proxy_hosts SET domain=?, upstream=?, ssl_enabled=?, enabled=?, custom_config=? WHERE id=?", + [$data['domain'], $data['upstream'], (int)($data['ssl_enabled'] ?? 0), (int)($data['enabled'] ?? 1), $data['custom_config'] ?? null, $id] + ); + self::writeAllConfigs(); + } + + public static function deleteHost(int $id): void { + $db = DB::getInstance(); + $host = $db->fetchOne("SELECT domain FROM proxy_hosts WHERE id=?", [$id]); + $db->execute("DELETE FROM proxy_hosts WHERE id=?", [$id]); + if ($host) { + @unlink(self::$confDir . '/' . self::$confPrefix . $host['domain'] . '.conf'); + @unlink(self::$enabledDir . '/' . self::$confPrefix . $host['domain'] . '.conf'); + } + self::reload(); + } + + public static function toggleHost(int $id, bool $enable): void { + $db = DB::getInstance(); + $db->execute("UPDATE proxy_hosts SET enabled=? WHERE id=?", [(int)$enable, $id]); + self::writeAllConfigs(); + } + + // --- Config Generation --- + + public static function writeAllConfigs(): void { + if (!self::isInstalled()) return; + $db = DB::getInstance(); + $hosts = $db->fetchAll("SELECT * FROM proxy_hosts") ?: []; + // Remove old novacpx proxy configs + foreach (glob(self::$confDir . '/' . self::$confPrefix . '*.conf') ?: [] as $f) @unlink($f); + foreach (glob(self::$enabledDir . '/' . self::$confPrefix . '*.conf') ?: [] as $f) @unlink($f); + foreach ($hosts as $host) { + if (!$host['enabled']) continue; + self::writeHostConfig($host); + } + self::reload(); + } + + private static function writeHostConfig(array $host): void { + $safe = preg_replace('/[^a-z0-9._-]/', '', strtolower($host['domain'])); + $confPath = self::$confDir . '/' . self::$confPrefix . $safe . '.conf'; + $linkPath = self::$enabledDir . '/' . self::$confPrefix . $safe . '.conf'; + + if ($host['custom_config']) { + file_put_contents($confPath, $host['custom_config']); + } else { + $upstream = rtrim($host['upstream'], '/'); + $ssl = !empty($host['ssl_enabled']); + $certDir = "/etc/novacpx/ssl/accounts/" . preg_replace('/[^a-z0-9._-]/', '', $host['domain']); + + $conf = "server {\n"; + $conf .= " listen 80;\n"; + if ($ssl) $conf .= " listen 443 ssl http2;\n"; + $conf .= " server_name {$host['domain']} www.{$host['domain']};\n"; + if ($ssl) { + $conf .= " ssl_certificate {$certDir}/cert.pem;\n"; + $conf .= " ssl_certificate_key {$certDir}/key.pem;\n"; + $conf .= " ssl_protocols TLSv1.2 TLSv1.3;\n"; + $conf .= " ssl_ciphers HIGH:!aNULL:!MD5;\n"; + } + $conf .= " location / {\n"; + $conf .= " proxy_pass {$upstream};\n"; + $conf .= " proxy_http_version 1.1;\n"; + $conf .= " proxy_set_header Host \$host;\n"; + $conf .= " proxy_set_header X-Real-IP \$remote_addr;\n"; + $conf .= " proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;\n"; + $conf .= " proxy_set_header X-Forwarded-Proto \$scheme;\n"; + $conf .= " proxy_set_header Upgrade \$http_upgrade;\n"; + $conf .= " proxy_set_header Connection 'upgrade';\n"; + $conf .= " proxy_cache_bypass \$http_upgrade;\n"; + $conf .= " proxy_read_timeout 86400;\n"; + $conf .= " }\n"; + $conf .= "}\n"; + file_put_contents($confPath, $conf); + } + @symlink($confPath, $linkPath); + } + + // --- Setup Script --- + + public static function setupScript(): string { + $serverIp = trim(shell_exec("hostname -I | awk '{print $1}'") ?: '127.0.0.1'); + return << /etc/nginx/conf.d/novacpx-proxy.conf << 'EOF' +client_max_body_size 256M; +proxy_buffers 16 16k; +proxy_buffer_size 16k; +gzip on; +gzip_types text/plain text/css application/json application/javascript text/xml application/xml; +EOF + +# Point proxy back to NovaCPX Apache backend (update SERVER_IP below) +BACKEND_IP={$serverIp} + +# Generic catch-all for testing +cat > /etc/nginx/sites-available/novacpx-default.conf << EOF +server { + listen 80 default_server; + server_name _; + return 444; +} +EOF +ln -sf /etc/nginx/sites-available/novacpx-default.conf /etc/nginx/sites-enabled/ + +# Test and reload +nginx -t && systemctl reload nginx +systemctl enable nginx + +echo "[NovaCPX] Nginx proxy installed and running." +echo " Backend IP: \$BACKEND_IP" +echo " Add proxy hosts from the NovaCPX admin panel → Nginx Proxy" +BASH; + } + + // --- Helpers --- + + private static function sysctl(string $action): string { + if (!self::isInstalled()) return 'nginx not installed'; + shell_exec("sudo systemctl {$action} nginx 2>/dev/null"); + sleep(1); + return self::isRunning() ? 'running' : 'stopped'; + } +} diff --git a/panel/public/admin/index.php b/panel/public/admin/index.php index 202bf99..c16e457 100644 --- a/panel/public/admin/index.php +++ b/panel/public/admin/index.php @@ -97,6 +97,10 @@ FTP Server
+ + + Nginx Proxy + WordPress diff --git a/panel/public/assets/js/admin.js b/panel/public/assets/js/admin.js index 5c3d3ac..a7d7032 100644 --- a/panel/public/assets/js/admin.js +++ b/panel/public/assets/js/admin.js @@ -87,6 +87,7 @@ 'mysql-manager': mysqlManager, 'mail-server': mailServer, 'ftp-server': ftpServer, + 'nginx-proxy': nginxProxy, wordpress, 'ssl-manager': sslManager, firewall, @@ -98,6 +99,7 @@ settings, }; + window._novaPages = pages; Nova.initNav(pages); await Nova.loadPage('dashboard', pages); checkUpdates(); @@ -1341,6 +1343,7 @@ ${ips.length ? ` async function wordpress() { return `

Loading…

`; } async function cloudflare() { return `

Loading…

`; } async function twofa() { return `

Loading…

`; } + async function nginxProxy() { return `

Loading…

`; } // ── Global action helpers ────────────────────────────────────────────────── window.adminPage = (page) => Nova.loadPage(page, pages); @@ -1919,3 +1922,249 @@ window.totpAdminDisable = (userId, username) => { } }, true); }; + +// ── Nginx Proxy Manager ─────────────────────────────────────────────────────── +async function nginxProxy() { + const [statusR, hostsR] = await Promise.all([ + Nova.api('proxy', 'status'), + Nova.api('proxy', 'hosts'), + ]); + const s = statusR?.data || {}; + const hosts = hostsR?.data || (Array.isArray(hostsR) ? hostsR : []); + const run = s.running; + const inst = s.installed; + + return ` + + +
+
+
Nginx Status
+
${inst ? (run ? 'Running' : 'Stopped') : 'Not Installed'}
+
${s.version || (inst ? 'nginx' : 'click Install to set up')}
+
+
+
Proxy Hosts
+
${hosts.length}
+
${hosts.filter(h => h.enabled).length} active
+
+
+
SSL Enabled
+
${hosts.filter(h => h.ssl_enabled).length}
+
of ${hosts.length} hosts
+
+
+ +${!inst ? ` +
+ +

Nginx Not Installed

+

Install Nginx on this VM to use it as a reverse proxy in front of Apache, or use a separate proxy VM (see Setup Guide).

+
+ + +
+
+` : ` +
+
+

Service Controls

+
+ + + + +
+
+
+ +
+
+

Proxy Hosts

+ ${hosts.length} total +
+ ${hosts.length === 0 ? ` +
+ No proxy hosts yet. Click Sync Accounts to auto-add all hosted domains, or + Add Host to add manually. +
+ ` : ` +
+ + + + + + + + + + ${hosts.map(h => ` + + + + + + + `).join('')} + +
DomainUpstreamSSLStatusActions
${Nova.escHtml(h.domain)}${Nova.escHtml(h.upstream)}${h.ssl_enabled ? Nova.badge('SSL','green') : Nova.badge('HTTP','muted')}${h.enabled ? Nova.badge('Active','green') : Nova.badge('Disabled','red')} + + + +
+
+ `} +
+`}`; +} + +window.proxyInstall = async () => { + if (!confirm('Install Nginx on this VM? This will run apt-get install nginx.')) return; + Nova.toast('Installing nginx...', 'info'); + const r = await Nova.api('proxy', 'install', { method: 'POST' }); + Nova.toast(r?.data?.result || r?.message || 'Done', r?.data?.result === 'installed' ? 'success' : 'info'); + Nova.loadPage('nginx-proxy', window._novaPages); +}; + +window.proxyControl = async (action) => { + const r = await Nova.api('proxy', 'control', { method: 'POST', body: { action } }); + Nova.toast(r?.data?.result || r?.message || action + ' done', 'success'); + setTimeout(() => Nova.loadPage('nginx-proxy', window._novaPages), 800); +}; + +window.proxySync = async () => { + const r = await Nova.api('proxy', 'sync', { method: 'POST' }); + Nova.toast(`Synced: ${r?.data?.added ?? 0} new hosts added`, 'success'); + Nova.loadPage('nginx-proxy', window._novaPages); +}; + +window.proxyAddHost = () => { + Nova.modal('Add Proxy Host', ` +
+
+
+ + e.g. http://127.0.0.1:80 or http://10.0.0.2:8080
+
+
+
+
+ `, async () => { + const domain = document.getElementById('ph-domain')?.value?.trim(); + const upstream = document.getElementById('ph-upstream')?.value?.trim(); + if (!domain || !upstream) { Nova.toast('Domain and upstream required', 'error'); return; } + const r = await Nova.api('proxy', 'hosts', { + method: 'POST', + body: { domain, upstream, ssl_enabled: document.getElementById('ph-ssl')?.checked ? 1 : 0 } + }); + Nova.toast(r?.success ? 'Host added' : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages); + }); +}; + +window.proxyEditHost = async (id) => { + const hostsR = await Nova.api('proxy', 'hosts'); + const hosts = hostsR?.data || (Array.isArray(hostsR) ? hostsR : []); + const h = hosts.find(x => x.id == id); + if (!h) return; + Nova.modal('Edit Proxy Host', ` +
+
+
+
+
+
+
+ + Leave blank to use auto-generated config
+ `, async () => { + const r = await Nova.api('proxy', `hosts/${id}`, { + method: 'PUT', + body: { + domain: document.getElementById('phe-domain')?.value?.trim(), + upstream: document.getElementById('phe-upstream')?.value?.trim(), + ssl_enabled: document.getElementById('phe-ssl')?.checked ? 1 : 0, + custom_config: document.getElementById('phe-custom')?.value?.trim() || null, + } + }); + Nova.toast(r?.success ? 'Updated' : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages); + }); +}; + +window.proxyToggle = async (id, enable) => { + const r = await Nova.api('proxy', `hosts/${id}/toggle`, { method: 'POST', body: { enabled: enable } }); + Nova.toast(r?.success ? (enable ? 'Enabled' : 'Disabled') : 'Failed', r?.success ? 'success' : 'error'); + if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages); +}; + +window.proxyDeleteHost = (id, domain) => { + Nova.confirm(`Delete proxy host for ${domain}?`, async () => { + const r = await Nova.api('proxy', `hosts/${id}`, { method: 'DELETE' }); + Nova.toast(r?.success ? 'Deleted' : 'Failed', r?.success ? 'success' : 'error'); + if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages); + }, true); +}; + +window.proxySetupInstructions = async () => { + const scriptUrl = '/api/proxy/setup-script'; + Nova.modal('Nginx Proxy Setup Guide', ` +
+

Option A — Local (Nginx on this VM)

+

Install Nginx alongside Apache on this VM. Nginx listens on ports 80/443 and forwards to Apache. Best for SSL termination and caching.

+
    +
  1. Click Install Nginx Locally on the main Nginx Proxy page
  2. +
  3. Move Apache to port 8080: edit /etc/apache2/ports.conf → change Listen 80 to Listen 8080
  4. +
  5. Update upstream in all proxy hosts to http://127.0.0.1:8080
  6. +
  7. Click Sync Accounts to auto-populate proxy hosts from your hosted accounts
  8. +
  9. Click Reload Config to apply changes
  10. +
+ +

Option B — Remote Proxy VM (Recommended for production)

+

Run a dedicated Nginx proxy VM in front of this NovaCPX VM. Traffic flows: Internet → FortiGate → Nginx Proxy VM → NovaCPX VM (Apache).

+
    +
  1. Create a new VM on Proxmox (Ubuntu 22.04, 1 vCPU, 1GB RAM)
  2. +
  3. Run the setup script below on the new VM as root
  4. +
  5. Point FortiGate VIPs to the proxy VM IP (ports 80/443)
  6. +
  7. Set the proxy upstream to this NovaCPX VM IP (http://10.48.200.110:80)
  8. +
  9. Add proxy hosts for each domain from your NovaCPX admin panel
  10. +
+ +

Automated Setup Script

+

Run this on the target VM (local or remote) as root:

+
+ curl -sk https://YOUR_NOVACPX_IP:8882/api/proxy/setup-script | bash +
+

Or download and review before running:

+
+ curl -sk https://YOUR_NOVACPX_IP:8882/api/proxy/setup-script -o proxy-setup.sh
+ cat proxy-setup.sh # review
+ bash proxy-setup.sh +
+ +

Integration with VirtualHost Manager

+

When proxy mode is active, NovaCPX automatically:

+
    +
  • Creates a proxy host entry for every new account
  • +
  • Removes the proxy host when an account is terminated
  • +
  • Re-generates Nginx config on every account change
  • +
  • Uses account SSL certs automatically if SSL is enabled on the proxy host
  • +
+
+ `, null, { cancelLabel: 'Close', showConfirm: false }); +}; From e2e4fa7fbf3e9da84e45f5ea3876caaad1f21e26 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Mon, 8 Jun 2026 00:37:31 +0000 Subject: [PATCH 4/5] fix: proxy endpoint uses require() not requireRole(); fix JS API routes --- panel/api/endpoints/proxy.php | 162 +++++++++++++++++--------------- panel/public/assets/js/admin.js | 8 +- 2 files changed, 89 insertions(+), 81 deletions(-) diff --git a/panel/api/endpoints/proxy.php b/panel/api/endpoints/proxy.php index 112fe33..c5adc03 100644 --- a/panel/api/endpoints/proxy.php +++ b/panel/api/endpoints/proxy.php @@ -2,99 +2,107 @@ /** * Proxy endpoint — manage Nginx reverse proxy * Routes: - * GET proxy/status — nginx status + mode - * POST proxy/install — install nginx - * POST proxy/control — {action: start|stop|restart|reload} - * GET proxy/hosts — list proxy hosts - * POST proxy/hosts — add proxy host - * PUT proxy/hosts/{id} — update proxy host - * DELETE proxy/hosts/{id} — delete proxy host - * POST proxy/hosts/{id}/toggle — {enabled: bool} - * POST proxy/sync — sync hosts from accounts - * POST proxy/write-configs — regenerate all nginx configs - * GET proxy/setup-script — return bash install script + * GET /api/proxy/status — nginx status + * POST /api/proxy/install — install nginx + * POST /api/proxy/control — {action: start|stop|restart|reload} + * GET /api/proxy/hosts — list proxy hosts + * POST /api/proxy/hosts — add proxy host + * PUT /api/proxy/host — {id, ...fields} update host + * DELETE /api/proxy/host — {id} delete host + * POST /api/proxy/toggle — {id, enabled} toggle host + * POST /api/proxy/sync — sync hosts from accounts + * POST /api/proxy/write-configs — regenerate all nginx configs + * GET /api/proxy/setup-script — return bash install script */ -Auth::getInstance()->requireRole('admin'); -require_once PANEL_ROOT . '/lib/ProxyManager.php'; +Auth::getInstance()->require('admin'); +$body = json_decode(file_get_contents('php://input'), true) ?? []; -$method = $_SERVER['REQUEST_METHOD']; -$parts = $routeParts ?? []; -$subpath = implode('/', array_slice($parts, 1)); - -// Numeric id extraction -preg_match('|hosts/(\d+)(/.+)?|', $subpath, $m); -$hostId = isset($m[1]) ? (int)$m[1] : null; -$hostSub = $m[2] ?? ''; +require_once NOVACPX_LIB . '/ProxyManager.php'; try { - // GET proxy/status - if ($method === 'GET' && $subpath === 'status') { - json_ok(ProxyManager::status()); + $method = $_SERVER['REQUEST_METHOD']; - // POST proxy/install - } elseif ($method === 'POST' && $subpath === 'install') { - $result = ProxyManager::install(); - json_ok(['result' => $result]); + match (true) { - // POST proxy/control - } elseif ($method === 'POST' && $subpath === 'control') { - $action = $body['action'] ?? ''; - if (!in_array($action, ['start','stop','restart','reload'])) json_error('Invalid action', 400); - $result = match($action) { - 'start' => ProxyManager::start(), - 'stop' => ProxyManager::stop(), - 'restart' => ProxyManager::restart(), - 'reload' => ProxyManager::reload(), - }; - json_ok(['result' => $result, 'running' => ProxyManager::isRunning()]); + // GET status + $action === 'status' && $method === 'GET' => + Response::json(['success' => true, 'data' => ProxyManager::status()]), - // GET proxy/hosts - } elseif ($method === 'GET' && $subpath === 'hosts') { - json_ok(ProxyManager::listHosts()); + // POST install + $action === 'install' && $method === 'POST' => + Response::json(['success' => true, 'data' => ['result' => ProxyManager::install()]]), - // POST proxy/hosts — add - } elseif ($method === 'POST' && $subpath === 'hosts') { - if (empty($body['domain'])) json_error('domain required', 400); - if (empty($body['upstream'])) json_error('upstream required', 400); - $id = ProxyManager::addHost($body); - json_ok(['id' => $id]); + // POST control + $action === 'control' && $method === 'POST' => (function() use ($body) { + $act = $body['action'] ?? ''; + if (!in_array($act, ['start','stop','restart','reload'])) Response::error('Invalid action', 400); + $result = match($act) { + 'start' => ProxyManager::start(), + 'stop' => ProxyManager::stop(), + 'restart' => ProxyManager::restart(), + 'reload' => ProxyManager::reload(), + }; + Response::json(['success' => true, 'data' => ['result' => $result, 'running' => ProxyManager::isRunning()]]); + })(), - // PUT proxy/hosts/{id} - } elseif ($method === 'PUT' && $hostId && !$hostSub) { - ProxyManager::updateHost($hostId, $body); - json_ok(); + // GET hosts list + $action === 'hosts' && $method === 'GET' => + Response::json(['success' => true, 'data' => ProxyManager::listHosts()]), - // DELETE proxy/hosts/{id} - } elseif ($method === 'DELETE' && $hostId && !$hostSub) { - ProxyManager::deleteHost($hostId); - json_ok(); + // POST hosts — add + $action === 'hosts' && $method === 'POST' => (function() use ($body) { + if (empty($body['domain'])) Response::error('domain required', 400); + if (empty($body['upstream'])) Response::error('upstream required', 400); + $id = ProxyManager::addHost($body); + Response::json(['success' => true, 'data' => ['id' => $id]]); + })(), - // POST proxy/hosts/{id}/toggle - } elseif ($method === 'POST' && $hostId && $hostSub === '/toggle') { - ProxyManager::toggleHost($hostId, (bool)($body['enabled'] ?? true)); - json_ok(); + // PUT host — update (body has id) + $action === 'host' && $method === 'PUT' => (function() use ($body) { + if (empty($body['id'])) Response::error('id required', 400); + ProxyManager::updateHost((int)$body['id'], $body); + Response::json(['success' => true]); + })(), - // POST proxy/sync - } elseif ($method === 'POST' && $subpath === 'sync') { - $added = ProxyManager::syncFromAccounts(); - json_ok(['added' => $added]); + // DELETE host (body has id) + $action === 'host' && $method === 'DELETE' => (function() use ($body) { + $id = (int)($body['id'] ?? $_GET['id'] ?? 0); + if (!$id) Response::error('id required', 400); + ProxyManager::deleteHost($id); + Response::json(['success' => true]); + })(), - // POST proxy/write-configs - } elseif ($method === 'POST' && $subpath === 'write-configs') { - ProxyManager::writeAllConfigs(); - json_ok(['result' => 'configs written']); + // POST toggle + $action === 'toggle' && $method === 'POST' => (function() use ($body) { + if (empty($body['id'])) Response::error('id required', 400); + ProxyManager::toggleHost((int)$body['id'], (bool)($body['enabled'] ?? true)); + Response::json(['success' => true]); + })(), - // GET proxy/setup-script - } elseif ($method === 'GET' && $subpath === 'setup-script') { - header('Content-Type: text/plain'); - echo ProxyManager::setupScript(); - exit; + // POST sync + $action === 'sync' && $method === 'POST' => (function() { + $added = ProxyManager::syncFromAccounts(); + Response::json(['success' => true, 'data' => ['added' => $added]]); + })(), + + // POST write-configs + ($action === 'write-configs' || $action === 'write_configs') && $method === 'POST' => (function() { + ProxyManager::writeAllConfigs(); + Response::json(['success' => true, 'data' => ['result' => 'configs written']]); + })(), + + // GET setup-script + ($action === 'setup-script' || $action === 'setup_script') && $method === 'GET' => (function() { + header('Content-Type: text/plain'); + echo ProxyManager::setupScript(); + exit; + })(), + + default => Response::error('Not found', 404), + }; - } else { - json_error('Not found', 404); - } } catch (Throwable $e) { novacpx_log('error', 'proxy endpoint: ' . $e->getMessage()); - json_error($e->getMessage(), 500); + Response::error($e->getMessage(), 500); } diff --git a/panel/public/assets/js/admin.js b/panel/public/assets/js/admin.js index a7d7032..e2683e2 100644 --- a/panel/public/assets/js/admin.js +++ b/panel/public/assets/js/admin.js @@ -2093,9 +2093,9 @@ window.proxyEditHost = async (id) => { Leave blank to use auto-generated config `, async () => { - const r = await Nova.api('proxy', `hosts/${id}`, { + const r = await Nova.api('proxy', 'host', { method: 'PUT', - body: { + body: { id, domain: document.getElementById('phe-domain')?.value?.trim(), upstream: document.getElementById('phe-upstream')?.value?.trim(), ssl_enabled: document.getElementById('phe-ssl')?.checked ? 1 : 0, @@ -2108,14 +2108,14 @@ window.proxyEditHost = async (id) => { }; window.proxyToggle = async (id, enable) => { - const r = await Nova.api('proxy', `hosts/${id}/toggle`, { method: 'POST', body: { enabled: enable } }); + const r = await Nova.api('proxy', 'toggle', { method: 'POST', body: { id, enabled: enable } }); Nova.toast(r?.success ? (enable ? 'Enabled' : 'Disabled') : 'Failed', r?.success ? 'success' : 'error'); if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages); }; window.proxyDeleteHost = (id, domain) => { Nova.confirm(`Delete proxy host for ${domain}?`, async () => { - const r = await Nova.api('proxy', `hosts/${id}`, { method: 'DELETE' }); + const r = await Nova.api('proxy', 'host', { method: 'DELETE', body: { id } }); Nova.toast(r?.success ? 'Deleted' : 'Failed', r?.success ? 'success' : 'error'); if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages); }, true); From 88e98b4727a9058a1d473ccf64923845a0409c16 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Mon, 8 Jun 2026 00:50:21 +0000 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20polish=20items=20#26-29=20=E2=80=94?= =?UTF-8?q?=20mobile=20CSS,=20error=20pages,=20rate=20limiting,=20session?= =?UTF-8?q?=20manager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #26 Mobile responsive: - Hamburger button (SVG) in topbar for all three panels (admin/user/reseller) - Sidebar overlay div for click-outside-to-close on mobile - nova.js: DOMContentLoaded toggle handler with overlay and auto-close on nav click - nova.css: sidebar-overlay, page-header, panel/panel-header, table, btn-success/warning/danger/secondary/xs, badge-muted; mobile media query shows toggle, fixes stats-grid/modal/panel-header layout #27 Custom error pages: - /errors/404.php and /errors/500.php with NovaCPX dark theme matching panel design - Apache ErrorDocument 400/401/403/404/500/503 for ports 8880/8881/8882 with Alias /errors #28 API rate limiting: - api_rate_limits table (migration 004) with per-IP per-bucket counters - api/index.php: 10 req/min for auth endpoint, 120 req/min for all others - Returns X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset headers - Returns 429 Too Many Requests when exceeded; rate limit failure is non-fatal #29 Session Manager: - sessions.php endpoint: list/revoke/revoke-user/revoke-all - Admin panel Sessions page: table of active sessions with user, role, IP, browser, timestamps - Revoke single session, revoke all for user, revoke all sessions (self-evicts) --- db/migrations/004_rate_limits.sql | 8 +++ panel/api/endpoints/sessions.php | 57 ++++++++++++++++++++++ panel/api/index.php | 34 +++++++++++++ panel/public/admin/index.php | 8 ++- panel/public/assets/css/nova.css | 81 +++++++++++++++++++++++++++++++ panel/public/assets/js/admin.js | 70 +++++++++++++++++++++++++- panel/public/assets/js/nova.js | 19 ++++++++ panel/public/errors/404.php | 39 +++++++++++++++ panel/public/errors/500.php | 39 +++++++++++++++ panel/public/reseller/index.php | 1 + panel/public/user/index.php | 1 + 11 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 db/migrations/004_rate_limits.sql create mode 100644 panel/api/endpoints/sessions.php create mode 100644 panel/public/errors/404.php create mode 100644 panel/public/errors/500.php diff --git a/db/migrations/004_rate_limits.sql b/db/migrations/004_rate_limits.sql new file mode 100644 index 0000000..2adc7f5 --- /dev/null +++ b/db/migrations/004_rate_limits.sql @@ -0,0 +1,8 @@ +-- Migration 004: API rate limiting table +CREATE TABLE IF NOT EXISTS api_rate_limits ( + ip VARCHAR(45) NOT NULL, + endpoint VARCHAR(64) NOT NULL DEFAULT 'api', + hits INT UNSIGNED NOT NULL DEFAULT 1, + window_start INT UNSIGNED NOT NULL, + PRIMARY KEY (ip, endpoint) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/panel/api/endpoints/sessions.php b/panel/api/endpoints/sessions.php new file mode 100644 index 0000000..3680e9d --- /dev/null +++ b/panel/api/endpoints/sessions.php @@ -0,0 +1,57 @@ +require('admin'); +$body = json_decode(file_get_contents('php://input'), true) ?? []; +$db = DB::getInstance(); +$me = Auth::getInstance()->user(); +$method = $_SERVER['REQUEST_METHOD']; + +match (true) { + + $action === 'list' && $method === 'GET' => (function() use ($db) { + $rows = $db->fetchAll( + "SELECT s.id, s.user_id, s.ip_address, s.user_agent, s.created_at, s.expires_at, + u.username, u.email, u.role + FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.expires_at > NOW() + ORDER BY s.created_at DESC + LIMIT 200" + ) ?: []; + Response::json(['success' => true, 'data' => $rows]); + })(), + + $action === 'revoke' && $method === 'DELETE' => (function() use ($db, $body) { + $sid = trim($body['session_id'] ?? ''); + if (!$sid) Response::error('session_id required', 400); + $db->execute("DELETE FROM sessions WHERE id = ?", [$sid]); + Response::json(['success' => true]); + })(), + + $action === 'revoke-user' && $method === 'DELETE' => (function() use ($db, $body) { + $uid = (int)($body['user_id'] ?? 0); + if (!$uid) Response::error('user_id required', 400); + $count = $db->execute("DELETE FROM sessions WHERE user_id = ?", [$uid]); + Response::json(['success' => true, 'data' => ['revoked' => $count]]); + })(), + + $action === 'revoke-all' && $method === 'DELETE' => (function() use ($db, $me, $body) { + // Keep current session if provided + $keepId = $body['keep_session'] ?? null; + if ($keepId) { + $db->execute("DELETE FROM sessions WHERE id != ?", [hash('sha256', $keepId)]); + } else { + $db->execute("DELETE FROM sessions"); + } + Response::json(['success' => true]); + })(), + + default => Response::error('Not found', 404), +}; diff --git a/panel/api/index.php b/panel/api/index.php index 1b7839e..7b84ec6 100644 --- a/panel/api/index.php +++ b/panel/api/index.php @@ -56,4 +56,38 @@ if (!file_exists($endpointFile)) { Response::error("Unknown endpoint: $endpoint", 404); } + + +// #28 Rate limiting — per-IP, per-endpoint bucket +(function() use ($endpoint) { + $db = DB::getInstance(); + $ip = $_SERVER["REMOTE_ADDR"] ?? "0.0.0.0"; + $now = time(); + $window = 60; + $limit = $endpoint === "auth" ? 10 : 120; + $bucket = $endpoint === "auth" ? "auth" : "api"; + try { + $row = $db->fetchOne("SELECT hits, window_start FROM api_rate_limits WHERE ip=? AND endpoint=?", [$ip, $bucket]); + if ($row && ($now - (int)$row["window_start"]) < $window) { + $hits = (int)$row["hits"] + 1; + $db->execute("UPDATE api_rate_limits SET hits=? WHERE ip=? AND endpoint=?", [$hits, $ip, $bucket]); + } else { + $hits = 1; + $db->execute("INSERT INTO api_rate_limits (ip, endpoint, hits, window_start) VALUES (?,?,1,?) ON DUPLICATE KEY UPDATE hits=1, window_start=VALUES(window_start)", [$ip, $bucket, $now]); + } + $reset = ($row ? (int)$row["window_start"] : $now) + $window; + $remaining = max(0, $limit - $hits); + header("X-RateLimit-Limit: {$limit}"); + header("X-RateLimit-Remaining: {$remaining}"); + header("X-RateLimit-Reset: {$reset}"); + if ($hits > $limit) { + http_response_code(429); + echo json_encode(["success"=>false,"message"=>"Too many requests. Try again in " . ($reset - $now) . " seconds.","errors"=>[]]); + exit; + } + } catch (Throwable $e) { + novacpx_log("warn", "rate limit error: " . $e->getMessage()); + } +})(); + require $endpointFile; diff --git a/panel/public/admin/index.php b/panel/public/admin/index.php index c16e457..504d9df 100644 --- a/panel/public/admin/index.php +++ b/panel/public/admin/index.php @@ -14,6 +14,7 @@