From e3b166803af5d96e4f32736cb740ebff9e9fcf27 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Sun, 7 Jun 2026 05:50:50 +0000 Subject: [PATCH] Add full API endpoint suite, lib managers, webmail (Roundcube :8883), and NovaCPX icon/branding assets - 14 API endpoints: accounts, packages, domains, dns, email, databases, ftp, ssl, cron, php, files, stats, webmail, server_setup - 8 lib managers: AccountManager, VhostManager, DNSManager, EmailManager, DatabaseManager, PHPManager, FTPManager, SSLManager - Roundcube webmail on dedicated port 8883 (sequenced after 8880/8881/8882) - Custom NovaCPX SVG icon sprite (30+ unique icons), logo, mark, favicon - PORT_WEBMAIL=8883 wired into Core.php, install.sh, UFW, Fail2Ban, credentials file Co-Authored-By: Claude Sonnet 4.6 --- install.sh | 79 ++++++ panel/api/endpoints/accounts.php | 145 ++++++++++ panel/api/endpoints/cron.php | 69 +++++ panel/api/endpoints/databases.php | 60 +++++ panel/api/endpoints/dns.php | 101 +++++++ panel/api/endpoints/domains.php | 123 +++++++++ panel/api/endpoints/email.php | 94 +++++++ panel/api/endpoints/files.php | 140 ++++++++++ panel/api/endpoints/ftp.php | 40 +++ panel/api/endpoints/packages.php | 74 +++++ panel/api/endpoints/php.php | 56 ++++ panel/api/endpoints/server_setup.php | 110 ++++++++ panel/api/endpoints/ssl.php | 62 +++++ panel/api/endpoints/stats.php | 114 ++++++++ panel/api/endpoints/webmail.php | 53 ++++ panel/lib/AccountManager.php | 123 +++++++++ panel/lib/Core.php | 4 +- panel/lib/DNSManager.php | 147 ++++++++++ panel/lib/DatabaseManager.php | 95 +++++++ panel/lib/EmailManager.php | 99 +++++++ panel/lib/FTPManager.php | 66 +++++ panel/lib/PHPManager.php | 108 ++++++++ panel/lib/SSLManager.php | 86 ++++++ panel/lib/VhostManager.php | 150 +++++++++++ panel/public/assets/img/nova-favicon.svg | 9 + panel/public/assets/img/nova-icons.svg | 329 +++++++++++++++++++++++ panel/public/assets/img/nova-logo.svg | 26 ++ panel/public/assets/img/nova-mark.svg | 15 ++ 28 files changed, 2576 insertions(+), 1 deletion(-) create mode 100644 panel/api/endpoints/accounts.php create mode 100644 panel/api/endpoints/cron.php create mode 100644 panel/api/endpoints/databases.php create mode 100644 panel/api/endpoints/dns.php create mode 100644 panel/api/endpoints/domains.php create mode 100644 panel/api/endpoints/email.php create mode 100644 panel/api/endpoints/files.php create mode 100644 panel/api/endpoints/ftp.php create mode 100644 panel/api/endpoints/packages.php create mode 100644 panel/api/endpoints/php.php create mode 100644 panel/api/endpoints/server_setup.php create mode 100644 panel/api/endpoints/ssl.php create mode 100644 panel/api/endpoints/stats.php create mode 100644 panel/api/endpoints/webmail.php create mode 100644 panel/lib/AccountManager.php create mode 100644 panel/lib/DNSManager.php create mode 100644 panel/lib/DatabaseManager.php create mode 100644 panel/lib/EmailManager.php create mode 100644 panel/lib/FTPManager.php create mode 100644 panel/lib/PHPManager.php create mode 100644 panel/lib/SSLManager.php create mode 100644 panel/lib/VhostManager.php create mode 100644 panel/public/assets/img/nova-favicon.svg create mode 100644 panel/public/assets/img/nova-icons.svg create mode 100644 panel/public/assets/img/nova-logo.svg create mode 100644 panel/public/assets/img/nova-mark.svg diff --git a/install.sh b/install.sh index d250596..5e63687 100644 --- a/install.sh +++ b/install.sh @@ -18,6 +18,7 @@ PHP_DEFAULT="8.3" PORT_USER=8880 # End-user panel PORT_RESELLER=8881 # Reseller panel PORT_ADMIN=8882 # Admin / datacenter panel +PORT_WEBMAIL=8883 # Roundcube webmail # ── Colors ──────────────────────────────────────────────────────────────────── RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' @@ -114,6 +115,7 @@ NovaCPX Installation Credentials — $(date) User Panel: https://$(hostname -I | awk '{print $1}'):${PORT_USER} Reseller Panel: https://$(hostname -I | awk '{print $1}'):${PORT_RESELLER} Admin Panel: https://$(hostname -I | awk '{print $1}'):${PORT_ADMIN} +Webmail: https://$(hostname -I | awk '{print $1}'):${PORT_WEBMAIL} Admin User: admin Admin Pass: $ADMIN_PASS DB Name: $DB_NAME @@ -376,6 +378,74 @@ log "SSL certificate generated" apt-get install -y -qq certbot >> "$LOG" 2>&1 log "Certbot installed for Let's Encrypt SSL" +# ── Roundcube Webmail ───────────────────────────────────────────────────────── +step "Installing Roundcube Webmail (port ${PORT_WEBMAIL})" +apt-get install -y -qq roundcube roundcube-mysql php8.3-intl php8.3-ldap >> "$LOG" 2>&1 +RC_ROOT="/usr/share/roundcube" +mkdir -p /etc/novacpx/roundcube + +# Roundcube config +RC_DB_PASS=$(openssl rand -base64 16 | tr -dc 'A-Za-z0-9' | head -c 16) +mysql -e "CREATE DATABASE IF NOT EXISTS roundcube CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" >> "$LOG" 2>&1 +mysql -e "CREATE USER IF NOT EXISTS 'roundcube'@'localhost' IDENTIFIED BY '${RC_DB_PASS}';" >> "$LOG" 2>&1 +mysql -e "GRANT ALL PRIVILEGES ON roundcube.* TO 'roundcube'@'localhost';" >> "$LOG" 2>&1 +mysql roundcube < /usr/share/dbconfig-common/data/roundcube/install/mysql 2>/dev/null || true + +cat > /etc/roundcube/config.inc.php <> "$PANEL_WEB_CONF" <> "$PANEL_WEB_CONF" < + DocumentRoot ${RC_ROOT} + SSLEngine on + SSLCertificateFile /etc/novacpx/ssl/novacpx.crt + SSLCertificateKeyFile /etc/novacpx/ssl/novacpx.key + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + + SetHandler "proxy:unix:/run/php/php8.3-fpm.sock|fcgi://localhost/" + + Header always set X-NovaCPX-Portal "webmail" + +WMAP +fi + +log "Roundcube webmail installed on port ${PORT_WEBMAIL}" + # ── Panel installation ──────────────────────────────────────────────────────── step "Installing NovaCPX Panel" mkdir -p "$WEB_ROOT" "$PANEL_DIR" @@ -402,6 +472,7 @@ secret = ${SECRET_KEY} port_user = ${PORT_USER} port_reseller = ${PORT_RESELLER} port_admin = ${PORT_ADMIN} +port_webmail = ${PORT_WEBMAIL} webroot = ${WEB_ROOT} version = ${NOVACPX_VERSION} @@ -435,6 +506,7 @@ ufw allow 443/tcp >> "$LOG" 2>&1 # HTTPS ufw allow ${PORT_USER}/tcp >> "$LOG" 2>&1 # NovaCPX user panel ufw allow ${PORT_RESELLER}/tcp >> "$LOG" 2>&1 # NovaCPX reseller panel ufw allow ${PORT_ADMIN}/tcp >> "$LOG" 2>&1 # NovaCPX admin panel +ufw allow ${PORT_WEBMAIL}/tcp >> "$LOG" 2>&1 # Roundcube webmail ufw allow 21/tcp >> "$LOG" 2>&1 # FTP ufw allow 20/tcp >> "$LOG" 2>&1 # FTP data ufw allow 25/tcp >> "$LOG" 2>&1 # SMTP @@ -477,6 +549,12 @@ enabled = true port = ${PORT_ADMIN} logpath = /var/log/novacpx/access.log maxretry = 5 + +[novacpx-webmail] +enabled = true +port = ${PORT_WEBMAIL} +logpath = /var/log/novacpx/access.log +maxretry = 10 F2B systemctl enable fail2ban >> "$LOG" 2>&1 systemctl restart fail2ban >> "$LOG" 2>&1 @@ -515,6 +593,7 @@ cat <require('admin', 'reseller'); +$db = DB::getInstance(); +$body = json_decode(file_get_contents('php://input'), true) ?? []; +$user = Auth::getInstance()->user(); + +require_once NOVACPX_LIB . '/AccountManager.php'; +require_once NOVACPX_LIB . '/VhostManager.php'; +require_once NOVACPX_LIB . '/DNSManager.php'; +require_once NOVACPX_LIB . '/PHPManager.php'; + +// Resellers can only see their own accounts +$ownerId = $user['role'] === 'reseller' ? $user['uid'] : null; +$ownerClause = $ownerId ? "AND u.reseller_id = {$ownerId}" : ''; + +match ($action) { + + 'list' => (function() use ($db, $ownerClause) { + $page = max(1, (int)($_GET['page'] ?? 1)); + $perPage = min(100, (int)($_GET['per_page'] ?? 25)); + $search = $_GET['search'] ?? ''; + $status = $_GET['status'] ?? ''; + $offset = ($page - 1) * $perPage; + + $where = "WHERE 1=1 $ownerClause"; + $params = []; + if ($search) { $where .= " AND (a.domain LIKE ? OR a.username LIKE ?)"; $params[] = "%$search%"; $params[] = "%$search%"; } + if ($status) { $where .= " AND a.status = ?"; $params[] = $status; } + + $total = $db->fetchOne("SELECT COUNT(*) c FROM accounts a JOIN users u ON u.id = a.user_id $where", $params)['c']; + $rows = $db->fetchAll( + "SELECT a.*, u.email, u.role, + p.name as package_name, + (SELECT COUNT(*) FROM domains WHERE account_id = a.id) as domain_count, + (SELECT COUNT(*) FROM email_accounts WHERE account_id = a.id) as email_count, + (SELECT COUNT(*) FROM databases WHERE account_id = a.id) as db_count + FROM accounts a + JOIN users u ON u.id = a.user_id + LEFT JOIN packages p ON p.id = a.package_id + $where ORDER BY a.created_at DESC LIMIT ? OFFSET ?", + [...$params, $perPage, $offset] + ); + Response::paginate($rows, (int)$total, $page, $perPage); + })(), + + 'get' => (function() use ($db, $ownerClause) { + $id = (int)($_GET['id'] ?? 0); + $acct = $db->fetchOne( + "SELECT a.*, u.email, p.name as package_name FROM accounts a + JOIN users u ON u.id = a.user_id + LEFT JOIN packages p ON p.id = a.package_id + WHERE a.id = ? $ownerClause", + [$id] + ); + if (!$acct) Response::error("Account not found", 404); + $acct['domains'] = $db->fetchAll("SELECT * FROM domains WHERE account_id = ?", [$id]); + $acct['disk_used'] = AccountManager::getDiskUsage($acct['home_dir']); + Response::success($acct); + })(), + + 'create' => (function() use ($db, $body, $user) { + $required = ['username','domain','email','password']; + foreach ($required as $f) { if (empty($body[$f])) Response::error("$f is required"); } + + // Create user account + $userId = (int)$db->insert( + "INSERT INTO users (username, password, email, role, status, reseller_id) VALUES (?,?,?,?,?,?)", + [ + $body['username'], + password_hash($body['password'], PASSWORD_BCRYPT), + $body['email'], + 'user', + 'active', + $user['role'] === 'reseller' ? $user['uid'] : null, + ] + ); + $body['user_id'] = $userId; + + $result = AccountManager::create($body); + audit('account.create', $body['domain'], $result); + Response::success($result, 'Account created successfully'); + })(), + + 'suspend' => (function() use ($db, $body, $ownerClause) { + $id = (int)($body['id'] ?? 0); + $acct = $db->fetchOne("SELECT a.id FROM accounts a JOIN users u ON u.id = a.user_id WHERE a.id = ? $ownerClause", [$id]); + if (!$acct) Response::error("Account not found", 404); + AccountManager::suspend($id, $body['reason'] ?? ''); + audit('account.suspend', "account:$id"); + Response::success(null, 'Account suspended'); + })(), + + 'unsuspend' => (function() use ($db, $body, $ownerClause) { + $id = (int)($body['id'] ?? 0); + AccountManager::unsuspend($id); + audit('account.unsuspend', "account:$id"); + Response::success(null, 'Account unsuspended'); + })(), + + 'terminate' => (function() use ($db, $body, $user) { + Auth::getInstance()->require('admin'); + $id = (int)($body['id'] ?? 0); + AccountManager::terminate($id); + audit('account.terminate', "account:$id"); + Response::success(null, 'Account terminated'); + })(), + + 'change-password' => (function() use ($db, $body) { + $id = (int)($body['id'] ?? 0); + $pass = $body['password'] ?? ''; + if (strlen($pass) < 8) Response::error("Password must be at least 8 characters"); + $acct = $db->fetchOne("SELECT user_id FROM accounts WHERE id = ?", [$id]); + if (!$acct) Response::error("Account not found", 404); + $db->execute("UPDATE users SET password = ? WHERE id = ?", [password_hash($pass, PASSWORD_BCRYPT), $acct['user_id']]); + // Also update system user password + shell_exec("echo " . escapeshellarg("{$id}:{$pass}") . " | chpasswd 2>/dev/null"); + audit('account.change-password', "account:$id"); + Response::success(null, 'Password changed'); + })(), + + 'usage' => (function() use ($db) { + $id = (int)($_GET['id'] ?? 0); + $acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$id]); + if (!$acct) Response::error("Account not found", 404); + $pkg = $acct['package_id'] ? $db->fetchOne("SELECT * FROM packages WHERE id = ?", [$acct['package_id']]) : null; + Response::success([ + 'disk_used_mb' => AccountManager::getDiskUsage($acct['home_dir']), + 'disk_limit_mb' => $pkg['disk_mb'] ?? 0, + 'email_count' => $db->fetchOne("SELECT COUNT(*) c FROM email_accounts WHERE account_id = ?", [$id])['c'], + 'email_limit' => $pkg['max_email'] ?? 0, + 'db_count' => $db->fetchOne("SELECT COUNT(*) c FROM databases WHERE account_id = ?", [$id])['c'], + 'db_limit' => $pkg['max_databases'] ?? 0, + 'domain_count' => $db->fetchOne("SELECT COUNT(*) c FROM domains WHERE account_id = ?", [$id])['c'], + 'domain_limit' => $pkg['max_domains'] ?? 0, + 'ftp_count' => $db->fetchOne("SELECT COUNT(*) c FROM ftp_accounts WHERE account_id = ?", [$id])['c'], + 'ftp_limit' => $pkg['max_ftp'] ?? 0, + ]); + })(), + + default => Response::error("Unknown accounts action: $action", 404), +}; diff --git a/panel/api/endpoints/cron.php b/panel/api/endpoints/cron.php new file mode 100644 index 0000000..aca47e6 --- /dev/null +++ b/panel/api/endpoints/cron.php @@ -0,0 +1,69 @@ +user(); +$accountId = $user['role'] === 'user' + ? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0) + : (int)($body['account_id'] ?? $_GET['account_id'] ?? 0); + +function writeCrontab(int $accountId, $db): void { + $acct = $db->fetchOne("SELECT username FROM accounts WHERE id = ?", [$accountId]); + if (!$acct) return; + $jobs = $db->fetchAll("SELECT * FROM cron_jobs WHERE account_id = ? AND is_active = 1", [$accountId]); + $cron = "# NovaCPX cron jobs for {$acct['username']}\n"; + foreach ($jobs as $j) { + $cron .= "{$j['minute']} {$j['hour']} {$j['day']} {$j['month']} {$j['weekday']} {$acct['username']} {$j['command']}\n"; + } + file_put_contents("/etc/cron.d/novacpx-{$acct['username']}", $cron); +} + +match ($action) { + 'list' => (function() use ($db, $accountId) { + Response::success($db->fetchAll("SELECT * FROM cron_jobs WHERE account_id = ? ORDER BY id", [$accountId])); + })(), + + 'create' => (function() use ($db, $body, $accountId) { + $cmd = trim($body['command'] ?? ''); + if (!$cmd) Response::error("command required"); + // Validate cron schedule fields + $fields = ['minute','hour','day','month','weekday']; + foreach ($fields as $f) { if (empty($body[$f])) $body[$f] = '*'; } + + $id = (int)$db->insert( + "INSERT INTO cron_jobs (account_id, command, minute, hour, day, month, weekday) VALUES (?,?,?,?,?,?,?)", + [$accountId, $cmd, $body['minute'], $body['hour'], $body['day'], $body['month'], $body['weekday']] + ); + writeCrontab($accountId, $db); + audit('cron.create', $cmd); + Response::success(['id' => $id], 'Cron job created'); + })(), + + 'update' => (function() use ($db, $body, $accountId) { + $id = (int)($body['id'] ?? 0); + $db->execute( + "UPDATE cron_jobs SET command=?, minute=?, hour=?, day=?, month=?, weekday=?, is_active=? WHERE id=? AND account_id=?", + [$body['command'], $body['minute'] ?? '*', $body['hour'] ?? '*', $body['day'] ?? '*', + $body['month'] ?? '*', $body['weekday'] ?? '*', (int)($body['is_active'] ?? 1), $id, $accountId] + ); + writeCrontab($accountId, $db); + Response::success(null, 'Cron job updated'); + })(), + + 'delete' => (function() use ($db, $body, $accountId) { + $id = (int)($body['id'] ?? 0); + $db->execute("DELETE FROM cron_jobs WHERE id = ? AND account_id = ?", [$id, $accountId]); + writeCrontab($accountId, $db); + audit('cron.delete', "job:$id"); + Response::success(null, 'Cron job deleted'); + })(), + + 'toggle' => (function() use ($db, $body, $accountId) { + $id = (int)($body['id'] ?? 0); + $db->execute("UPDATE cron_jobs SET is_active = NOT is_active WHERE id = ? AND account_id = ?", [$id, $accountId]); + writeCrontab($accountId, $db); + Response::success(null, 'Cron job toggled'); + })(), + + default => Response::error("Unknown cron action: $action", 404), +}; diff --git a/panel/api/endpoints/databases.php b/panel/api/endpoints/databases.php new file mode 100644 index 0000000..7acf811 --- /dev/null +++ b/panel/api/endpoints/databases.php @@ -0,0 +1,60 @@ +user(); +$accountId = $user['role'] === 'user' + ? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0) + : (int)($body['account_id'] ?? $_GET['account_id'] ?? 0); + +match ($action) { + + 'list' => (function() use ($db, $accountId) { + if (!$accountId) Response::error("account_id required"); + $rows = $db->fetchAll("SELECT id, db_name, db_user, db_type, size_mb, created_at FROM databases WHERE account_id = ?", [$accountId]); + foreach ($rows as &$r) { $r['size_mb'] = DatabaseManager::getSize($r['db_name'], $r['db_type']); } + Response::success($rows); + })(), + + 'create' => (function() use ($db, $body, $accountId) { + if (!$accountId) Response::error("account_id required"); + $type = $body['type'] ?? 'mysql'; + $dbName = trim($body['db_name'] ?? ''); + $dbUser = trim($body['db_user'] ?? $dbName . '_user'); + $dbPass = $body['db_pass'] ?? bin2hex(random_bytes(8)); + if (!$dbName) Response::error("db_name required"); + + // Prefix with account username to avoid conflicts + $acct = $db->fetchOne("SELECT username FROM accounts WHERE id = ?", [$accountId]); + $prefix = $acct['username'] . '_'; + if (!str_starts_with($dbName, $prefix)) $dbName = $prefix . $dbName; + if (!str_starts_with($dbUser, $prefix)) $dbUser = $prefix . $dbUser; + + $id = $type === 'postgresql' + ? DatabaseManager::createPostgres($accountId, $dbName, $dbUser, $dbPass) + : DatabaseManager::createMySQL($accountId, $dbName, $dbUser, $dbPass); + + audit('database.create', $dbName, ['type' => $type]); + Response::success(['id' => $id, 'db_name' => $dbName, 'db_user' => $dbUser, 'db_pass' => $dbPass], 'Database created'); + })(), + + 'drop' => (function() use ($db, $body) { + $id = (int)($body['id'] ?? 0); + $dbe = $db->fetchOne("SELECT db_name, db_user, db_type FROM databases WHERE id = ?", [$id]); + if (!$dbe) Response::error("Database not found", 404); + DatabaseManager::drop($dbe['db_name'], $dbe['db_user'], $dbe['db_type']); + audit('database.drop', $dbe['db_name']); + Response::success(null, 'Database deleted'); + })(), + + 'change-password' => (function() use ($body) { + $id = (int)($body['id'] ?? 0); + $pass = $body['password'] ?? ''; + if (strlen($pass) < 8) Response::error("Password must be at least 8 characters"); + DatabaseManager::changePassword($id, $pass); + Response::success(null, 'Database password updated'); + })(), + + default => Response::error("Unknown databases action: $action", 404), +}; diff --git a/panel/api/endpoints/dns.php b/panel/api/endpoints/dns.php new file mode 100644 index 0000000..375e524 --- /dev/null +++ b/panel/api/endpoints/dns.php @@ -0,0 +1,101 @@ +user(); +$accountId = null; +if ($user['role'] === 'user') { + $acct = $db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']]); + $accountId = $acct ? (int)$acct['id'] : null; +} else { + $accountId = (int)($_GET['account_id'] ?? $body['account_id'] ?? 0) ?: null; +} + +match ($action) { + + 'zones' => (function() use ($db, $accountId, $user) { + $where = $accountId ? "WHERE account_id = $accountId" : ($user['role'] !== 'admin' ? "WHERE 1=0" : ''); + $rows = $db->fetchAll("SELECT z.*, (SELECT COUNT(*) FROM dns_records WHERE zone_id = z.id) as record_count FROM dns_zones z $where ORDER BY z.domain"); + Response::success($rows); + })(), + + 'records' => (function() use ($db, $body) { + $zoneId = (int)($_GET['zone_id'] ?? 0); + if (!$zoneId) Response::error("zone_id required"); + $records = $db->fetchAll("SELECT * FROM dns_records WHERE zone_id = ? ORDER BY type, name", [$zoneId]); + $zone = $db->fetchOne("SELECT * FROM dns_zones WHERE id = ?", [$zoneId]); + Response::success(['zone' => $zone, 'records' => $records]); + })(), + + 'add-record' => (function() use ($db, $body, $accountId) { + $zoneId = (int)($body['zone_id'] ?? 0); + $name = trim($body['name'] ?? '@'); + $type = strtoupper($body['type'] ?? 'A'); + $content = trim($body['content'] ?? ''); + $ttl = (int)($body['ttl'] ?? 3600); + $priority = isset($body['priority']) ? (int)$body['priority'] : null; + + if (!$content) Response::error("Record content required"); + $allowed = ['A','AAAA','CNAME','MX','TXT','SRV','NS','CAA','DKIM','SPF','DMARC']; + if (!in_array($type, $allowed)) Response::error("Invalid record type"); + + // Verify zone belongs to this account + $zone = $db->fetchOne("SELECT id FROM dns_zones WHERE id = ?" . ($accountId ? " AND account_id = $accountId" : ''), [$zoneId]); + if (!$zone) Response::error("Zone not found", 404); + + $id = DNSManager::addRecord($zoneId, $name, $type, $content, $ttl, $priority); + audit('dns.add-record', "{$type}:{$name}", ['zone_id' => $zoneId]); + Response::success(['id' => $id], 'Record added'); + })(), + + 'update-record' => (function() use ($db, $body) { + $id = (int)($body['id'] ?? 0); + DNSManager::updateRecord($id, $body); + audit('dns.update-record', "record:$id"); + Response::success(null, 'Record updated'); + })(), + + 'delete-record' => (function() use ($db, $body) { + $id = (int)($body['id'] ?? 0); + DNSManager::deleteRecord($id); + audit('dns.delete-record', "record:$id"); + Response::success(null, 'Record deleted'); + })(), + + 'create-zone' => (function() use ($db, $body, $accountId, $user) { + Auth::getInstance()->require('admin', 'reseller'); + $domain = strtolower(trim($body['domain'] ?? '')); + $acctId = (int)($body['account_id'] ?? $accountId ?? 0); + if (!$domain) Response::error("Domain required"); + if (!$acctId) Response::error("account_id required"); + DNSManager::createZone($acctId, $domain); + audit('dns.create-zone', $domain); + Response::success(null, "DNS zone created for $domain"); + })(), + + 'delete-zone' => (function() use ($db, $body, $user) { + Auth::getInstance()->require('admin'); + $domain = trim($body['domain'] ?? ''); + if (!$domain) Response::error("Domain required"); + DNSManager::removeZone($domain); + audit('dns.delete-zone', $domain); + Response::success(null, "DNS zone removed for $domain"); + })(), + + 'check-propagation' => (function() use ($db) { + $domain = trim($_GET['domain'] ?? ''); + if (!$domain) Response::error("Domain required"); + $results = []; + $nameservers = ['8.8.8.8', '1.1.1.1', '9.9.9.9']; + foreach ($nameservers as $ns) { + $out = trim(shell_exec("dig +short A " . escapeshellarg($domain) . " @{$ns} 2>/dev/null") ?: ''); + $results[$ns] = $out ?: 'no answer'; + } + Response::success(['domain' => $domain, 'results' => $results]); + })(), + + default => Response::error("Unknown dns action: $action", 404), +}; diff --git a/panel/api/endpoints/domains.php b/panel/api/endpoints/domains.php new file mode 100644 index 0000000..c5e0d8d --- /dev/null +++ b/panel/api/endpoints/domains.php @@ -0,0 +1,123 @@ +user(); +$accountId = $user['role'] === 'user' + ? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0) + : (int)($body['account_id'] ?? $_GET['account_id'] ?? 0); + +if (!$accountId) Response::error("account_id required"); +$acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$accountId]); +if (!$acct) Response::error("Account not found", 404); + +match ($action) { + 'list' => (function() use ($db, $accountId) { + $rows = $db->fetchAll("SELECT * FROM domains WHERE account_id = ? ORDER BY is_primary DESC, domain", [$accountId]); + Response::success($rows); + })(), + + 'add-addon' => (function() use ($db, $body, $accountId, $acct) { + $domain = strtolower(trim($body['domain'] ?? '')); + if (!$domain) Response::error("domain required"); + if (!preg_match('/^[a-z0-9][a-z0-9\-\.]+\.[a-z]{2,}$/', $domain)) Response::error("Invalid domain name"); + + $exists = $db->fetchOne("SELECT id FROM domains WHERE domain = ?", [$domain]); + if ($exists) Response::error("Domain already exists"); + + $db->execute( + "INSERT INTO domains (account_id, domain, type, doc_root, created_at) VALUES (?,?,?,?,NOW())", + [$accountId, $domain, 'addon', $acct['home_dir'] . '/public_html/' . $domain] + ); + $docRoot = $acct['home_dir'] . '/public_html/' . $domain; + @mkdir($docRoot, 0755, true); + file_put_contents("$docRoot/index.html", "

$domain is ready!

Upload your website files.

"); + + VhostManager::create([ + 'domain' => $domain, + 'username' => $acct['username'], + 'home_dir' => $acct['home_dir'], + 'doc_root' => $docRoot, + 'php_ver' => $acct['php_version'] ?? PHP_DEFAULT, + ]); + DNSManager::createZone($domain, gethostbyname(gethostname())); + audit('domains.add-addon', $domain); + Response::success(null, "Addon domain $domain added"); + })(), + + 'add-subdomain' => (function() use ($db, $body, $accountId, $acct) { + $sub = strtolower(trim($body['subdomain'] ?? '')); + $parent = strtolower(trim($body['parent'] ?? $acct['domain'])); + $full = "$sub.$parent"; + if (!$sub) Response::error("subdomain required"); + + $docRoot = $acct['home_dir'] . '/public_html/' . $full; + @mkdir($docRoot, 0755, true); + file_put_contents("$docRoot/index.html", "

$full

"); + + $db->execute( + "INSERT INTO domains (account_id, domain, type, doc_root, created_at) VALUES (?,?,?,?,NOW())", + [$accountId, $full, 'subdomain', $docRoot] + ); + VhostManager::createSubdomain($full, $acct['username'], $docRoot, $acct['php_version'] ?? PHP_DEFAULT); + DNSManager::addRecord($parent, $sub, 'A', gethostbyname(gethostname())); + audit('domains.add-subdomain', $full); + Response::success(null, "Subdomain $full created"); + })(), + + 'add-alias' => (function() use ($db, $body, $accountId, $acct) { + $alias = strtolower(trim($body['domain'] ?? '')); + $target = strtolower(trim($body['target'] ?? $acct['domain'])); + if (!$alias) Response::error("domain required"); + + $db->execute( + "INSERT INTO domains (account_id, domain, type, doc_root, created_at) VALUES (?,?,?,?,NOW())", + [$accountId, $alias, 'alias', $acct['home_dir'] . '/public_html'] + ); + // Create vhost that serves same doc root as primary + VhostManager::create([ + 'domain' => $alias, + 'username' => $acct['username'], + 'home_dir' => $acct['home_dir'], + 'doc_root' => $acct['home_dir'] . '/public_html', + 'php_ver' => $acct['php_version'] ?? PHP_DEFAULT, + ]); + DNSManager::createZone($alias, gethostbyname(gethostname())); + audit('domains.add-alias', $alias); + Response::success(null, "Domain alias $alias added"); + })(), + + 'redirect' => (function() use ($db, $body, $accountId) { + $id = (int)($body['id'] ?? 0); + $url = trim($body['redirect_url'] ?? ''); + $code = in_array((int)($body['code'] ?? 301), [301, 302]) ? (int)$body['code'] : 301; + if (!$url) Response::error("redirect_url required"); + $db->execute("UPDATE domains SET redirect_url=?, redirect_code=? WHERE id=? AND account_id=?", [$url, $code, $id, $accountId]); + // Update vhost to add Redirect directive + $dom = $db->fetchOne("SELECT * FROM domains WHERE id = ?", [$id]); + if ($dom) { + VhostManager::setRedirect($dom['domain'], $url, $code); + } + Response::success(null, 'Redirect configured'); + })(), + + 'remove' => (function() use ($db, $body, $accountId) { + $id = (int)($body['id'] ?? 0); + $dom = $db->fetchOne("SELECT * FROM domains WHERE id = ? AND account_id = ?", [$id, $accountId]); + if (!$dom) Response::error("Domain not found", 404); + if ($dom['is_primary']) Response::error("Cannot remove primary domain"); + + VhostManager::remove($dom['domain']); + if ($dom['type'] !== 'subdomain') DNSManager::removeZone($dom['domain']); + $db->execute("DELETE FROM domains WHERE id = ?", [$id]); + audit('domains.remove', $dom['domain']); + Response::success(null, "Domain {$dom['domain']} removed"); + })(), + + default => Response::error("Unknown domains action: $action", 404), +}; diff --git a/panel/api/endpoints/email.php b/panel/api/endpoints/email.php new file mode 100644 index 0000000..06e5195 --- /dev/null +++ b/panel/api/endpoints/email.php @@ -0,0 +1,94 @@ +user(); +$accountId = self_account_id($db, $user); + +function self_account_id($db, $user): ?int { + if ($user['role'] === 'user') { + $a = $db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']]); + return $a ? (int)$a['id'] : null; + } + return (int)(request_param('account_id') ?? 0) ?: null; +} +function request_param(string $k): mixed { return $_GET[$k] ?? (json_decode(file_get_contents('php://input'), true) ?? [])[$k] ?? null; } + +match ($action) { + + 'list' => (function() use ($db, $accountId) { + if (!$accountId) Response::error("account_id required"); + $rows = $db->fetchAll("SELECT id, email, quota_mb, used_mb, status, created_at FROM email_accounts WHERE account_id = ? ORDER BY email", [$accountId]); + Response::success($rows); + })(), + + 'create' => (function() use ($db, $body, $accountId) { + $email = strtolower(trim($body['email'] ?? '')); + $password = $body['password'] ?? ''; + $quota = (int)($body['quota_mb'] ?? 500); + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) Response::error("Invalid email address"); + if (strlen($password) < 6) Response::error("Password must be at least 6 characters"); + if (!$accountId) Response::error("account_id required"); + $id = EmailManager::createAccount($accountId, $email, $password, $quota); + audit('email.create', $email); + Response::success(['id' => $id], "Email account created: $email"); + })(), + + 'delete' => (function() use ($db, $body) { + $id = (int)($body['id'] ?? 0); + EmailManager::deleteAccount($id); + audit('email.delete', "email:$id"); + Response::success(null, 'Email account deleted'); + })(), + + 'change-password' => (function() use ($body) { + $id = (int)($body['id'] ?? 0); + $pass = $body['password'] ?? ''; + if (strlen($pass) < 6) Response::error("Password too short"); + EmailManager::changePassword($id, $pass); + Response::success(null, 'Password updated'); + })(), + + 'suspend' => (function() use ($body) { + EmailManager::suspend((int)($body['id'] ?? 0)); + Response::success(null, 'Email account suspended'); + })(), + + // ── Forwarders ─────────────────────────────────────────────────────────── + 'forwarders' => (function() use ($db, $accountId) { + if (!$accountId) Response::error("account_id required"); + Response::success($db->fetchAll("SELECT * FROM email_forwarders WHERE account_id = ?", [$accountId])); + })(), + + 'add-forwarder' => (function() use ($db, $body, $accountId) { + $source = strtolower(trim($body['source'] ?? '')); + $dest = strtolower(trim($body['destination'] ?? '')); + if (!filter_var($dest, FILTER_VALIDATE_EMAIL)) Response::error("Invalid destination email"); + $id = EmailManager::addForwarder($accountId, $source, $dest); + audit('email.add-forwarder', "{$source}→{$dest}"); + Response::success(['id' => $id], 'Forwarder added'); + })(), + + 'delete-forwarder' => (function() use ($body) { + EmailManager::removeForwarder((int)($body['id'] ?? 0)); + Response::success(null, 'Forwarder removed'); + })(), + + // ── Autoresponders ─────────────────────────────────────────────────────── + 'autoresponders' => (function() use ($db, $accountId) { + Response::success($db->fetchAll("SELECT * FROM email_autoresponders WHERE account_id = ?", [$accountId ?? 0])); + })(), + + 'add-autoresponder' => (function() use ($body, $accountId) { + $id = EmailManager::addAutoresponder($accountId, $body['email'] ?? '', $body['subject'] ?? '', $body['body'] ?? ''); + Response::success(['id' => $id], 'Autoresponder created'); + })(), + + 'delete-autoresponder' => (function() use ($db, $body) { + $db->execute("DELETE FROM email_autoresponders WHERE id = ?", [(int)($body['id'] ?? 0)]); + Response::success(null, 'Autoresponder deleted'); + })(), + + default => Response::error("Unknown email action: $action", 404), +}; diff --git a/panel/api/endpoints/files.php b/panel/api/endpoints/files.php new file mode 100644 index 0000000..3a05119 --- /dev/null +++ b/panel/api/endpoints/files.php @@ -0,0 +1,140 @@ +user(); +$acct = $db->fetchOne("SELECT * FROM accounts WHERE user_id = ?", [$user['uid']]); +if (!$acct && $user['role'] !== 'admin') Response::error("No hosting account found", 404); + +$baseDir = $acct ? realpath($acct['home_dir']) : '/'; + +function safe_path(string $base, string $rel): string { + $full = realpath($base . '/' . ltrim($rel, '/')); + if (!$full || !str_starts_with($full, $base)) { + throw new RuntimeException("Path outside account directory"); + } + return $full; +} + +function fmt_size(int $bytes): string { + if ($bytes >= 1073741824) return round($bytes/1073741824, 1) . 'GB'; + if ($bytes >= 1048576) return round($bytes/1048576, 1) . 'MB'; + if ($bytes >= 1024) return round($bytes/1024, 1) . 'KB'; + return $bytes . 'B'; +} + +match ($action) { + 'list' => (function() use ($baseDir, $body) { + $rel = $body['path'] ?? $_GET['path'] ?? '/public_html'; + $path = safe_path($baseDir, $rel); + if (!is_dir($path)) Response::error("Not a directory"); + + $items = []; + foreach (new DirectoryIterator($path) as $f) { + if ($f->isDot()) continue; + $items[] = [ + 'name' => $f->getFilename(), + 'type' => $f->isDir() ? 'dir' : 'file', + 'size' => $f->isFile() ? fmt_size($f->getSize()) : null, + 'size_raw' => $f->isFile() ? $f->getSize() : 0, + 'perms' => substr(sprintf('%o', $f->getPerms()), -4), + 'modified' => date('Y-m-d H:i', $f->getMTime()), + 'path' => str_replace($baseDir, '', $path . '/' . $f->getFilename()), + ]; + } + usort($items, fn($a,$b) => ($a['type'] === 'dir' ? -1 : 1) - ($b['type'] === 'dir' ? -1 : 1) ?: strcmp($a['name'], $b['name'])); + Response::success(['path' => $rel, 'items' => $items]); + })(), + + 'read' => (function() use ($baseDir, $body) { + $path = safe_path($baseDir, $body['path'] ?? $_GET['path'] ?? ''); + if (!is_file($path)) Response::error("Not a file"); + if (filesize($path) > 2 * 1024 * 1024) Response::error("File too large to edit (>2MB)"); + Response::success(['content' => file_get_contents($path), 'path' => $body['path'] ?? '']); + })(), + + 'write' => (function() use ($baseDir, $body) { + $path = safe_path($baseDir, $body['path'] ?? ''); + $content = $body['content'] ?? ''; + // PHP syntax check for .php files + if (str_ends_with($path, '.php')) { + $tmp = tempnam(sys_get_temp_dir(), 'ncpx_'); + file_put_contents($tmp, $content); + $result = shell_exec("php8.3 -l " . escapeshellarg($tmp) . " 2>&1"); + unlink($tmp); + if (!str_contains($result, 'No syntax errors')) Response::error("PHP syntax error: $result"); + } + file_put_contents($path, $content); + audit('files.write', $body['path'] ?? ''); + Response::success(null, 'File saved'); + })(), + + 'mkdir' => (function() use ($baseDir, $body) { + $path = $baseDir . '/' . ltrim($body['path'] ?? '', '/'); + // Don't use safe_path since dir may not exist yet + if (!str_starts_with(realpath(dirname($path)) ?: '', $baseDir)) Response::error("Invalid path"); + if (!mkdir($path, 0755, true)) Response::error("Could not create directory"); + Response::success(null, 'Directory created'); + })(), + + 'rename' => (function() use ($baseDir, $body) { + $from = safe_path($baseDir, $body['from'] ?? ''); + $to = $baseDir . '/' . ltrim($body['to'] ?? '', '/'); + if (!str_starts_with(realpath(dirname($to)) ?: $baseDir, $baseDir)) Response::error("Invalid destination"); + rename($from, $to); + Response::success(null, 'Renamed'); + })(), + + 'delete' => (function() use ($baseDir, $body) { + $path = safe_path($baseDir, $body['path'] ?? ''); + if (is_dir($path)) { + shell_exec("rm -rf " . escapeshellarg($path)); + } else { + unlink($path); + } + audit('files.delete', $body['path'] ?? ''); + Response::success(null, 'Deleted'); + })(), + + 'chmod' => (function() use ($baseDir, $body) { + $path = safe_path($baseDir, $body['path'] ?? ''); + $perms = octdec((string)($body['perms'] ?? '755')); + chmod($path, $perms); + Response::success(null, 'Permissions updated'); + })(), + + 'upload' => (function() use ($baseDir, $body) { + $dir = safe_path($baseDir, $body['path'] ?? '/public_html'); + if (empty($_FILES['file'])) Response::error("No file uploaded"); + $dest = $dir . '/' . basename($_FILES['file']['name']); + if (!str_starts_with(realpath(dirname($dest)) ?: $baseDir, $baseDir)) Response::error("Invalid destination"); + move_uploaded_file($_FILES['file']['tmp_name'], $dest); + Response::success(['name' => basename($dest)], 'File uploaded'); + })(), + + 'compress' => (function() use ($baseDir, $body) { + $paths = array_map(fn($p) => safe_path($baseDir, $p), (array)($body['paths'] ?? [])); + $dest = $baseDir . '/' . ltrim($body['dest'] ?? 'archive.zip', '/'); + $files = implode(' ', array_map('escapeshellarg', $paths)); + shell_exec("cd " . escapeshellarg($baseDir) . " && zip -r " . escapeshellarg($dest) . " $files 2>/dev/null"); + Response::success(null, 'Archive created'); + })(), + + 'extract' => (function() use ($baseDir, $body) { + $file = safe_path($baseDir, $body['path'] ?? ''); + $dest = safe_path($baseDir, $body['dest'] ?? dirname($body['path'] ?? '/')); + $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + match($ext) { + 'zip' => shell_exec("unzip -o " . escapeshellarg($file) . " -d " . escapeshellarg($dest)), + 'gz','tgz','tar' => shell_exec("tar xf " . escapeshellarg($file) . " -C " . escapeshellarg($dest)), + default => Response::error("Unsupported archive type"), + }; + Response::success(null, 'Extracted'); + })(), + + default => Response::error("Unknown files action: $action", 404), +}; diff --git a/panel/api/endpoints/ftp.php b/panel/api/endpoints/ftp.php new file mode 100644 index 0000000..27c1fe6 --- /dev/null +++ b/panel/api/endpoints/ftp.php @@ -0,0 +1,40 @@ +user(); +$accountId = $user['role'] === 'user' + ? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0) + : (int)($body['account_id'] ?? $_GET['account_id'] ?? 0); + +match ($action) { + 'list' => (function() use ($db, $accountId) { + Response::success($db->fetchAll("SELECT id, username, home_dir, quota_mb, status, created_at FROM ftp_accounts WHERE account_id = ?", [$accountId])); + })(), + + 'create' => (function() use ($db, $body, $accountId) { + if (!$accountId) Response::error("account_id required"); + $username = trim($body['username'] ?? ''); + $password = $body['password'] ?? bin2hex(random_bytes(6)); + $acct = $db->fetchOne("SELECT home_dir FROM accounts WHERE id = ?", [$accountId]); + $homeDir = $body['home_dir'] ?? ($acct['home_dir'] . '/public_html'); + if (!$username) Response::error("username required"); + $id = FTPManager::createAccount($accountId, $username, $password, $homeDir, (int)($body['quota_mb'] ?? 0)); + audit('ftp.create', $username); + Response::success(['id' => $id, 'password' => $password], 'FTP account created'); + })(), + + 'delete' => (function() use ($body) { + FTPManager::deleteAccount((int)($body['id'] ?? 0)); + audit('ftp.delete', "ftp:{$body['id']}"); + Response::success(null, 'FTP account deleted'); + })(), + + 'change-password' => (function() use ($body) { + FTPManager::changePassword((int)($body['id'] ?? 0), $body['password'] ?? ''); + Response::success(null, 'FTP password updated'); + })(), + + default => Response::error("Unknown ftp action: $action", 404), +}; diff --git a/panel/api/endpoints/packages.php b/panel/api/endpoints/packages.php new file mode 100644 index 0000000..eca4dbb --- /dev/null +++ b/panel/api/endpoints/packages.php @@ -0,0 +1,74 @@ +require('admin', 'reseller'); +$db = DB::getInstance(); +$body = json_decode(file_get_contents('php://input'), true) ?? []; +$user = Auth::getInstance()->user(); +$ownerFilter = $user['role'] === 'reseller' ? "AND (owner_id = {$user['uid']} OR owner_id IS NULL)" : ''; + +match ($action) { + 'list' => (function() use ($db, $ownerFilter) { + $rows = $db->fetchAll("SELECT p.*, (SELECT COUNT(*) FROM accounts WHERE package_id = p.id) as account_count FROM packages p WHERE 1=1 $ownerFilter ORDER BY p.name"); + Response::success($rows); + })(), + + 'get' => (function() use ($db) { + $id = (int)($_GET['id'] ?? 0); + $row = $db->fetchOne("SELECT * FROM packages WHERE id = ?", [$id]); + if (!$row) Response::error("Package not found", 404); + Response::success($row); + })(), + + 'create' => (function() use ($db, $body, $user) { + $name = trim($body['name'] ?? ''); + if (!$name) Response::error("Package name required"); + $id = (int)$db->insert( + "INSERT INTO packages (name, owner_id, disk_mb, bandwidth_mb, max_domains, max_subdomains, max_addon_domains, max_parked_domains, max_email, max_ftp, max_databases, php_version, ssl_enabled) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + [ + $name, + $user['role'] === 'reseller' ? $user['uid'] : null, + (int)($body['disk_mb'] ?? 1024), + (int)($body['bandwidth_mb'] ?? 10240), + (int)($body['max_domains'] ?? 1), + (int)($body['max_subdomains'] ?? 10), + (int)($body['max_addon_domains'] ?? 0), + (int)($body['max_parked_domains'] ?? 5), + (int)($body['max_email'] ?? 10), + (int)($body['max_ftp'] ?? 5), + (int)($body['max_databases'] ?? 5), + $body['php_version'] ?? '8.3', + (int)($body['ssl_enabled'] ?? 1), + ] + ); + audit('package.create', $name); + Response::success(['id' => $id], 'Package created'); + })(), + + 'update' => (function() use ($db, $body) { + $id = (int)($body['id'] ?? 0); + $db->execute( + "UPDATE packages SET name=?, disk_mb=?, bandwidth_mb=?, max_domains=?, max_subdomains=?, + max_addon_domains=?, max_parked_domains=?, max_email=?, max_ftp=?, max_databases=?, php_version=?, ssl_enabled=? WHERE id=?", + [ + $body['name'], $body['disk_mb'], $body['bandwidth_mb'], $body['max_domains'], + $body['max_subdomains'], $body['max_addon_domains'], $body['max_parked_domains'], + $body['max_email'], $body['max_ftp'], $body['max_databases'], + $body['php_version'] ?? '8.3', $body['ssl_enabled'] ?? 1, $id, + ] + ); + audit('package.update', "package:$id"); + Response::success(null, 'Package updated'); + })(), + + 'delete' => (function() use ($db, $body) { + Auth::getInstance()->require('admin'); + $id = (int)($body['id'] ?? 0); + $cnt = $db->fetchOne("SELECT COUNT(*) c FROM accounts WHERE package_id = ?", [$id])['c']; + if ($cnt > 0) Response::error("Cannot delete: $cnt accounts use this package"); + $db->execute("DELETE FROM packages WHERE id = ?", [$id]); + audit('package.delete', "package:$id"); + Response::success(null, 'Package deleted'); + })(), + + default => Response::error("Unknown packages action: $action", 404), +}; diff --git a/panel/api/endpoints/php.php b/panel/api/endpoints/php.php new file mode 100644 index 0000000..599b906 --- /dev/null +++ b/panel/api/endpoints/php.php @@ -0,0 +1,56 @@ +user(); +$accountId = $user['role'] === 'user' + ? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0) + : (int)($body['account_id'] ?? $_GET['account_id'] ?? 0); + +match ($action) { + 'config' => (function() use ($db, $accountId) { + $cfg = $db->fetchOne("SELECT * FROM php_configs WHERE account_id = ?", [$accountId]); + $acct = $db->fetchOne("SELECT php_version FROM accounts WHERE id = ?", [$accountId]); + Response::success([ + 'php_version' => $acct['php_version'] ?? PHP_DEFAULT, + 'memory_limit' => $cfg['memory_limit'] ?? '256M', + 'max_execution_time' => $cfg['max_execution_time'] ?? 30, + 'upload_max_filesize'=> $cfg['upload_max_filesize'] ?? '64M', + 'post_max_size' => $cfg['post_max_size'] ?? '64M', + 'display_errors' => (bool)($cfg['display_errors'] ?? false), + 'extensions' => json_decode($cfg['extensions'] ?? '[]', true) ?: [], + ]); + })(), + + 'versions' => (function() { + $versions = []; + foreach (['7.4','8.1','8.2','8.3'] as $v) { + $installed = file_exists("/usr/bin/php{$v}"); + $versions[] = ['version' => $v, 'installed' => $installed, 'is_default' => $v === PHP_DEFAULT]; + } + Response::success($versions); + })(), + + 'switch-version' => (function() use ($body, $accountId) { + $ver = $body['version'] ?? ''; + if (!in_array($ver, ['7.4','8.1','8.2','8.3'])) Response::error("Invalid PHP version"); + PHPManager::switchVersion($accountId, $ver); + audit('php.switch-version', "account:{$accountId} → php{$ver}"); + Response::success(null, "PHP version switched to $ver"); + })(), + + 'update-config' => (function() use ($body, $accountId) { + PHPManager::updateConfig($accountId, $body); + audit('php.update-config', "account:$accountId"); + Response::success(null, 'PHP configuration updated'); + })(), + + 'extensions' => (function() use ($db, $accountId) { + $acct = $db->fetchOne("SELECT php_version FROM accounts WHERE id = ?", [$accountId]); + $ver = $acct['php_version'] ?? PHP_DEFAULT; + Response::success(['version' => $ver, 'extensions' => PHPManager::listExtensions($ver)]); + })(), + + default => Response::error("Unknown php action: $action", 404), +}; diff --git a/panel/api/endpoints/server_setup.php b/panel/api/endpoints/server_setup.php new file mode 100644 index 0000000..4bc98bb --- /dev/null +++ b/panel/api/endpoints/server_setup.php @@ -0,0 +1,110 @@ +requireRole(['admin']); + +function getSetting(string $key, $db): ?string { + return $db->fetchOne("SELECT value FROM settings WHERE `key` = ?", [$key])['value'] ?? null; +} +function setSetting(string $key, string $value, $db): void { + $db->execute( + "INSERT INTO settings (`key`, value, updated_at) VALUES (?,?,NOW()) ON DUPLICATE KEY UPDATE value=?, updated_at=NOW()", + [$key, $value, $value] + ); +} + +match ($action) { + 'get' => (function() use ($db) { + $keys = ['hostname','contact_name','contact_email','contact_phone','company_name', + 'nameserver1','nameserver2','server_ip','ipv6_enabled','panel_theme', + 'panel_title','smtp_host','smtp_port','smtp_user','admin_email', + 'two_fa_required','registration_enabled','max_accounts','timezone']; + $data = []; + foreach ($keys as $k) { + $data[$k] = getSetting($k, $db) ?? ''; + } + // Live hostname + $data['system_hostname'] = trim(shell_exec('hostname -f') ?: ''); + Response::success($data); + })(), + + 'save' => (function() use ($body, $db) { + $allowed = ['contact_name','contact_email','contact_phone','company_name', + 'nameserver1','nameserver2','panel_theme','panel_title', + 'smtp_host','smtp_port','smtp_user','smtp_pass','admin_email', + 'two_fa_required','registration_enabled','max_accounts','timezone']; + foreach ($allowed as $k) { + if (isset($body[$k])) setSetting($k, (string)$body[$k], $db); + } + audit('server_setup.save', 'settings updated'); + Response::success(null, 'Settings saved'); + })(), + + 'set-hostname' => (function() use ($body, $db) { + $host = trim($body['hostname'] ?? ''); + if (!$host || !preg_match('/^[a-z0-9][a-z0-9\-\.]+$/', $host)) Response::error("Invalid hostname"); + shell_exec("hostnamectl set-hostname " . escapeshellarg($host)); + // Update /etc/hosts + $hosts = file_get_contents('/etc/hosts'); + $ip = trim(shell_exec("hostname -I | awk '{print $1}'") ?: '127.0.0.1'); + $hosts = preg_replace('/^127\.0\.1\.1.*/m', "127.0.1.1\t$host", $hosts); + if (!str_contains($hosts, '127.0.1.1')) $hosts .= "\n127.0.1.1\t$host\n"; + file_put_contents('/etc/hosts', $hosts); + setSetting('hostname', $host, $db); + audit('server_setup.hostname', $host); + Response::success(['hostname' => $host], 'Hostname updated'); + })(), + + 'nameservers' => (function() use ($body, $db) { + $ns1 = trim($body['ns1'] ?? ''); + $ns2 = trim($body['ns2'] ?? ''); + if (!$ns1) Response::error("ns1 required"); + setSetting('nameserver1', $ns1, $db); + if ($ns2) setSetting('nameserver2', $ns2, $db); + // Update BIND options + $named = file_get_contents('/etc/bind/named.conf.options') ?: ''; + if (str_contains($named, 'forwarders')) { + $named = preg_replace('/forwarders\s*\{[^}]*\}/', "forwarders { 8.8.8.8; 1.1.1.1; }", $named); + file_put_contents('/etc/bind/named.conf.options', $named); + shell_exec('systemctl reload bind9 2>/dev/null || systemctl reload named 2>/dev/null'); + } + audit('server_setup.nameservers', "$ns1 / $ns2"); + Response::success(null, 'Nameservers updated'); + })(), + + 'smtp-test' => (function() use ($body, $db) { + $to = $body['to'] ?? getSetting('admin_email', $db); + if (!$to) Response::error("email address required"); + $host = getSetting('smtp_host', $db) ?: 'localhost'; + $port = (int)(getSetting('smtp_port', $db) ?: 587); + $user = getSetting('smtp_user', $db) ?: ''; + $pass = getSetting('smtp_pass', $db) ?: ''; + // Simple test using PHP mail() for localhost + $sent = mail($to, 'NovaCPX SMTP Test', 'SMTP is working correctly from NovaCPX.'); + Response::success(['sent' => $sent], $sent ? 'Test email sent' : 'mail() returned false — check SMTP config'); + })(), + + 'system-info' => (function() { + $os = trim(shell_exec("lsb_release -d 2>/dev/null | cut -d: -f2 | xargs") ?: php_uname('s')); + $kernel = trim(shell_exec("uname -r") ?: ''); + $uptime = trim(shell_exec("uptime -p") ?: ''); + $cpu = trim(shell_exec("grep 'model name' /proc/cpuinfo | head -1 | cut -d: -f2 | xargs") ?: ''); + $cpuCores = (int)trim(shell_exec("nproc") ?: 1); + $memTotal = (int)trim(shell_exec("grep MemTotal /proc/meminfo | awk '{print $2}'") ?: 0); + Response::success([ + 'os' => $os, + 'kernel' => $kernel, + 'uptime' => $uptime, + 'cpu' => $cpu, + 'cores' => $cpuCores, + 'ram_gb' => round($memTotal / 1048576, 1), + 'hostname' => trim(shell_exec('hostname -f') ?: ''), + 'ip' => trim(shell_exec("hostname -I | awk '{print $1}'") ?: ''), + ]); + })(), + + default => Response::error("Unknown server_setup action: $action", 404), +}; diff --git a/panel/api/endpoints/ssl.php b/panel/api/endpoints/ssl.php new file mode 100644 index 0000000..c967437 --- /dev/null +++ b/panel/api/endpoints/ssl.php @@ -0,0 +1,62 @@ +user(); +$accountId = $user['role'] === 'user' + ? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0) + : (int)($body['account_id'] ?? $_GET['account_id'] ?? 0); + +match ($action) { + 'list' => (function() use ($db, $accountId) { + $rows = $db->fetchAll("SELECT id, domain, type, issued_at, expires_at, auto_renew, status FROM ssl_certs WHERE account_id = ? ORDER BY domain", [$accountId]); + foreach ($rows as &$r) { + $r['days_remaining'] = $r['expires_at'] ? (int)floor((strtotime($r['expires_at']) - time()) / 86400) : null; + } + Response::success($rows); + })(), + + 'issue' => (function() use ($body, $accountId) { + $domain = trim($body['domain'] ?? ''); + $email = trim($body['email'] ?? ''); + if (!$domain) Response::error("domain required"); + if (!$accountId) Response::error("account_id required"); + $result = SSLManager::issueLetsEncrypt($accountId, $domain, $email); + audit('ssl.issue', $domain); + Response::success($result, "SSL certificate issued for $domain"); + })(), + + 'install-custom' => (function() use ($body, $accountId) { + $domain = trim($body['domain'] ?? ''); + $cert = trim($body['cert'] ?? ''); + $key = trim($body['key'] ?? ''); + $chain = trim($body['chain'] ?? ''); + if (!$domain || !$cert || !$key) Response::error("domain, cert, and key required"); + $id = SSLManager::installCustom($accountId, $domain, $cert, $key, $chain); + audit('ssl.install-custom', $domain); + Response::success(['id' => $id], 'Custom certificate installed'); + })(), + + 'renew' => (function() use ($db, $body, $accountId) { + $certId = (int)($body['cert_id'] ?? 0); + $cert = $db->fetchOne("SELECT * FROM ssl_certs WHERE id = ? AND account_id = ?", [$certId, $accountId]); + if (!$cert) Response::error("Certificate not found", 404); + $result = SSLManager::issueLetsEncrypt($accountId, $cert['domain']); + audit('ssl.renew', $cert['domain']); + Response::success($result, 'Certificate renewed'); + })(), + + 'delete' => (function() use ($db, $body, $accountId) { + $certId = (int)($body['cert_id'] ?? 0); + $cert = $db->fetchOne("SELECT * FROM ssl_certs WHERE id = ? AND account_id = ?", [$certId, $accountId]); + if (!$cert) Response::error("Certificate not found", 404); + $db->execute("DELETE FROM ssl_certs WHERE id = ?", [$certId]); + $db->execute("UPDATE domains SET ssl_enabled = 0 WHERE domain = ?", [$cert['domain']]); + audit('ssl.delete', $cert['domain']); + Response::success(null, 'Certificate removed'); + })(), + + default => Response::error("Unknown ssl action: $action", 404), +}; diff --git a/panel/api/endpoints/stats.php b/panel/api/endpoints/stats.php new file mode 100644 index 0000000..21598ee --- /dev/null +++ b/panel/api/endpoints/stats.php @@ -0,0 +1,114 @@ +user(); + +match ($action) { + 'server' => (function() use ($db) { + // Last 24 hours of 5-min samples + $rows = $db->fetchAll( + "SELECT cpu_usage, ram_usage, disk_usage, load_avg, recorded_at + FROM server_stats ORDER BY recorded_at DESC LIMIT 288" + ); + $rows = array_reverse($rows); + + // Current live snapshot + $cpu = (float) trim(shell_exec("top -bn1 | grep 'Cpu(s)' | awk '{print $2+$4}'") ?: 0); + $ram = []; + foreach (file('/proc/meminfo') as $line) { + [$k, $v] = preg_split('/\s+/', trim($line), 2); + $ram[$k] = (int) $v; + } + $ramPct = $ram['MemTotal_kB'] ?? 0 + ? round(100 - ($ram['MemAvailable:'] / $ram['MemTotal:']) * 100, 1) + : 0; + + $disk = []; + $dfLine = trim(shell_exec("df / | tail -1") ?: ''); + $dfParts = preg_split('/\s+/', $dfLine); + $diskPct = isset($dfParts[4]) ? (int) $dfParts[4] : 0; + $diskUsed = isset($dfParts[2]) ? round($dfParts[2]/1024/1024, 1) : 0; + $diskTotal = isset($dfParts[1]) ? round($dfParts[1]/1024/1024, 1) : 0; + + $load = sys_getloadavg(); + Response::success([ + 'current' => [ + 'cpu' => $cpu, + 'ram' => $ramPct, + 'disk_pct' => $diskPct, + 'disk_used' => $diskUsed, + 'disk_total' => $diskTotal, + 'load' => $load[0], + ], + 'history' => $rows, + ]); + })(), + + 'account' => (function() use ($db, $body) { + $accountId = (int)($body['account_id'] ?? $_GET['account_id'] ?? 0); + if (!$accountId) Response::error("account_id required"); + $acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$accountId]); + if (!$acct) Response::error("Account not found", 404); + + // Disk + $diskKB = (int)trim(shell_exec("du -sk " . escapeshellarg($acct['home_dir']) . " 2>/dev/null | awk '{print $1}'") ?: 0); + $diskMB = round($diskKB / 1024, 1); + + // Inodes + $inodes = (int)trim(shell_exec("find " . escapeshellarg($acct['home_dir']) . " 2>/dev/null | wc -l") ?: 0); + + // DB count & size + $dbs = $db->fetchAll("SELECT id FROM databases WHERE account_id = ?", [$accountId]); + $dbCount = count($dbs); + + // Email count + $emailCount = (int)($db->fetchOne("SELECT COUNT(*) c FROM email_accounts WHERE account_id = ?", [$accountId])['c'] ?? 0); + // FTP count + $ftpCount = (int)($db->fetchOne("SELECT COUNT(*) c FROM ftp_accounts WHERE account_id = ?", [$accountId])['c'] ?? 0); + // Domain count + $domCount = (int)($db->fetchOne("SELECT COUNT(*) c FROM domains WHERE account_id = ?", [$accountId])['c'] ?? 0); + + // Package limits + $pkg = $db->fetchOne("SELECT * FROM packages WHERE id = ?", [$acct['package_id'] ?? 0]); + + Response::success([ + 'disk_mb' => $diskMB, + 'disk_limit' => $pkg['disk_mb'] ?? 0, + 'inodes' => $inodes, + 'databases' => $dbCount, + 'db_limit' => $pkg['databases'] ?? 0, + 'emails' => $emailCount, + 'email_limit' => $pkg['email_accounts'] ?? 0, + 'ftp' => $ftpCount, + 'ftp_limit' => $pkg['ftp_accounts'] ?? 0, + 'domains' => $domCount, + 'subdomain_limit' => $pkg['subdomains'] ?? 0, + ]); + })(), + + 'bandwidth' => (function() use ($db, $body) { + $accountId = (int)($body['account_id'] ?? $_GET['account_id'] ?? 0); + // Read nginx/apache access log and sum bytes for account's domains + $acct = $db->fetchOne("SELECT username FROM accounts WHERE id = ?", [$accountId]); + if (!$acct) Response::error("Account not found"); + $logFile = "/var/log/novacpx/{$acct['username']}-access.log"; + $daily = []; + if (file_exists($logFile)) { + $lines = explode("\n", trim(shell_exec("tail -50000 " . escapeshellarg($logFile)) ?: '')); + foreach ($lines as $line) { + if (preg_match('/\[(\d{2}\/\w+\/\d{4})/', $line, $m) && + preg_match('/" \d+ (\d+)/', $line, $b)) { + $day = $m[1]; + $daily[$day] = ($daily[$day] ?? 0) + (int)$b[1]; + } + } + } + Response::success(array_map(fn($d,$b) => ['date'=>$d,'bytes'=>$b], array_keys($daily), $daily)); + })(), + + default => Response::error("Unknown stats action: $action", 404), +}; diff --git a/panel/api/endpoints/webmail.php b/panel/api/endpoints/webmail.php new file mode 100644 index 0000000..aef98b9 --- /dev/null +++ b/panel/api/endpoints/webmail.php @@ -0,0 +1,53 @@ +user(); + +match ($action) { + 'url' => (function() use ($db, $body, $user) { + $accountId = $user['role'] === 'user' + ? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0) + : (int)($body['account_id'] ?? 0); + $acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$accountId]); + if (!$acct) Response::error("Account not found"); + + $domain = $acct['domain']; + // Roundcube installed by default at /var/www/roundcube + $rcUrl = "https://{$domain}/webmail/"; + // Check if Roundcube is installed + $installed = file_exists('/var/www/roundcube/index.php'); + Response::success([ + 'url' => $rcUrl, + 'installed' => $installed, + 'domain' => $domain, + ]); + })(), + + 'install' => (function() use ($db) { + Auth::getInstance()->requireRole(['admin']); + // Background install + $logFile = '/var/log/novacpx/webmail-install.log'; + $cmd = 'apt-get install -y roundcube roundcube-mysql php8.3-intl > ' . escapeshellarg($logFile) . ' 2>&1 && ' . + 'ln -sf /usr/share/roundcube /var/www/roundcube >> ' . escapeshellarg($logFile) . ' 2>&1'; + shell_exec("nohup bash -c " . escapeshellarg($cmd) . " &"); + Response::success(['log' => $logFile], 'Webmail install started'); + })(), + + 'login-url' => (function() use ($db, $body, $user) { + // Generate a short-lived token for auto-login + $emailAccount = $db->fetchOne("SELECT * FROM email_accounts WHERE id = ?", [(int)($body['email_id'] ?? 0)]); + if (!$emailAccount) Response::error("Email account not found"); + $token = bin2hex(random_bytes(16)); + $db->execute("INSERT INTO api_tokens (user_id, token, purpose, expires_at) VALUES (?,?,?,DATE_ADD(NOW(), INTERVAL 30 SECOND))", + [$user['uid'], hash('sha256', $token), 'webmail_sso']); + $domain = parse_url($emailAccount['email'], PHP_URL_HOST) ?: ''; + Response::success(['url' => "https://{$domain}/webmail/?_token={$token}"]); + })(), + + default => Response::error("Unknown webmail action: $action", 404), +}; diff --git a/panel/lib/AccountManager.php b/panel/lib/AccountManager.php new file mode 100644 index 0000000..5123378 --- /dev/null +++ b/panel/lib/AccountManager.php @@ -0,0 +1,123 @@ + 32) throw new RuntimeException("Username must be 2-32 chars"); + if (!filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) throw new RuntimeException("Invalid domain"); + + // Check uniqueness + if ($db->fetchOne("SELECT id FROM accounts WHERE username = ?", [$username])) throw new RuntimeException("Username taken"); + if ($db->fetchOne("SELECT id FROM domains WHERE domain = ?", [$domain])) throw new RuntimeException("Domain already hosted"); + + $homeDir = "/home/{$username}"; + $docRoot = "{$homeDir}/public_html"; + $password = $data['password'] ?? bin2hex(random_bytes(8)); + + // 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}"); + + // Default index page + file_put_contents("{$docRoot}/index.html", + "

Welcome to {$domain}

Hosted by NovaCPX

" + ); + + // Save account to DB + $acctId = (int)$db->insert( + "INSERT INTO accounts (user_id, username, domain, home_dir, package_id, php_version, web_server) VALUES (?,?,?,?,?,?,?)", + [$userId, $username, $domain, $homeDir, $pkgId ?: null, $phpVer, $webSrv] + ); + + // Save domain + $db->insert( + "INSERT INTO domains (account_id, domain, type, document_root) VALUES (?,?,?,?)", + [$acctId, $domain, 'main', $docRoot] + ); + + // Create web vhost + VhostManager::create($username, $domain, $docRoot, $phpVer); + + // Create DNS zone + DNSManager::createZone($acctId, $domain); + + // Create PHP-FPM pool + PHPManager::createPool($username, $phpVer); + + novacpx_log('info', "Account created: $username ($domain)"); + return ['account_id' => $acctId, 'username' => $username, 'domain' => $domain, 'home_dir' => $homeDir]; + } + + public static function suspend(int $acctId, string $reason = ''): void { + $db = DB::getInstance(); + $acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$acctId]); + if (!$acct) throw new RuntimeException("Account not found"); + + self::shell("usermod -L " . escapeshellarg($acct['username'])); + VhostManager::suspend($acct['username'], $acct['domain']); + $db->execute("UPDATE accounts SET status = 'suspended', suspended_at = NOW() WHERE id = ?", [$acctId]); + novacpx_log('info', "Account suspended: {$acct['username']} — $reason"); + } + + public static function unsuspend(int $acctId): void { + $db = DB::getInstance(); + $acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$acctId]); + if (!$acct) throw new RuntimeException("Account not found"); + + self::shell("usermod -U " . escapeshellarg($acct['username'])); + VhostManager::unsuspend($acct['username'], $acct['domain']); + $db->execute("UPDATE accounts SET status = 'active', suspended_at = NULL WHERE id = ?", [$acctId]); + } + + public static function terminate(int $acctId): void { + $db = DB::getInstance(); + $acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$acctId]); + if (!$acct) throw new RuntimeException("Account not found"); + + // Remove vhost + VhostManager::remove($acct['username'], $acct['domain']); + + // Remove DNS zone + DNSManager::removeZone($acct['domain']); + + // Drop databases + $dbs = $db->fetchAll("SELECT * FROM databases WHERE account_id = ?", [$acctId]); + foreach ($dbs as $dbe) { DatabaseManager::drop($dbe['db_name'], $dbe['db_user'], $dbe['db_type']); } + + // Remove PHP-FPM pool + PHPManager::removePool($acct['username']); + + // Remove Linux user and home dir + self::shell("userdel -r " . escapeshellarg($acct['username']) . " 2>/dev/null || true"); + + // Remove from DB (cascade handles child tables) + $db->execute("DELETE FROM users WHERE id = ?", [$acct['user_id']]); + $db->execute("DELETE FROM accounts WHERE id = ?", [$acctId]); + novacpx_log('info', "Account terminated: {$acct['username']}"); + } + + public static function getDiskUsage(string $homeDir): int { + $out = trim(shell_exec("du -sm " . escapeshellarg($homeDir) . " 2>/dev/null | awk '{print $1}'") ?: '0'); + return (int)$out; + } + + private static function shell(string $cmd): string { + $out = shell_exec($cmd . ' 2>&1'); + novacpx_log('debug', "shell: $cmd"); + return $out ?: ''; + } +} diff --git a/panel/lib/Core.php b/panel/lib/Core.php index 8553086..dc0331c 100644 --- a/panel/lib/Core.php +++ b/panel/lib/Core.php @@ -22,6 +22,7 @@ define('PANEL_VER', $_cfg['panel']['version'] ?? NOVACPX_VERSION); define('PORT_USER', (int)($_cfg['panel']['port_user'] ?? 8880)); define('PORT_RESELLER', (int)($_cfg['panel']['port_reseller'] ?? 8881)); define('PORT_ADMIN', (int)($_cfg['panel']['port_admin'] ?? 8882)); +define('PORT_WEBMAIL', (int)($_cfg['panel']['port_webmail'] ?? 8883)); define('WEB_SERVER', $_cfg['web']['server'] ?? 'apache'); define('PHP_DEFAULT', $_cfg['web']['php_default'] ?? '8.3'); @@ -29,7 +30,8 @@ define('PHP_DEFAULT', $_cfg['web']['php_default'] ?? '8.3'); $requestPort = (int)($_SERVER['SERVER_PORT'] ?? 0); define('CURRENT_PORTAL', $requestPort === PORT_ADMIN ? 'admin' : - ($requestPort === PORT_RESELLER ? 'reseller' : 'user') + ($requestPort === PORT_RESELLER ? 'reseller' : + ($requestPort === PORT_WEBMAIL ? 'webmail' : 'user')) ); function novacpx_log(string $level, string $msg, array $ctx = []): void { diff --git a/panel/lib/DNSManager.php b/panel/lib/DNSManager.php new file mode 100644 index 0000000..0694a7e --- /dev/null +++ b/panel/lib/DNSManager.php @@ -0,0 +1,147 @@ +insert( + "INSERT INTO dns_zones (account_id, domain, serial, primary_ns, secondary_ns, admin_email) VALUES (?,?,?,?,?,?)", + [$accountId, $domain, $serial, $ns1, $ns2, $email] + ); + + // Default records + $defaults = [ + ['@', 'A', $ip, 3600, null], + ['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( + "INSERT INTO dns_records (zone_id, name, type, content, ttl, priority) VALUES (?,?,?,?,?,?)", + [$zoneId, $name, $type, $content, $ttl, $prio] + ); + } + + self::writeZoneFile($zoneId); + self::reloadBind(); + } + + public static function removeZone(string $domain): void { + $db = DB::getInstance(); + $zone = $db->fetchOne("SELECT id FROM dns_zones WHERE domain = ?", [$domain]); + if ($zone) { + $db->execute("DELETE FROM dns_zones WHERE id = ?", [$zone['id']]); + } + $file = self::$zonesDir . '/' . $domain . '.zone'; + @unlink($file); + self::rebuildNamedConf(); + self::reloadBind(); + } + + public static function addRecord(int $zoneId, string $name, string $type, string $content, int $ttl = 3600, ?int $priority = null): int { + $db = DB::getInstance(); + $id = (int)$db->insert( + "INSERT INTO dns_records (zone_id, name, type, content, ttl, priority) VALUES (?,?,?,?,?,?)", + [$zoneId, $name, $type, $content, $ttl, $priority] + ); + $db->execute("UPDATE dns_zones SET serial = serial + 1, updated_at = NOW() WHERE id = ?", [$zoneId]); + self::writeZoneFile($zoneId); + self::reloadBind(); + return $id; + } + + public static function updateRecord(int $recordId, array $data): void { + $db = DB::getInstance(); + $rec = $db->fetchOne("SELECT zone_id FROM dns_records WHERE id = ?", [$recordId]); + if (!$rec) throw new RuntimeException("Record not found"); + $db->execute( + "UPDATE dns_records SET name=?, type=?, content=?, ttl=?, priority=? WHERE id=?", + [$data['name'], $data['type'], $data['content'], $data['ttl'] ?? 3600, $data['priority'] ?? null, $recordId] + ); + $db->execute("UPDATE dns_zones SET serial = serial + 1, updated_at = NOW() WHERE id = ?", [$rec['zone_id']]); + self::writeZoneFile($rec['zone_id']); + self::reloadBind(); + } + + public static function deleteRecord(int $recordId): void { + $db = DB::getInstance(); + $rec = $db->fetchOne("SELECT zone_id FROM dns_records WHERE id = ?", [$recordId]); + if (!$rec) throw new RuntimeException("Record not found"); + $db->execute("DELETE FROM dns_records WHERE id = ?", [$recordId]); + $db->execute("UPDATE dns_zones SET serial = serial + 1 WHERE id = ?", [$rec['zone_id']]); + self::writeZoneFile($rec['zone_id']); + self::reloadBind(); + } + + public static function writeZoneFile(int $zoneId): void { + $db = DB::getInstance(); + $zone = $db->fetchOne("SELECT * FROM dns_zones WHERE id = ?", [$zoneId]); + if (!$zone) return; + $records = $db->fetchAll("SELECT * FROM dns_records WHERE zone_id = ? ORDER BY type, name", [$zoneId]); + + @mkdir(self::$zonesDir, 0755, true); + $domain = $zone['domain']; + $content = "\$ORIGIN {$domain}.\n\$TTL {$zone['ttl']}\n\n"; + $content .= "@ IN SOA {$zone['primary_ns']}. {$zone['admin_email']}. (\n"; + $content .= " {$zone['serial']} ; serial\n"; + $content .= " {$zone['refresh']} ; refresh\n"; + $content .= " {$zone['retry']} ; retry\n"; + $content .= " {$zone['expire']} ; expire\n"; + $content .= " {$zone['minimum']} ; minimum\n)\n\n"; + $content .= "@ IN NS {$zone['primary_ns']}.\n"; + $content .= "@ IN NS {$zone['secondary_ns']}.\n\n"; + + foreach ($records as $r) { + $name = $r['name'] === '@' ? '@' : $r['name']; + $prio = $r['priority'] !== null ? "{$r['priority']} " : ''; + $val = in_array($r['type'], ['TXT','SPF','DMARC','DKIM']) ? "\"{$r['content']}\"" : $r['content']; + $content .= "{$name} {$r['ttl']} IN {$r['type']} {$prio}{$val}\n"; + } + + file_put_contents(self::$zonesDir . '/' . $domain . '.zone', $content); + self::rebuildNamedConf(); + } + + private static function rebuildNamedConf(): void { + @mkdir(self::$zonesDir, 0755, true); + $zones = glob(self::$zonesDir . '/*.zone') ?: []; + $conf = "// NovaCPX auto-generated zone list\n"; + foreach ($zones as $zf) { + $domain = basename($zf, '.zone'); + $conf .= "zone \"{$domain}\" { type master; file \"" . self::$zonesDir . "/{$domain}.zone\"; };\n"; + } + file_put_contents(self::$namedConf, $conf); + + // 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); + } + } + + private static function reloadBind(): void { + shell_exec("rndc reload 2>/dev/null || systemctl reload named 2>/dev/null || true"); + } + + private static function serverIp(): string { + return trim(shell_exec("hostname -I | awk '{print $1}'") ?: '127.0.0.1'); + } + + private static function getSetting(string $key, string $default): string { + $row = DB::getInstance()->fetchOne("SELECT value FROM settings WHERE `key` = ?", [$key]); + return $row['value'] ?? $default; + } +} diff --git a/panel/lib/DatabaseManager.php b/panel/lib/DatabaseManager.php new file mode 100644 index 0000000..e052884 --- /dev/null +++ b/panel/lib/DatabaseManager.php @@ -0,0 +1,95 @@ +pdo(); + + $pdo->exec("CREATE DATABASE IF NOT EXISTS `{$dbName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + $pdo->exec("CREATE USER IF NOT EXISTS '{$dbUser}'@'localhost' IDENTIFIED BY " . $pdo->quote($dbPass)); + $pdo->exec("GRANT ALL PRIVILEGES ON `{$dbName}`.* TO '{$dbUser}'@'localhost'"); + $pdo->exec("FLUSH PRIVILEGES"); + + return (int)$db->insert( + "INSERT INTO databases (account_id, db_name, db_user, db_pass, db_type) VALUES (?,?,?,?,?)", + [$accountId, $dbName, $dbUser, encrypt($dbPass), 'mysql'] + ); + } + + public static function createPostgres(int $accountId, string $dbName, string $dbUser, string $dbPass): int { + self::validateName($dbName); self::validateName($dbUser); + $db = DB::getInstance(); + $safe = escapeshellarg($dbPass); + shell_exec("sudo -u postgres psql -c \"CREATE USER {$dbUser} WITH PASSWORD {$safe}\" 2>/dev/null"); + shell_exec("sudo -u postgres createdb -O {$dbUser} {$dbName} 2>/dev/null"); + + return (int)$db->insert( + "INSERT INTO databases (account_id, db_name, db_user, db_pass, db_type) VALUES (?,?,?,?,?)", + [$accountId, $dbName, $dbUser, encrypt($dbPass), 'postgresql'] + ); + } + + public static function drop(string $dbName, string $dbUser, string $type = 'mysql'): void { + if ($type === 'mysql') { + $pdo = DB::getInstance()->pdo(); + $pdo->exec("DROP DATABASE IF EXISTS `{$dbName}`"); + $pdo->exec("DROP USER IF EXISTS '{$dbUser}'@'localhost'"); + $pdo->exec("FLUSH PRIVILEGES"); + } else { + shell_exec("sudo -u postgres dropdb --if-exists " . escapeshellarg($dbName) . " 2>/dev/null"); + shell_exec("sudo -u postgres dropuser --if-exists " . escapeshellarg($dbUser) . " 2>/dev/null"); + } + DB::getInstance()->execute("DELETE FROM databases WHERE db_name = ? AND db_type = ?", [$dbName, $type]); + } + + public static function changePassword(int $id, string $newPass): void { + $db = DB::getInstance(); + $dbe = $db->fetchOne("SELECT * FROM databases WHERE id = ?", [$id]); + if (!$dbe) throw new RuntimeException("Database not found"); + if ($dbe['db_type'] === 'mysql') { + $pdo = $db->pdo(); + $pdo->exec("ALTER USER '{$dbe['db_user']}'@'localhost' IDENTIFIED BY " . $pdo->quote($newPass)); + } else { + shell_exec("sudo -u postgres psql -c \"ALTER USER {$dbe['db_user']} WITH PASSWORD " . escapeshellarg($newPass) . "\" 2>/dev/null"); + } + $db->execute("UPDATE databases SET db_pass = ? WHERE id = ?", [encrypt($newPass), $id]); + } + + public static function getSize(string $dbName, string $type = 'mysql'): float { + if ($type === 'mysql') { + $row = DB::getInstance()->fetchOne( + "SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS size + FROM information_schema.tables WHERE table_schema = ?", + [$dbName] + ); + return (float)($row['size'] ?? 0); + } + $out = shell_exec("sudo -u postgres psql -t -c \"SELECT pg_size_pretty(pg_database_size('{$dbName}'))\" 2>/dev/null"); + return (float)$out; + } + + private static function validateName(string $name): void { + if (!preg_match('/^[a-zA-Z0-9_]{1,64}$/', $name)) { + throw new RuntimeException("Invalid database/user name: must be alphanumeric+underscore, max 64 chars"); + } + } +} + +function encrypt(string $val): string { + $key = SECRET_KEY; + $iv = random_bytes(16); + $enc = openssl_encrypt($val, 'aes-256-cbc', substr(hash('sha256', $key, true), 0, 32), OPENSSL_RAW_DATA, $iv); + return base64_encode($iv . $enc); +} + +function decrypt(string $val): string { + $key = SECRET_KEY; + $data = base64_decode($val); + $iv = substr($data, 0, 16); + $enc = substr($data, 16); + return openssl_decrypt($enc, 'aes-256-cbc', substr(hash('sha256', $key, true), 0, 32), OPENSSL_RAW_DATA, $iv) ?: ''; +} diff --git a/panel/lib/EmailManager.php b/panel/lib/EmailManager.php new file mode 100644 index 0000000..5818ca7 --- /dev/null +++ b/panel/lib/EmailManager.php @@ -0,0 +1,99 @@ +insert( + "INSERT INTO email_accounts (account_id, email, password, quota_mb) VALUES (?,?,?,?)", + [$accountId, $email, $hashed, $quotaMb] + ); + self::syncPostfix(); + novacpx_log('info', "Email account created: $email"); + return $id; + } + + public static function deleteAccount(int $id): void { + $db = DB::getInstance(); + $acc = $db->fetchOne("SELECT email FROM email_accounts WHERE id = ?", [$id]); + if (!$acc) throw new RuntimeException("Email account not found"); + $db->execute("DELETE FROM email_accounts WHERE id = ?", [$id]); + self::syncPostfix(); + } + + 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]); + } + + public static function suspend(int $id): void { + DB::getInstance()->execute("UPDATE email_accounts SET status = 'suspended' WHERE id = ?", [$id]); + self::syncPostfix(); + } + + public static function addForwarder(int $accountId, string $source, string $destination): int { + $db = DB::getInstance(); + return (int)$db->insert( + "INSERT INTO email_forwarders (account_id, source, destination) VALUES (?,?,?)", + [$accountId, $source, $destination] + ); + } + + public static function removeForwarder(int $id): void { + DB::getInstance()->execute("DELETE FROM email_forwarders WHERE id = ?", [$id]); + self::syncPostfix(); + } + + public static function addAutoresponder(int $accountId, string $email, string $subject, string $body): int { + $db = DB::getInstance(); + return (int)$db->insert( + "INSERT INTO email_autoresponders (account_id, email, subject, body, is_active) VALUES (?,?,?,?,1)", + [$accountId, $email, $subject, $body] + ); + } + + /** + * 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(); + + // Virtual mailbox map + $accounts = $db->fetchAll("SELECT ea.email, a.username FROM email_accounts ea JOIN accounts a ON a.id = ea.account_id WHERE ea.status = 'active'"); + $mailboxes = ''; + foreach ($accounts as $a) { + $domain = substr(strrchr($a['email'], '@'), 1); + $user = strstr($a['email'], '@', true); + $mailboxes .= "{$a['email']} {$a['username']}/{$domain}/{$user}/\n"; + } + file_put_contents('/etc/postfix/novacpx_mailboxes', $mailboxes); + shell_exec('postmap /etc/postfix/novacpx_mailboxes 2>/dev/null'); + + // Virtual alias map (forwarders) + $forwarders = $db->fetchAll("SELECT source, destination FROM email_forwarders"); + $aliases = ''; + foreach ($forwarders as $f) { + $aliases .= "{$f['source']} {$f['destination']}\n"; + } + file_put_contents('/etc/postfix/novacpx_aliases', $aliases); + shell_exec('postmap /etc/postfix/novacpx_aliases 2>/dev/null'); + + // Virtual domains map + $domains = $db->fetchAll("SELECT DISTINCT SUBSTRING_INDEX(email,'@',-1) as domain FROM email_accounts WHERE status='active'"); + $vdomains = ''; + foreach ($domains as $d) { $vdomains .= "{$d['domain']} novacpx\n"; } + file_put_contents('/etc/postfix/novacpx_domains', $vdomains); + shell_exec('postmap /etc/postfix/novacpx_domains 2>/dev/null'); + shell_exec('systemctl reload postfix 2>/dev/null || true'); + } + + private static function hashPassword(string $password): string { + // Dovecot SHA512-CRYPT compatible + return '{SHA512-CRYPT}' . crypt($password, '$6$' . bin2hex(random_bytes(8)) . '$'); + } +} diff --git a/panel/lib/FTPManager.php b/panel/lib/FTPManager.php new file mode 100644 index 0000000..2a2df83 --- /dev/null +++ b/panel/lib/FTPManager.php @@ -0,0 +1,66 @@ +fetchOne("SELECT username as owner FROM accounts WHERE id = ?", [$accountId]); + $ftpUser = strtolower(preg_replace('/[^a-z0-9_]/', '', $username)); + + $id = (int)$db->insert( + "INSERT INTO ftp_accounts (account_id, username, password, home_dir, quota_mb) VALUES (?,?,?,?,?)", + [$accountId, $ftpUser, $hashed, $homeDir, $quotaMb] + ); + + self::syncProftpd(); + novacpx_log('info', "FTP account created: $ftpUser"); + return $id; + } + + public static function deleteAccount(int $id): void { + DB::getInstance()->execute("DELETE FROM ftp_accounts WHERE id = ?", [$id]); + self::syncProftpd(); + } + + public static function changePassword(int $id, string $newPassword): void { + DB::getInstance()->execute( + "UPDATE ftp_accounts SET password = ? WHERE id = ?", + [password_hash($newPassword, PASSWORD_BCRYPT), $id] + ); + self::syncProftpd(); + } + + public static function suspend(int $id): void { + DB::getInstance()->execute("UPDATE ftp_accounts SET status = 'suspended' WHERE id = ?", [$id]); + self::syncProftpd(); + } + + private static function syncProftpd(): void { + // Write ProFTPD virtual users file (passwd format) + $db = DB::getInstance(); + $accounts = $db->fetchAll("SELECT f.*, a.username as owner FROM ftp_accounts f JOIN accounts a ON a.id = f.account_id WHERE f.status = 'active'"); + $passwd = ''; + foreach ($accounts as $a) { + $uid = self::getUid($a['owner']); + $gid = self::getGid('www-data'); + $passwd .= "{$a['username']}:{$a['password']}:{$uid}:{$gid}:NovaCPX FTP:{$a['home_dir']}:/sbin/nologin\n"; + } + file_put_contents('/etc/proftpd/novacpx-users.passwd', $passwd); + shell_exec('systemctl reload proftpd 2>/dev/null || true'); + } + + private static function getUid(string $username): int { + $out = trim(shell_exec("id -u " . escapeshellarg($username) . " 2>/dev/null") ?: '33'); + return (int)$out; + } + + private static function getGid(string $group): int { + $out = trim(shell_exec("getent group " . escapeshellarg($group) . " | cut -d: -f3 2>/dev/null") ?: '33'); + return (int)$out; + } +} diff --git a/panel/lib/PHPManager.php b/panel/lib/PHPManager.php new file mode 100644 index 0000000..9e9345a --- /dev/null +++ b/panel/lib/PHPManager.php @@ -0,0 +1,108 @@ +fetchOne("SELECT * FROM accounts WHERE id = ?", [$accountId]); + if (!$acct) throw new RuntimeException("Account not found"); + + $oldVer = $acct['php_version']; + if ($oldVer === $newVer) return; + + // Remove old pool, create new one + $oldPool = str_replace('{ver}', $oldVer, self::$poolDir) . "/{$acct['username']}.conf"; + if (file_exists($oldPool)) { unlink($oldPool); self::reloadFPM($oldVer); } + + self::createPool($acct['username'], $newVer); + + // Update vhost to use new socket + VhostManager::create($acct['username'], $acct['domain'], $acct['home_dir'] . '/public_html', $newVer); + + $db->execute("UPDATE accounts SET php_version = ? WHERE id = ?", [$newVer, $accountId]); + $db->execute("UPDATE php_configs SET php_version = ?, updated_at = NOW() WHERE account_id = ?", [$newVer, $accountId]); + } + + public static function updateConfig(int $accountId, array $cfg): void { + $db = DB::getInstance(); + $acct = $db->fetchOne("SELECT username, php_version FROM accounts WHERE id = ?", [$accountId]); + if (!$acct) throw new RuntimeException("Account not found"); + + $poolFile = str_replace('{ver}', $acct['php_version'], self::$poolDir) . "/{$acct['username']}.conf"; + if (!file_exists($poolFile)) throw new RuntimeException("PHP-FPM pool not found"); + + $content = file_get_contents($poolFile); + $map = [ + 'memory_limit' => 'php_value[memory_limit]', + 'max_execution_time' => 'php_value[max_execution_time]', + 'upload_max_filesize' => 'php_value[upload_max_filesize]', + 'post_max_size' => 'php_value[post_max_size]', + ]; + foreach ($map as $key => $iniKey) { + if (isset($cfg[$key])) { + $content = preg_replace("/{$iniKey}\s*=.*/", "{$iniKey} = {$cfg[$key]}", $content); + } + } + file_put_contents($poolFile, $content); + self::reloadFPM($acct['php_version']); + + $db->execute( + "INSERT INTO php_configs (account_id, php_version, memory_limit, max_execution_time, upload_max_filesize, post_max_size) + VALUES (?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE memory_limit=VALUES(memory_limit), max_execution_time=VALUES(max_execution_time), + upload_max_filesize=VALUES(upload_max_filesize), post_max_size=VALUES(post_max_size), updated_at=NOW()", + [$accountId, $acct['php_version'], $cfg['memory_limit'] ?? '256M', $cfg['max_execution_time'] ?? 30, + $cfg['upload_max_filesize'] ?? '64M', $cfg['post_max_size'] ?? '64M'] + ); + } + + public static function listExtensions(string $phpVer): array { + $out = shell_exec("php{$phpVer} -m 2>/dev/null") ?: ''; + return array_values(array_filter(explode("\n", $out), fn($l) => $l && !str_starts_with($l, '['))); + } + + private static function reloadFPM(string $ver): void { + shell_exec("systemctl reload php{$ver}-fpm 2>/dev/null || true"); + } +} diff --git a/panel/lib/SSLManager.php b/panel/lib/SSLManager.php new file mode 100644 index 0000000..7c4392f --- /dev/null +++ b/panel/lib/SSLManager.php @@ -0,0 +1,86 @@ +fetchOne("SELECT d.document_root FROM domains d WHERE d.domain = ? AND d.account_id = ?", [$domain, $accountId]); + if (!$webRoot) throw new RuntimeException("Domain not found for this account"); + + $docRoot = $webRoot['document_root']; + $email = $email ?: "ssl@{$domain}"; + $cmd = "certbot certonly --webroot -w {$docRoot} -d {$domain} -d www.{$domain}" + . " --email " . escapeshellarg($email) + . " --agree-tos --non-interactive 2>&1"; + $out = shell_exec($cmd); + + $certPath = "/etc/letsencrypt/live/{$domain}/fullchain.pem"; + $keyPath = "/etc/letsencrypt/live/{$domain}/privkey.pem"; + $chainPath = "/etc/letsencrypt/live/{$domain}/chain.pem"; + + if (!file_exists($certPath)) { + novacpx_log('error', "Certbot failed for $domain: $out"); + throw new RuntimeException("Certbot failed. Check DNS propagation and try again."); + } + + $cert = file_get_contents($certPath); + $key = file_get_contents($keyPath); + $chain = file_get_contents($chainPath); + + // Parse expiry + $expiryRaw = shell_exec("openssl x509 -enddate -noout -in " . escapeshellarg($certPath)); + preg_match('/notAfter=(.+)/', $expiryRaw ?: '', $m); + $expires = isset($m[1]) ? date('Y-m-d', strtotime($m[1])) : null; + + // Store in DB + $certId = self::storeCert($accountId, $domain, 'lets_encrypt', $cert, $key, $chain, $expires); + + // Install on vhost + $acct = $db->fetchOne("SELECT username FROM accounts WHERE id = ?", [$accountId]); + VhostManager::enableSSL($acct['username'], $domain, $cert, $key, $chain); + + return ['cert_id' => $certId, 'expires' => $expires, 'output' => $out]; + } + + public static function installCustom(int $accountId, string $domain, string $cert, string $key, string $chain = ''): int { + $db = DB::getInstance(); + $expiryRaw = shell_exec("echo " . escapeshellarg($cert) . " | openssl x509 -enddate -noout 2>/dev/null"); + preg_match('/notAfter=(.+)/', $expiryRaw ?: '', $m); + $expires = isset($m[1]) ? date('Y-m-d', strtotime($m[1])) : null; + + $certId = self::storeCert($accountId, $domain, 'custom', $cert, $key, $chain, $expires); + + $acct = $db->fetchOne("SELECT username FROM accounts WHERE id = ?", [$accountId]); + VhostManager::enableSSL($acct['username'], $domain, $cert, $key, $chain); + return $certId; + } + + public static function renewAll(): void { + $db = DB::getInstance(); + $soon = $db->fetchAll( + "SELECT * FROM ssl_certs WHERE auto_renew = 1 AND type = 'lets_encrypt' + AND expires_at <= DATE_ADD(NOW(), INTERVAL 30 DAY) AND status = 'active'" + ); + foreach ($soon as $cert) { + try { + self::issueLetsEncrypt($cert['account_id'], $cert['domain']); + novacpx_log('info', "SSL auto-renewed: {$cert['domain']}"); + } catch (Throwable $e) { + novacpx_log('error', "SSL renewal failed for {$cert['domain']}: " . $e->getMessage()); + $db->execute("UPDATE ssl_certs SET status = 'failed' WHERE id = ?", [$cert['id']]); + } + } + } + + private static function storeCert(int $accountId, string $domain, string $type, string $cert, string $key, string $chain, ?string $expires): int { + $db = DB::getInstance(); + $db->execute("DELETE FROM ssl_certs WHERE account_id = ? AND domain = ?", [$accountId, $domain]); + return (int)$db->insert( + "INSERT INTO ssl_certs (account_id, domain, type, cert, private_key, chain, issued_at, expires_at, status) + VALUES (?,?,?,?,?,?,NOW(),?,?)", + [$accountId, $domain, $type, $cert, $key, $chain, $expires, 'active'] + ); + } +} diff --git a/panel/lib/VhostManager.php b/panel/lib/VhostManager.php new file mode 100644 index 0000000..1986888 --- /dev/null +++ b/panel/lib/VhostManager.php @@ -0,0 +1,150 @@ +" + . "

Account Suspended

" + . "

This account has been suspended. Please contact support.

" + ); + // Rewrite vhost to serve suspension page + if (WEB_SERVER === 'nginx') { + $conf = "/etc/nginx/sites-available/novacpx-{$username}.conf"; + if (file_exists($conf)) { + $content = file_get_contents($conf); + $content = preg_replace('/root\s+[^;]+;/', "root {$suspendedRoot};", $content); + file_put_contents($conf, $content); + } + } else { + $conf = "/etc/apache2/sites-available/novacpx-{$username}.conf"; + if (file_exists($conf)) { + $content = file_get_contents($conf); + $content = preg_replace('/DocumentRoot\s+\S+/', "DocumentRoot {$suspendedRoot}", $content); + file_put_contents($conf, $content); + } + } + self::reload(); + } + + public static function unsuspend(string $username, string $domain): void { + // Re-create from DB + $db = DB::getInstance(); + $acct = $db->fetchOne("SELECT * FROM accounts WHERE username = ?", [$username]); + if ($acct) { + self::create($username, $domain, $acct['home_dir'] . '/public_html', $acct['php_version']); + } + } + + public static function remove(string $username, string $domain): void { + if (WEB_SERVER === 'nginx') { + $conf = "/etc/nginx/sites-available/novacpx-{$username}.conf"; + $link = "/etc/nginx/sites-enabled/novacpx-{$username}.conf"; + @unlink($conf); @unlink($link); + } else { + $conf = "/etc/apache2/sites-available/novacpx-{$username}.conf"; + shell_exec("a2dissite novacpx-{$username} 2>/dev/null"); + @unlink($conf); + } + self::reload(); + } + + public static function enableSSL(string $username, string $domain, string $cert, string $key, string $chain = ''): void { + $certDir = "/etc/novacpx/ssl/accounts/{$username}"; + @mkdir($certDir, 0700, true); + file_put_contents("{$certDir}/cert.pem", $cert); + file_put_contents("{$certDir}/key.pem", $key); + if ($chain) file_put_contents("{$certDir}/chain.pem", $chain); + + if (WEB_SERVER === 'nginx') { + $conf = file_get_contents("/etc/nginx/sites-available/novacpx-{$username}.conf") ?: ''; + if (!str_contains($conf, 'ssl_certificate')) { + $conf = str_replace('listen 80;', "listen 443 ssl http2;\n listen 80;\n ssl_certificate {$certDir}/cert.pem;\n ssl_certificate_key {$certDir}/key.pem;", $conf); + file_put_contents("/etc/nginx/sites-available/novacpx-{$username}.conf", $conf); + } + } else { + $conf = file_get_contents("/etc/apache2/sites-available/novacpx-{$username}.conf") ?: ''; + if (!str_contains($conf, 'SSLEngine')) { + $conf = str_replace('', "\n SSLEngine on\n SSLCertificateFile {$certDir}/cert.pem\n SSLCertificateKeyFile {$certDir}/key.pem", $conf); + $conf .= "\n\n ServerName {$domain}\n Redirect permanent / https://{$domain}/\n"; + file_put_contents("/etc/apache2/sites-available/novacpx-{$username}.conf", $conf); + } + } + self::reload(); + } + + private static function writeNginx(string $username, string $domain, string $docRoot, string $phpVer, string $logDir): void { + $sock = "/run/php/php{$phpVer}-fpm-{$username}.sock"; + $conf = "/etc/nginx/sites-available/novacpx-{$username}.conf"; + file_put_contents($conf, "server { + listen 80; + server_name {$domain} www.{$domain}; + root {$docRoot}; + index index.php index.html index.htm; + access_log {$logDir}/access.log; + error_log {$logDir}/error.log; + + location / { try_files \$uri \$uri/ /index.php?\$query_string; } + location ~ \.php$ { + fastcgi_pass unix:{$sock}; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name; + fastcgi_param PHP_VALUE \"error_log={$logDir}/php.log\"; + } + location ~ /\\.ht { deny all; } + location ~* \\.(jpg|jpeg|png|gif|ico|css|js|svg|woff2)$ { expires 30d; add_header Cache-Control public; } +} +"); + @symlink($conf, "/etc/nginx/sites-enabled/novacpx-{$username}.conf"); + } + + private static function writeApache(string $username, string $domain, string $docRoot, string $phpVer, string $logDir): void { + $sock = "/run/php/php{$phpVer}-fpm-{$username}.sock"; + $conf = "/etc/apache2/sites-available/novacpx-{$username}.conf"; + file_put_contents($conf, " + ServerName {$domain} + ServerAlias www.{$domain} + DocumentRoot {$docRoot} + ErrorLog {$logDir}/error.log + CustomLog {$logDir}/access.log combined + + + Options -Indexes +FollowSymLinks +MultiViews + AllowOverride All + Require all granted + + + SetHandler \"proxy:unix:{$sock}|fcgi://localhost/\" + + +"); + shell_exec("a2ensite novacpx-{$username} 2>/dev/null"); + } + + private static function reload(): void { + if (WEB_SERVER === 'nginx') { + shell_exec("nginx -t 2>/dev/null && systemctl reload nginx 2>/dev/null"); + } else { + shell_exec("apache2ctl configtest 2>/dev/null && systemctl reload apache2 2>/dev/null"); + } + } +} diff --git a/panel/public/assets/img/nova-favicon.svg b/panel/public/assets/img/nova-favicon.svg new file mode 100644 index 0000000..4476ee0 --- /dev/null +++ b/panel/public/assets/img/nova-favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/panel/public/assets/img/nova-icons.svg b/panel/public/assets/img/nova-icons.svg new file mode 100644 index 0000000..b00b95e --- /dev/null +++ b/panel/public/assets/img/nova-icons.svg @@ -0,0 +1,329 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/panel/public/assets/img/nova-logo.svg b/panel/public/assets/img/nova-logo.svg new file mode 100644 index 0000000..bbe4464 --- /dev/null +++ b/panel/public/assets/img/nova-logo.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + Nova + CPX + diff --git a/panel/public/assets/img/nova-mark.svg b/panel/public/assets/img/nova-mark.svg new file mode 100644 index 0000000..1ee7a38 --- /dev/null +++ b/panel/public/assets/img/nova-mark.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + +