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 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 05:50:50 +00:00
parent 716d292e77
commit e3b166803a
28 changed files with 2576 additions and 1 deletions
+123
View File
@@ -0,0 +1,123 @@
<?php
/**
* AccountManager — creates/suspends/terminates Linux hosting accounts
* Each account = system user + home dir + vhost + DNS zone + mail domain
*/
class AccountManager {
public static function create(array $data): array {
$db = DB::getInstance();
$username = strtolower(preg_replace('/[^a-z0-9_]/', '', $data['username']));
$domain = strtolower(trim($data['domain']));
$userId = (int)$data['user_id'];
$pkgId = (int)($data['package_id'] ?? 0);
$phpVer = $data['php_version'] ?? PHP_DEFAULT;
$webSrv = WEB_SERVER;
if (strlen($username) < 2 || strlen($username) > 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",
"<html><body style='font-family:sans-serif;text-align:center;padding:4rem'><h1>Welcome to {$domain}</h1><p>Hosted by NovaCPX</p></body></html>"
);
// 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 ?: '';
}
}
+3 -1
View File
@@ -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 {
+147
View File
@@ -0,0 +1,147 @@
<?php
/**
* DNSManager — BIND9 zone file generation and management
*/
class DNSManager {
private static string $zonesDir = '/etc/bind/novacpx-zones';
private static string $namedConf = '/etc/bind/named.conf.novacpx';
public static function createZone(int $accountId, string $domain): void {
$db = DB::getInstance();
$serial = (int)date('Ymd') * 100 + 1;
$ns1 = self::getSetting('default_nameserver1', 'ns1.localhost');
$ns2 = self::getSetting('default_nameserver2', 'ns2.localhost');
$email = 'hostmaster.' . $domain;
$ip = self::serverIp();
$zoneId = (int)$db->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;
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
/**
* DatabaseManager — MySQL 8 + PostgreSQL database/user provisioning
*/
class DatabaseManager {
public static function createMySQL(int $accountId, string $dbName, string $dbUser, string $dbPass): int {
self::validateName($dbName); self::validateName($dbUser);
$db = DB::getInstance();
$pdo = $db->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) ?: '';
}
+99
View File
@@ -0,0 +1,99 @@
<?php
/**
* EmailManager — Postfix virtual mailbox + Dovecot user management
* Uses MySQL backend for both Postfix and Dovecot
*/
class EmailManager {
public static function createAccount(int $accountId, string $email, string $password, int $quotaMb = 500): int {
$db = DB::getInstance();
$hashed = self::hashPassword($password);
$id = (int)$db->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)) . '$');
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
/**
* FTPManager — ProFTPD virtual user management via MySQL
*/
class FTPManager {
public static function createAccount(int $accountId, string $username, string $password, string $homeDir, int $quotaMb = 0): int {
$db = DB::getInstance();
$hashed = password_hash($password, PASSWORD_BCRYPT);
// Create system user for FTP (no shell, no home dir creation)
$acct = $db->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;
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
/**
* PHPManager — per-account PHP-FPM pools + version switching
*/
class PHPManager {
private static string $poolDir = '/etc/php/{ver}/fpm/pool.d';
public static function createPool(string $username, string $phpVer): void {
$poolFile = str_replace('{ver}', $phpVer, self::$poolDir) . "/{$username}.conf";
$homeDir = "/home/{$username}";
$sock = "/run/php/php{$phpVer}-fpm-{$username}.sock";
file_put_contents($poolFile, "[{$username}]
user = {$username}
group = www-data
listen = {$sock}
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = ondemand
pm.max_children = 5
pm.process_idle_timeout = 10s
pm.max_requests = 500
php_admin_value[error_log] = {$homeDir}/logs/php.log
php_admin_value[open_basedir] = {$homeDir}/:/tmp/
php_admin_flag[log_errors] = on
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen
php_value[upload_max_filesize] = 64M
php_value[post_max_size] = 64M
php_value[memory_limit] = 256M
php_value[max_execution_time] = 30
");
self::reloadFPM($phpVer);
}
public static function removePool(string $username): void {
foreach (['7.4','8.1','8.2','8.3'] as $ver) {
$file = str_replace('{ver}', $ver, self::$poolDir) . "/{$username}.conf";
if (file_exists($file)) { unlink($file); self::reloadFPM($ver); }
}
}
public static function switchVersion(int $accountId, string $newVer): void {
$db = DB::getInstance();
$acct = $db->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");
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
/**
* SSLManager — Let's Encrypt (Certbot) + custom certificate management
*/
class SSLManager {
public static function issueLetsEncrypt(int $accountId, string $domain, string $email = ''): array {
$db = DB::getInstance();
$webRoot = $db->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']
);
}
}
+150
View File
@@ -0,0 +1,150 @@
<?php
/**
* VhostManager — creates/removes Apache2 and nginx virtual host configs
*/
class VhostManager {
public static function create(string $username, string $domain, string $docRoot, string $phpVer): void {
$logDir = "/home/{$username}/logs";
if (WEB_SERVER === 'nginx') {
self::writeNginx($username, $domain, $docRoot, $phpVer, $logDir);
} else {
self::writeApache($username, $domain, $docRoot, $phpVer, $logDir);
}
self::reload();
}
public static function createSubdomain(string $username, string $subdomain, string $docRoot, string $phpVer): void {
self::create($username, $subdomain, $docRoot, $phpVer);
}
public static function suspend(string $username, string $domain): void {
$suspendedRoot = "/var/novacpx/suspended";
@mkdir($suspendedRoot, 0755, true);
$suspendPage = "{$suspendedRoot}/{$domain}.html";
file_put_contents($suspendPage,
"<html><body style='font-family:sans-serif;text-align:center;padding:4rem;background:#0d0f17;color:#e2e4f0'>"
. "<h1 style='color:#ef4444'>Account Suspended</h1>"
. "<p>This account has been suspended. Please contact support.</p></body></html>"
);
// 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('<VirtualHost *:80>', "<VirtualHost *:443>\n SSLEngine on\n SSLCertificateFile {$certDir}/cert.pem\n SSLCertificateKeyFile {$certDir}/key.pem", $conf);
$conf .= "\n<VirtualHost *:80>\n ServerName {$domain}\n Redirect permanent / https://{$domain}/\n</VirtualHost>";
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, "<VirtualHost *:80>
ServerName {$domain}
ServerAlias www.{$domain}
DocumentRoot {$docRoot}
ErrorLog {$logDir}/error.log
CustomLog {$logDir}/access.log combined
<Directory {$docRoot}>
Options -Indexes +FollowSymLinks +MultiViews
AllowOverride All
Require all granted
</Directory>
<FilesMatch \\.php$>
SetHandler \"proxy:unix:{$sock}|fcgi://localhost/\"
</FilesMatch>
</VirtualHost>
");
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");
}
}
}