feat: items #9-13 — password change, webmail SSO, DKIM live, file manager security, cache busting

#9  auth.php: add self-service change-password action (current+new+confirm)
    accounts.php: fix admin change-password — accept account_id, fetch username
    for chpasswd (was using int ID), add Auth::require('admin') guard
    user.js: add Change Password page + navItem + submitChangePassword()

#10 EmailManager: store AES-256-CBC enc_password alongside SHA512-CRYPT hash
    webmail.php: rewrite login-url to use webmail_sso_tokens table
    novacpx-sso.php: Roundcube SSO bridge (validate token, decrypt, autosubmit)
    Migration 005: add enc_password column + webmail_sso_tokens table

#11 opendkim: installed, configured (/etc/opendkim.conf, signing.table,
    key.table, trusted.hosts), socket at /var/spool/postfix/opendkim/,
    Postfix milter wired, service enabled+running, key generation verified

#12 files.php: fix safe_path() for non-existent paths (write/mkdir),
    add safe_path_new() helper using parent-dir realpath check,
    fix delete guard (block deleting account root dirs),
    fix rename destination, clamp chmod to 0777

#13 nova.js: api() handles network errors, 429 rate-limit with retry-after,
    non-JSON responses (PHP fatal pages) — graceful error instead of throw
    admin/user/reseller index.php: filemtime-based cache-busting on all assets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 01:19:33 +00:00
parent 62707d62ce
commit 6fdccc6dbd
27 changed files with 1736 additions and 79 deletions
+165
View File
@@ -0,0 +1,165 @@
<?php
class BackupManager {
private PDO $db;
private string $backupRoot = '/home/novacpx-backups';
public function __construct() {
$this->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");
}
}
+150
View File
@@ -0,0 +1,150 @@
<?php
class CloudflareManager {
private const API = 'https://api.cloudflare.com/client/v4/';
private PDO $db;
public function __construct() {
$this->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);
}
}
+28 -6
View File
@@ -6,11 +6,12 @@
class EmailManager {
public static function createAccount(int $accountId, string $email, string $password, int $quotaMb = 500): int {
$db = DB::getInstance();
$db = DB::getInstance();
$hashed = self::hashPassword($password);
$enc = self::encryptPassword($password);
$id = (int)$db->insert(
"INSERT INTO email_accounts (account_id, email, password, quota_mb) VALUES (?,?,?,?)",
[$accountId, $email, $hashed, $quotaMb]
"INSERT INTO email_accounts (account_id, email, password, enc_password, quota_mb) VALUES (?,?,?,?,?)",
[$accountId, $email, $hashed, $enc, $quotaMb]
);
self::syncPostfix();
novacpx_log('info', "Email account created: $email");
@@ -27,7 +28,10 @@ class EmailManager {
public static function changePassword(int $id, string $newPassword): void {
$db = DB::getInstance();
$db->execute("UPDATE email_accounts SET password = ? WHERE id = ?", [self::hashPassword($newPassword), $id]);
$db->execute(
"UPDATE email_accounts SET password = ?, enc_password = ? WHERE id = ?",
[self::hashPassword($newPassword), self::encryptPassword($newPassword), $id]
);
}
public static function suspend(int $id): void {
@@ -56,9 +60,21 @@ class EmailManager {
);
}
/**
* Decrypt stored IMAP password for SSO use only.
*/
public static function decryptPassword(string $enc): ?string {
$key = substr(hash('sha256', SECRET_KEY, true), 0, 32);
$data = base64_decode($enc);
if (strlen($data) <= 16) return null;
$iv = substr($data, 0, 16);
$encrypted = substr($data, 16);
$plain = openssl_decrypt($encrypted, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
return $plain !== false ? $plain : null;
}
/**
* Sync Postfix virtual_mailbox_maps + virtual_alias_maps files from DB
* Postfix reads these files (postmap creates .db hash)
*/
private static function syncPostfix(): void {
$db = DB::getInstance();
@@ -93,7 +109,13 @@ class EmailManager {
}
private static function hashPassword(string $password): string {
// Dovecot SHA512-CRYPT compatible
return '{SHA512-CRYPT}' . crypt($password, '$6$' . bin2hex(random_bytes(8)) . '$');
}
private static function encryptPassword(string $password): string {
$key = substr(hash('sha256', SECRET_KEY, true), 0, 32);
$iv = random_bytes(16);
$enc = openssl_encrypt($password, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
return base64_encode($iv . $enc);
}
}
+252
View File
@@ -0,0 +1,252 @@
<?php
/**
* ProxyManager — manages Nginx reverse proxy for NovaCPX hosted accounts.
* Supports local nginx (on same VM) or remote nginx (separate proxy VM via SSH).
*/
class ProxyManager {
private static string $confDir = '/etc/nginx/sites-available';
private static string $enabledDir = '/etc/nginx/sites-enabled';
private static string $confPrefix = 'novacpx-proxy-';
// --- Status & Control ---
public static function isInstalled(): bool {
return file_exists('/usr/sbin/nginx') || !empty(shell_exec('which nginx 2>/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 <<<BASH
#!/bin/bash
# NovaCPX Nginx Reverse Proxy Setup Script
# Run as root on the proxy VM (or this VM for local proxy)
set -e
echo "[NovaCPX] Installing Nginx reverse proxy..."
apt-get update -qq
apt-get install -y nginx certbot python3-certbot-nginx
# Disable default site
rm -f /etc/nginx/sites-enabled/default
# Create NovaCPX proxy conf directory
mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
# Main nginx.conf tuning
cat > /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';
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
class TOTP {
private const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
private const PERIOD = 30;
private const DIGITS = 6;
private const WINDOW = 1;
public static function generateSecret(int $length = 32): string {
$secret = '';
$bytes = random_bytes($length);
for ($i = 0; $i < $length; $i++) {
$secret .= self::CHARS[ord($bytes[$i]) & 31];
}
return $secret;
}
public static function generateCode(string $secret, ?int $timestamp = null): string {
$counter = intdiv($timestamp ?? time(), self::PERIOD);
$key = self::base32Decode($secret);
$msg = pack('J', $counter);
$hash = hash_hmac('sha1', $msg, $key, true);
$offset = ord($hash[19]) & 0x0F;
$code = ((ord($hash[$offset]) & 0x7F) << 24)
| ((ord($hash[$offset+1]) & 0xFF) << 16)
| ((ord($hash[$offset+2]) & 0xFF) << 8)
| (ord($hash[$offset+3]) & 0xFF);
return str_pad($code % (10 ** self::DIGITS), self::DIGITS, '0', STR_PAD_LEFT);
}
public static function verify(string $secret, string $code): bool {
$time = time();
for ($i = -self::WINDOW; $i <= self::WINDOW; $i++) {
if (hash_equals(self::generateCode($secret, $time + $i * self::PERIOD), $code)) {
return true;
}
}
return false;
}
public static function qrUrl(string $secret, string $username, string $issuer = 'NovaCPX'): string {
$label = rawurlencode("{$issuer}:{$username}");
$otpauth = "otpauth://totp/{$label}?secret={$secret}&issuer=" . rawurlencode($issuer) . "&algorithm=SHA1&digits=6&period=30";
return "https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=" . rawurlencode($otpauth);
}
public static function generateBackupCodes(int $count = 8): array {
$codes = [];
for ($i = 0; $i < $count; $i++) {
$raw = bin2hex(random_bytes(4));
$codes[] = strtoupper(substr($raw, 0, 4) . '-' . substr($raw, 4, 4));
}
return $codes;
}
public static function hashBackupCodes(array $codes): string {
return json_encode(array_map(fn($c) => 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;
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
class WordPressManager {
private PDO $db;
private string $wpcli = '/usr/local/bin/wp';
public function __construct() {
$this->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);
}
}
}