diff --git a/db/migrations/002_features_14_17.sql b/db/migrations/002_features_14_17.sql index 799658a..d01c183 100644 --- a/db/migrations/002_features_14_17.sql +++ b/db/migrations/002_features_14_17.sql @@ -1,59 +1,29 @@ -- Migration 002: Features #14-17 (WordPress, Backup, Cloudflare, TOTP) - --- #17 TOTP columns on users (ignore errors if columns already exist) 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; - --- #16 Cloudflare columns on accounts 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; - --- #16 Cloudflare zone_id on dns_zones ALTER TABLE dns_zones ADD COLUMN cf_zone_id VARCHAR(64) DEFAULT NULL; - --- #14 WordPress installs 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) + 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; - --- #15 Backups 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) + 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) + 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/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/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/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/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 { diff --git a/panel/public/admin/index.php b/panel/public/admin/index.php index ab85db1..3461378 100644 --- a/panel/public/admin/index.php +++ b/panel/public/admin/index.php @@ -14,6 +14,7 @@ $_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);