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
+79
View File
@@ -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 <<RCCONF
<?php
\$config['db_dsnw'] = 'mysql://roundcube:${RC_DB_PASS}@localhost/roundcube';
\$config['default_host'] = 'localhost';
\$config['default_port'] = 143;
\$config['smtp_server'] = 'localhost';
\$config['smtp_port'] = 587;
\$config['des_key'] = '$(openssl rand -base64 24 | head -c 24)';
\$config['plugins'] = ['archive','attachment_reminder','emoticons','markasjunk','newmail_notifier','zipdownload'];
\$config['skin'] = 'elastic';
\$config['session_lifetime'] = 60;
\$config['product_name'] = 'NovaCPX Webmail';
RCCONF
# Webmail vhost on port 8883
if [[ "$WEB_SERVER" == "nginx" ]]; then
cat >> "$PANEL_WEB_CONF" <<WMNGX
# ── Webmail (8883) ────────────────────────────────────────────────────────────
server {
listen ${PORT_WEBMAIL} ssl http2;
server_name _;
root ${RC_ROOT};
index index.php;
ssl_certificate /etc/novacpx/ssl/novacpx.crt;
ssl_certificate_key /etc/novacpx/ssl/novacpx.key;
location / { try_files \$uri \$uri/ /index.php; }
location ~ \.php$ { fastcgi_pass unix:/run/php/php8.3-fpm.sock; include fastcgi_params; fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name; }
location ~ /\.(ht|git) { deny all; }
}
WMNGX
else
cat >> "$PANEL_WEB_CONF" <<WMAP
# ── Webmail (8883) ────────────────────────────────────────────────────────────
<VirtualHost *:${PORT_WEBMAIL}>
DocumentRoot ${RC_ROOT}
SSLEngine on
SSLCertificateFile /etc/novacpx/ssl/novacpx.crt
SSLCertificateKeyFile /etc/novacpx/ssl/novacpx.key
<Directory ${RC_ROOT}>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
<FilesMatch "\.php$">
SetHandler "proxy:unix:/run/php/php8.3-fpm.sock|fcgi://localhost/"
</FilesMatch>
Header always set X-NovaCPX-Portal "webmail"
</VirtualHost>
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 <<DONE
║ User Panel: https://${SERVER_IP}:${PORT_USER}
║ Reseller Panel: https://${SERVER_IP}:${PORT_RESELLER}
║ Admin Panel: https://${SERVER_IP}:${PORT_ADMIN}
║ Webmail: https://${SERVER_IP}:${PORT_WEBMAIL}
║ Username: admin
║ Password: ${ADMIN_PASS}
╠══════════════════════════════════════════════════════════════╣
+145
View File
@@ -0,0 +1,145 @@
<?php
/**
* Accounts endpoint — create/list/suspend/terminate hosting accounts
* Admin + Reseller access
*/
Auth::getInstance()->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),
};
+69
View File
@@ -0,0 +1,69 @@
<?php
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$user = Auth::getInstance()->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),
};
+60
View File
@@ -0,0 +1,60 @@
<?php
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
require_once NOVACPX_LIB . '/DatabaseManager.php';
$user = Auth::getInstance()->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),
};
+101
View File
@@ -0,0 +1,101 @@
<?php
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
require_once NOVACPX_LIB . '/DNSManager.php';
// Resolve account_id from current user or from query param (admin)
$user = Auth::getInstance()->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),
};
+123
View File
@@ -0,0 +1,123 @@
<?php
/**
* Domains endpoint — addon domains, subdomains, parked/aliased domains
*/
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
require_once NOVACPX_LIB . '/VhostManager.php';
require_once NOVACPX_LIB . '/DNSManager.php';
$user = Auth::getInstance()->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", "<html><body><h1>$domain is ready!</h1><p>Upload your website files.</p></body></html>");
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", "<html><body><h1>$full</h1></body></html>");
$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),
};
+94
View File
@@ -0,0 +1,94 @@
<?php
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
require_once NOVACPX_LIB . '/EmailManager.php';
$user = Auth::getInstance()->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),
};
+140
View File
@@ -0,0 +1,140 @@
<?php
/**
* Files endpoint — file manager (list, read, write, rename, delete, chmod, upload, archive)
* Strictly confined to account home directory via realpath check
*/
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$user = Auth::getInstance()->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),
};
+40
View File
@@ -0,0 +1,40 @@
<?php
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
require_once NOVACPX_LIB . '/FTPManager.php';
$user = Auth::getInstance()->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),
};
+74
View File
@@ -0,0 +1,74 @@
<?php
Auth::getInstance()->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),
};
+56
View File
@@ -0,0 +1,56 @@
<?php
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
require_once NOVACPX_LIB . '/PHPManager.php';
$user = Auth::getInstance()->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),
};
+110
View File
@@ -0,0 +1,110 @@
<?php
/**
* Server Setup endpoint — hostname, contact info, nameservers, branding
*/
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
Auth::getInstance()->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),
};
+62
View File
@@ -0,0 +1,62 @@
<?php
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
require_once NOVACPX_LIB . '/SSLManager.php';
require_once NOVACPX_LIB . '/VhostManager.php';
$user = Auth::getInstance()->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),
};
+114
View File
@@ -0,0 +1,114 @@
<?php
/**
* Stats endpoint — resource usage history, charts, per-account usage
*/
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$user = Auth::getInstance()->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),
};
+53
View File
@@ -0,0 +1,53 @@
<?php
/**
* Webmail endpoint Roundcube integration proxy
* Redirects authenticated users to Roundcube with SSO token
*/
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$user = Auth::getInstance()->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),
};
+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");
}
}
}
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#0d0f17"/>
<polygon points="16,3 27,9 27,21 16,27 5,21 5,9" fill="none" stroke="#6366f1" stroke-width="2"/>
<circle cx="16" cy="15" r="5" fill="none" stroke="#0ea5e9" stroke-width="1.5" stroke-dasharray="2 1.5"/>
<circle cx="16" cy="15" r="2.5" fill="#6366f1"/>
<circle cx="16" cy="9.5" r="1.2" fill="#6366f1"/>
<circle cx="21" cy="18" r="1.2" fill="#0ea5e9"/>
<circle cx="11" cy="18" r="1.2" fill="#10b981"/>
</svg>

After

Width:  |  Height:  |  Size: 534 B

+329
View File
@@ -0,0 +1,329 @@
<!-- NovaCPX Custom Icon Sprite — inline <use href="#icon-name"/> -->
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<!-- Dashboard / home -->
<symbol id="ni-dashboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="9" height="9" rx="1.5"/>
<rect x="13" y="3" width="9" height="5" rx="1.5"/>
<rect x="13" y="11" width="9" height="9" rx="1.5"/>
<rect x="2" y="15" width="9" height="5" rx="1.5"/>
</symbol>
<!-- Accounts / users -->
<symbol id="ni-accounts" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="7" r="4"/>
<path d="M2 21c0-4 3-7 7-7h4c4 0 7 3 7 7"/>
<circle cx="19" cy="9" r="2.5"/>
<path d="M22 19c0-2.5-1.5-4.5-3.5-5.5"/>
</symbol>
<!-- Resellers -->
<symbol id="ni-resellers" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12,2 15,8.5 22,9.5 17,14 18.5,21 12,17.5 5.5,21 7,14 2,9.5 9,8.5"/>
</symbol>
<!-- Packages -->
<symbol id="ni-packages" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</symbol>
<!-- DNS -->
<symbol id="ni-dns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M12 2a14.5 14.5 0 0 0 0 20A14.5 14.5 0 0 0 12 2"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<line x1="12" y1="2" x2="12" y2="22"/>
</symbol>
<!-- Email -->
<symbol id="ni-email" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
<line x1="12" y1="13" x2="12" y2="20"/>
</symbol>
<!-- Databases -->
<symbol id="ni-databases" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"/>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
</symbol>
<!-- FTP -->
<symbol id="ni-ftp" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="8" height="6" rx="1"/>
<rect x="13" y="3" width="8" height="6" rx="1"/>
<rect x="3" y="13" width="8" height="6" rx="1"/>
<path d="M17 13v4m-2-2h4"/>
</symbol>
<!-- SSL / Lock -->
<symbol id="ni-ssl" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="11" width="14" height="11" rx="2"/>
<path d="M8 11V7a4 4 0 1 1 8 0v4"/>
<circle cx="12" cy="16" r="1.5" fill="currentColor" stroke="none"/>
<line x1="12" y1="17.5" x2="12" y2="19.5"/>
</symbol>
<!-- PHP -->
<symbol id="ni-php" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<ellipse cx="12" cy="12" rx="10" ry="6"/>
<path d="M9 10v4m0-4h2a1.5 1.5 0 0 1 0 3H9"/>
<path d="M14 10v4m0-4h2a1.5 1.5 0 0 1 0 3H14"/>
</symbol>
<!-- Cron / clock -->
<symbol id="ni-cron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
<path d="M16 2l2 3M8 2L6 5"/>
</symbol>
<!-- File Manager -->
<symbol id="ni-files" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<line x1="9" y1="14" x2="15" y2="14"/>
<line x1="12" y1="11" x2="12" y2="17"/>
</symbol>
<!-- Domains / Web -->
<symbol id="ni-domains" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="13" rx="2"/>
<line x1="3" y1="7" x2="21" y2="7"/>
<line x1="7" y1="21" x2="17" y2="21"/>
<line x1="12" y1="16" x2="12" y2="21"/>
<circle cx="6" cy="5" r="0.8" fill="currentColor" stroke="none"/>
<circle cx="9" cy="5" r="0.8" fill="currentColor" stroke="none"/>
</symbol>
<!-- Server / System -->
<symbol id="ni-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="7" rx="1.5"/>
<rect x="2" y="13" width="20" height="7" rx="1.5"/>
<circle cx="6" cy="6.5" r="1.2" fill="currentColor" stroke="none"/>
<circle cx="6" cy="16.5" r="1.2" fill="currentColor" stroke="none"/>
<line x1="10" y1="6.5" x2="16" y2="6.5"/>
<line x1="10" y1="16.5" x2="16" y2="16.5"/>
</symbol>
<!-- Features / Plugin -->
<symbol id="ni-features" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.5 2H9.5a1 1 0 0 0-.9.5l-4 7a1 1 0 0 0 0 1l4 7a1 1 0 0 0 .9.5h5a1 1 0 0 0 .9-.5l4-7a1 1 0 0 0 0-1l-4-7a1 1 0 0 0-.9-.5z"/>
<circle cx="12" cy="12" r="2.5"/>
</symbol>
<!-- Updates / Git -->
<symbol id="ni-updates" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 .49-3.51"/>
<polyline points="12 7 12 12 15 15"/>
</symbol>
<!-- Firewall / Shield -->
<symbol id="ni-firewall" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="15" x2="12.01" y2="15"/>
</symbol>
<!-- Backups -->
<symbol id="ni-backups" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<polyline points="21 15 21 21 15 21"/>
<polyline points="3 9 3 3 9 3"/>
<path d="M21 3L14 10"/>
<path d="M3 21l7-7"/>
<circle cx="12" cy="12" r="3"/>
</symbol>
<!-- Settings / Gear -->
<symbol id="ni-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3.5"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</symbol>
<!-- Audit Log -->
<symbol id="ni-audit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="13" x2="16" y2="13"/>
<line x1="8" y1="17" x2="12" y2="17"/>
<circle cx="16" cy="17" r="2"/>
<line x1="18" y1="19" x2="20" y2="21"/>
</symbol>
<!-- Webmail -->
<symbol id="ni-webmail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22c5.52 0 10-4.48 10-10S17.52 2 12 2 2 6.48 2 12s4.48 10 10 10z"/>
<path d="M12 8a4 4 0 0 1 0 8 4 4 0 0 1 0-8z"/>
<path d="M16 12h6M2 12h6"/>
</symbol>
<!-- WordPress -->
<symbol id="ni-wordpress" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M2.48 12s3.22 7 9.52 7 9.52-7 9.52-7"/>
<path d="M12 2c2.8 2 5 5.8 5 10s-2.2 8-5 10"/>
<path d="M12 2c-2.8 2-5 5.8-5 10s2.2 8 5 10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
</symbol>
<!-- Docker -->
<symbol id="ni-docker" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="9" width="4" height="4" rx="0.5"/>
<rect x="7" y="9" width="4" height="4" rx="0.5"/>
<rect x="12" y="9" width="4" height="4" rx="0.5"/>
<rect x="7" y="4" width="4" height="4" rx="0.5"/>
<path d="M18 11a4 4 0 0 1 4 4c0 3-3 5-7 5H6c-2 0-4-1.5-4-4 0-1.8 1-3.5 3-4.5"/>
<path d="M20 7s-1-1-3-1"/>
<circle cx="20" cy="6" r="1" fill="currentColor" stroke="none"/>
</symbol>
<!-- Node.js -->
<symbol id="ni-nodejs" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12,2 22,7.5 22,16.5 12,22 2,16.5 2,7.5"/>
<path d="M12 7v5l4 2.5"/>
</symbol>
<!-- Redis -->
<symbol id="ni-redis" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<ellipse cx="12" cy="8" rx="9" ry="3"/>
<path d="M3 8v4c0 1.66 4.03 3 9 3s9-1.34 9-3V8"/>
<path d="M3 12v4c0 1.66 4.03 3 9 3s9-1.34 9-3v-4"/>
<path d="M8 6l2 1 4-2"/>
</symbol>
<!-- Cloudflare -->
<symbol id="ni-cloudflare" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M17.5 16c.83-1.5.5-4-2.5-4.5l.3-1.5C18 9.5 21 11 21 14.5c0 1-.2 2-.8 2.5H17.5z"/>
<path d="M6.5 16H17m-3-4.5C12.5 8 8 8 6 10.5c-1 1.5-1 3-0.5 4.5"/>
<path d="M3 15c0 .55.45 1 1 1h1"/>
</symbol>
<!-- Gitea -->
<symbol id="ni-git" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="18" cy="18" r="3"/>
<circle cx="6" cy="6" r="3"/>
<circle cx="6" cy="18" r="3"/>
<path d="M6 9v6"/>
<path d="M15.7 5.7l-9.4 12.6"/>
</symbol>
<!-- Suspend / pause -->
<symbol id="ni-suspend" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="10" y1="8" x2="10" y2="16"/>
<line x1="14" y1="8" x2="14" y2="16"/>
</symbol>
<!-- Terminate / X -->
<symbol id="ni-terminate" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</symbol>
<!-- Stats / Chart -->
<symbol id="ni-stats" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</symbol>
<!-- Notifications / bell -->
<symbol id="ni-notifications" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</symbol>
<!-- Hostname -->
<symbol id="ni-hostname" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
<line x1="12" y1="15" x2="12" y2="18"/>
<circle cx="12" cy="15" r="1" fill="currentColor" stroke="none"/>
</symbol>
<!-- Add / Plus -->
<symbol id="ni-add" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="16"/>
<line x1="8" y1="12" x2="16" y2="12"/>
</symbol>
<!-- User profile -->
<symbol id="ni-profile" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="8" r="4"/>
<path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8"/>
</symbol>
<!-- Logout -->
<symbol id="ni-logout" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</symbol>
<!-- Search -->
<symbol id="ni-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</symbol>
<!-- Copy -->
<symbol id="ni-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</symbol>
<!-- Key / API -->
<symbol id="ni-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
</symbol>
<!-- Mail server -->
<symbol id="ni-mailserver" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="6" width="20" height="14" rx="2"/>
<path d="M22 6l-10 7L2 6"/>
<path d="M2 6l4-4h12l4 4"/>
</symbol>
<!-- Docs / book -->
<symbol id="ni-docs" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
<line x1="8" y1="7" x2="16" y2="7"/>
<line x1="8" y1="11" x2="14" y2="11"/>
</symbol>
<!-- Contact / support -->
<symbol id="ni-support" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<line x1="9" y1="10" x2="9.01" y2="10"/>
<line x1="12" y1="10" x2="12.01" y2="10"/>
<line x1="15" y1="10" x2="15.01" y2="10"/>
</symbol>
<!-- ModSecurity / WAF -->
<symbol id="ni-waf" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<polyline points="9 12 11 14 15 10"/>
</symbol>
<!-- Varnish / cache -->
<symbol id="ni-cache" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</symbol>
<!-- Network / IP -->
<symbol id="ni-network" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="2" width="6" height="4" rx="1"/>
<rect x="2" y="18" width="6" height="4" rx="1"/>
<rect x="16" y="18" width="6" height="4" rx="1"/>
<rect x="9" y="18" width="6" height="4" rx="1"/>
<line x1="12" y1="6" x2="12" y2="14"/>
<path d="M5 20v-6h14v6"/>
<line x1="12" y1="14" x2="5" y2="14"/>
<line x1="12" y1="14" x2="19" y2="14"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

+26
View File
@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 48">
<defs>
<linearGradient id="ng" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6366f1"/>
<stop offset="60%" stop-color="#0ea5e9"/>
<stop offset="100%" stop-color="#10b981"/>
</linearGradient>
<linearGradient id="ng2" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#6366f1" stop-opacity="1"/>
<stop offset="100%" stop-color="#0ea5e9" stop-opacity="0.7"/>
</linearGradient>
</defs>
<!-- Hexagon core mark -->
<polygon points="24,4 40,13 40,31 24,40 8,31 8,13" fill="none" stroke="url(#ng)" stroke-width="2.5"/>
<!-- Inner orbit ring -->
<circle cx="24" cy="22" r="7" fill="none" stroke="url(#ng)" stroke-width="1.5" stroke-dasharray="3 2"/>
<!-- Central dot -->
<circle cx="24" cy="22" r="3" fill="url(#ng)"/>
<!-- Orbit node dots -->
<circle cx="24" cy="14" r="1.5" fill="#6366f1"/>
<circle cx="31" cy="26" r="1.5" fill="#0ea5e9"/>
<circle cx="17" cy="26" r="1.5" fill="#10b981"/>
<!-- Wordmark -->
<text x="52" y="32" font-family="'SF Pro Display','Segoe UI',sans-serif" font-size="22" font-weight="700" fill="url(#ng)" letter-spacing="-0.5">Nova</text>
<text x="118" y="32" font-family="'SF Pro Display','Segoe UI',sans-serif" font-size="22" font-weight="300" fill="#94a3b8" letter-spacing="2">CPX</text>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<defs>
<linearGradient id="mg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6366f1"/>
<stop offset="60%" stop-color="#0ea5e9"/>
<stop offset="100%" stop-color="#10b981"/>
</linearGradient>
</defs>
<polygon points="24,4 40,13 40,31 24,40 8,31 8,13" fill="none" stroke="url(#mg)" stroke-width="2.5"/>
<circle cx="24" cy="22" r="7" fill="none" stroke="url(#mg)" stroke-width="1.5" stroke-dasharray="3 2"/>
<circle cx="24" cy="22" r="3" fill="url(#mg)"/>
<circle cx="24" cy="14" r="1.5" fill="#6366f1"/>
<circle cx="31" cy="26" r="1.5" fill="#0ea5e9"/>
<circle cx="17" cy="26" r="1.5" fill="#10b981"/>
</svg>

After

Width:  |  Height:  |  Size: 731 B