mirror of
https://github.com/myronblair/novacpx
synced 2026-07-28 05:03:14 -05:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d6592e0e2 | |||
| a5b2a67708 | |||
| 233a2105ed | |||
| 321a88fa28 | |||
| ffb0683d4c | |||
| 80e964ee8f | |||
| bebf01658d | |||
| bc06cb1f22 | |||
| 1f179f1732 | |||
| f4726c2b2e | |||
| 89c1ffd184 | |||
| 4e371e8f2f | |||
| bc17356249 | |||
| 5d1f3aa241 | |||
| 32e374a2d0 | |||
| 2fa1f10901 | |||
| 5037633f5f | |||
| 658e2f9057 | |||
| 7107d230c8 | |||
| e6550f0a90 | |||
| b0ac6e7aa2 | |||
| b9c37030b6 |
@@ -79,6 +79,9 @@ while IFS='|' read -r REPO_PATH WEB_ROOT COMMIT QUEUED_BRANCH; do
|
||||
rsync -av "$REPO_PATH/panel/bin/" /opt/novacpx/bin/ >> "$LOG" 2>&1
|
||||
chmod +x /opt/novacpx/bin/*.php 2>/dev/null || true
|
||||
|
||||
# Copy VERSION to web root so /VERSION endpoint stays current
|
||||
[[ -f "$REPO_PATH/VERSION" ]] && cp "$REPO_PATH/VERSION" "$WEB_ROOT/VERSION"
|
||||
|
||||
# Run pending DB migrations (SQLite)
|
||||
MIGR_DIR="$REPO_PATH/db/migrations"
|
||||
if [[ -d "$MIGR_DIR" && -f "$DB_PATH" ]]; then
|
||||
|
||||
+78
-15
@@ -636,29 +636,92 @@ log "Fail2Ban configured"
|
||||
# ── Sudoers for NovaCPX panel (www-data needs root for firewall/opendkim) ────
|
||||
cat > /etc/sudoers.d/novacpx-firewall <<SUDOERS
|
||||
Defaults:www-data !requiretty
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw *
|
||||
# Firewall / security
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw status
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw status verbose
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw allow *
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw deny *
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw delete *
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw reload
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw enable
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw disable
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw logging *
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client *
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart fail2ban
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl reload fail2ban
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start fail2ban
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop fail2ban
|
||||
# Web servers
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start apache2
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop apache2
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart apache2
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl reload apache2
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl enable apache2
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start nginx
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop nginx
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart nginx
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl reload nginx
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart apache2
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl reload apache2
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl enable nginx
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/nginx *
|
||||
# DB tool installation privileges
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/gpg *
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/curl *
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/debconf-set-selections *
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/tee /etc/apt/sources.list.d/*
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/tee /usr/share/keyrings/*
|
||||
# Mail servers
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start postfix
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop postfix
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart postfix
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl reload postfix
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start dovecot
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop dovecot
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart dovecot
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl reload dovecot
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start rspamd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop rspamd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart rspamd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl enable rspamd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl disable rspamd
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/postqueue -f
|
||||
# FTP servers
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start proftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop proftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart proftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl reload proftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl enable proftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start vsftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop vsftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart vsftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl enable vsftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start pure-ftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop pure-ftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart pure-ftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl enable pure-ftpd
|
||||
# DNS servers
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start named
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop named
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart named
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl reload named
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start bind9
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop bind9
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart bind9
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start pdns
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop pdns
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart pdns
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start nsd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop nsd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart nsd
|
||||
# Database servers
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start mysql
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop mysql
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart mysql
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start mariadb
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop mariadb
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart mariadb
|
||||
# Security
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start fail2ban
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop fail2ban
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart fail2ban
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl reload fail2ban
|
||||
# PHP-FPM
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl reload php*-fpm
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart php*-fpm
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start php*-fpm
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop php*-fpm
|
||||
# Web config file management (scoped paths only)
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/tee /etc/nginx/conf.d/*
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/tee /etc/apache2/conf-enabled/*
|
||||
www-data ALL=(root) NOPASSWD: /usr/pgadmin4/bin/setup-web.sh *
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/env *
|
||||
SUDOERS
|
||||
chmod 440 /etc/sudoers.d/novacpx-firewall
|
||||
log "Sudoers rules installed"
|
||||
|
||||
@@ -29,6 +29,36 @@ match ($action) {
|
||||
Response::success(null, $msg);
|
||||
})(),
|
||||
|
||||
'uninstall-account' => (function() use ($dm, $currentUser, $isAdmin, $_userAccountId, $body) {
|
||||
// Stop and remove all containers and stacks belonging to one account.
|
||||
// Users can only remove their own; admins can specify any account_id.
|
||||
$accountId = $isAdmin ? (int)($body['account_id'] ?? $_userAccountId) : ($_userAccountId ?? 0);
|
||||
if (!$accountId) Response::error('account_id required');
|
||||
|
||||
$db = DB::getInstance();
|
||||
|
||||
// Tear down compose stacks
|
||||
$stacks = $db->fetchAll("SELECT * FROM docker_compose_stacks WHERE account_id = ?", [$accountId]);
|
||||
foreach ($stacks as $stack) {
|
||||
if (is_dir($stack['stack_dir']) && file_exists("{$stack['stack_dir']}/docker-compose.yml")) {
|
||||
shell_exec("sudo docker compose -f " . escapeshellarg("{$stack['stack_dir']}/docker-compose.yml") . " down -v 2>/dev/null");
|
||||
}
|
||||
$db->execute("DELETE FROM docker_compose_stacks WHERE id = ?", [$stack['id']]);
|
||||
}
|
||||
|
||||
// Stop and remove bare containers
|
||||
$containers = $db->fetchAll("SELECT container_id FROM docker_containers WHERE account_id = ?", [$accountId]);
|
||||
foreach ($containers as $c) {
|
||||
if ($c['container_id']) {
|
||||
shell_exec("sudo docker rm -f " . escapeshellarg($c['container_id']) . " 2>/dev/null");
|
||||
}
|
||||
}
|
||||
$db->execute("DELETE FROM docker_containers WHERE account_id = ?", [$accountId]);
|
||||
|
||||
audit("docker.uninstall-account", "account:{$accountId}");
|
||||
Response::success(null, 'All Docker apps and containers removed for this account');
|
||||
})(),
|
||||
|
||||
'prune' => (function() use ($dm, $body, $isAdmin) {
|
||||
if (!$isAdmin) Response::error('Admin only', 403);
|
||||
$out = $dm->systemPrune((bool)($body['volumes'] ?? false));
|
||||
@@ -163,20 +193,42 @@ match ($action) {
|
||||
Response::success($result, 'Stack created');
|
||||
})(),
|
||||
|
||||
'stack-action' => (function() use ($dm, $body, $isAdmin) {
|
||||
if (!$isAdmin) Response::error('Admin only', 403);
|
||||
'stack-action' => (function() use ($dm, $body, $isAdmin, $_userAccountId) {
|
||||
$id = (int)($body['stack_id'] ?? 0);
|
||||
$act = $body['action'] ?? '';
|
||||
if (!$id || !$act) Response::error('stack_id and action required');
|
||||
// Non-admins can only act on their own stacks
|
||||
if (!$isAdmin) {
|
||||
$stack = DB::getInstance()->fetchOne("SELECT account_id FROM docker_compose_stacks WHERE id = ?", [$id]);
|
||||
if (!$stack || (int)$stack['account_id'] !== (int)$_userAccountId) Response::error('Not found', 404);
|
||||
}
|
||||
$out = $dm->composeAction($id, $act);
|
||||
audit("docker.stack.{$act}", "stack:{$id}");
|
||||
Response::success(['output' => $out]);
|
||||
})(),
|
||||
|
||||
'stack-remove' => (function() use ($dm, $body, $isAdmin) {
|
||||
if (!$isAdmin) Response::error('Admin only', 403);
|
||||
'stack-reinstall' => (function() use ($dm, $body, $isAdmin, $_userAccountId) {
|
||||
$id = (int)($body['stack_id'] ?? 0);
|
||||
if (!$id) Response::error('stack_id required');
|
||||
if (!$isAdmin) {
|
||||
$stack = DB::getInstance()->fetchOne("SELECT account_id FROM docker_compose_stacks WHERE id = ?", [$id]);
|
||||
if (!$stack || (int)$stack['account_id'] !== (int)$_userAccountId) Response::error('Not found', 404);
|
||||
}
|
||||
// Pull latest images, bring down (removing volumes), then start fresh
|
||||
$dm->composeAction($id, 'pull');
|
||||
$dm->composeAction($id, 'down');
|
||||
$out = $dm->composeAction($id, 'up');
|
||||
audit('docker.stack.reinstall', "stack:{$id}");
|
||||
Response::success(['output' => $out], 'Stack reinstalled');
|
||||
})(),
|
||||
|
||||
'stack-remove' => (function() use ($dm, $body, $isAdmin, $_userAccountId) {
|
||||
$id = (int)($body['stack_id'] ?? 0);
|
||||
if (!$id) Response::error('stack_id required');
|
||||
if (!$isAdmin) {
|
||||
$stack = DB::getInstance()->fetchOne("SELECT account_id FROM docker_compose_stacks WHERE id = ?", [$id]);
|
||||
if (!$stack || (int)$stack['account_id'] !== (int)$_userAccountId) Response::error('Not found', 404);
|
||||
}
|
||||
$dm->removeStack($id);
|
||||
audit('docker.stack.remove', "stack:{$id}");
|
||||
Response::success(null, 'Stack removed');
|
||||
|
||||
@@ -20,5 +20,80 @@ match ($action) {
|
||||
Response::success($rows);
|
||||
})(),
|
||||
|
||||
// Create a panel user (reseller or admin) — no hosting account provisioned
|
||||
'create' => (function() use ($db, $body) {
|
||||
$username = trim($body['username'] ?? '');
|
||||
$password = $body['password'] ?? '';
|
||||
$email = trim($body['email'] ?? '');
|
||||
$role = $body['role'] ?? 'reseller';
|
||||
|
||||
if (!$username || !$password || !$email) Response::error('username, password and email are required');
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) Response::error('Invalid email address');
|
||||
if (!in_array($role, ['reseller', 'admin'], true)) Response::error('role must be reseller or admin');
|
||||
if (strlen($password) < 8) Response::error('Password must be at least 8 characters');
|
||||
if (!preg_match('/^[a-zA-Z0-9_\-\.]+$/', $username)) Response::error('Username may only contain letters, numbers, _ - .');
|
||||
|
||||
$exists = $db->fetchOne("SELECT id FROM users WHERE username=? OR email=?", [$username, $email]);
|
||||
if ($exists) Response::error('Username or email already in use');
|
||||
|
||||
$uid = (int)$db->insert(
|
||||
"INSERT INTO users (username, password, email, role, status, created_at) VALUES (?,?,?,?,?,datetime('now'))",
|
||||
[$username, password_hash($password, PASSWORD_BCRYPT), $email, $role, 'active']
|
||||
);
|
||||
audit("user.create.{$role}", "user:{$username}");
|
||||
Response::success(['id' => $uid, 'username' => $username, 'email' => $email, 'role' => $role], ucfirst($role) . ' created');
|
||||
})(),
|
||||
|
||||
// Suspend a panel user (sets status=suspended on the users row)
|
||||
'suspend' => (function() use ($db, $body) {
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
if (!$id) Response::error('id required');
|
||||
$u = $db->fetchOne("SELECT id, username, role FROM users WHERE id=?", [$id]);
|
||||
if (!$u) Response::error('User not found', 404);
|
||||
if ($u['role'] === 'admin') Response::error('Cannot suspend admin users');
|
||||
$db->execute("UPDATE users SET status='suspended' WHERE id=?", [$id]);
|
||||
audit('user.suspend', "user:{$u['username']}");
|
||||
Response::success(null, 'User suspended');
|
||||
})(),
|
||||
|
||||
// Unsuspend a panel user
|
||||
'unsuspend' => (function() use ($db, $body) {
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
if (!$id) Response::error('id required');
|
||||
$u = $db->fetchOne("SELECT id, username FROM users WHERE id=?", [$id]);
|
||||
if (!$u) Response::error('User not found', 404);
|
||||
$db->execute("UPDATE users SET status='active' WHERE id=?", [$id]);
|
||||
audit('user.unsuspend', "user:{$u['username']}");
|
||||
Response::success(null, 'User unsuspended');
|
||||
})(),
|
||||
|
||||
// Change password for a panel user by user id
|
||||
'change-password' => (function() use ($db, $body) {
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
$pass = $body['password'] ?? '';
|
||||
if (!$id) Response::error('id required');
|
||||
if (strlen($pass) < 8) Response::error('Password must be at least 8 characters');
|
||||
$u = $db->fetchOne("SELECT id, username FROM users WHERE id=?", [$id]);
|
||||
if (!$u) Response::error('User not found', 404);
|
||||
$db->execute("UPDATE users SET password=? WHERE id=?", [password_hash($pass, PASSWORD_BCRYPT), $id]);
|
||||
audit('user.change-password', "user:{$u['username']}");
|
||||
Response::success(null, 'Password updated');
|
||||
})(),
|
||||
|
||||
// Delete a panel user (admin or reseller — not a hosting account user)
|
||||
'delete' => (function() use ($db, $body) {
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
if (!$id) Response::error('id required');
|
||||
$user = $db->fetchOne("SELECT id, username, role FROM users WHERE id=?", [$id]);
|
||||
if (!$user) Response::error('User not found', 404);
|
||||
if ($user['role'] === 'admin') Response::error('Cannot delete admin users');
|
||||
// Disown accounts under this reseller rather than deleting them
|
||||
$db->execute("UPDATE users SET reseller_id=NULL WHERE reseller_id=?", [$id]);
|
||||
$db->execute("UPDATE accounts SET reseller_id=NULL WHERE reseller_id=?", [$id]);
|
||||
$db->execute("DELETE FROM users WHERE id=?", [$id]);
|
||||
audit('user.delete', "user:{$user['username']}");
|
||||
Response::success(null, 'User deleted');
|
||||
})(),
|
||||
|
||||
default => Response::error("Unknown users action: $action", 404),
|
||||
};
|
||||
|
||||
@@ -87,6 +87,7 @@ class AccountManager {
|
||||
}
|
||||
|
||||
public static function terminate(int $acctId): void {
|
||||
require_once NOVACPX_LIB . '/DatabaseManager.php';
|
||||
$db = DB::getInstance();
|
||||
$acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$acctId]);
|
||||
if (!$acct) throw new RuntimeException("Account not found");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,10 @@ php_value[max_execution_time] = 30
|
||||
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)) { shell_exec("sudo rm -f " . escapeshellarg($file)); self::reloadFPM($ver); }
|
||||
// Always attempt removal — don't rely on file_exists() which fails when
|
||||
// www-data can't read the pool.d directory; rm -f is a no-op if missing
|
||||
shell_exec("sudo rm -f " . escapeshellarg($file) . " 2>/dev/null");
|
||||
self::reloadFPM($ver);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +65,8 @@ php_value[max_execution_time] = 30
|
||||
|
||||
// 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); }
|
||||
shell_exec("sudo rm -f " . escapeshellarg($oldPool) . " 2>/dev/null");
|
||||
self::reloadFPM($oldVer);
|
||||
|
||||
self::createPool($acct['username'], $newVer);
|
||||
|
||||
@@ -97,10 +101,12 @@ php_value[max_execution_time] = 30
|
||||
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()",
|
||||
"INSERT INTO php_configs (account_id, php_version, memory_limit, max_execution_time, upload_max_filesize, post_max_size, updated_at)
|
||||
VALUES (?,?,?,?,?,?,datetime('now'))
|
||||
ON CONFLICT(account_id) DO UPDATE SET php_version=excluded.php_version,
|
||||
memory_limit=excluded.memory_limit, max_execution_time=excluded.max_execution_time,
|
||||
upload_max_filesize=excluded.upload_max_filesize, post_max_size=excluded.post_max_size,
|
||||
updated_at=excluded.updated_at",
|
||||
[$accountId, $acct['php_version'], $cfg['memory_limit'] ?? '256M', $cfg['max_execution_time'] ?? 30,
|
||||
$cfg['upload_max_filesize'] ?? '64M', $cfg['post_max_size'] ?? '64M']
|
||||
);
|
||||
@@ -112,6 +118,6 @@ php_value[max_execution_time] = 30
|
||||
}
|
||||
|
||||
private static function reloadFPM(string $ver): void {
|
||||
shell_exec("systemctl reload php{$ver}-fpm 2>/dev/null || true");
|
||||
shell_exec("sudo systemctl reload php{$ver}-fpm 2>/dev/null || true");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ class WordPressManager {
|
||||
$stagingRoot = dirname($docRoot) . rtrim($stagingPath, '/');
|
||||
|
||||
// Copy files
|
||||
$this->exec("cp -r {$docRoot} {$stagingRoot}");
|
||||
$this->exec("cp -r " . escapeshellarg($docRoot) . " " . escapeshellarg($stagingRoot));
|
||||
|
||||
// Clone DB
|
||||
$stagingDb = $install['db_name'] . '_staging';
|
||||
@@ -119,7 +119,7 @@ class WordPressManager {
|
||||
$this->getProvDb()->exec("CREATE DATABASE IF NOT EXISTS `{$stagingDb}`");
|
||||
$this->getProvDb()->exec("CREATE USER IF NOT EXISTS '{$stagingDb}'@'localhost' IDENTIFIED BY '{$stagingDbPw}'");
|
||||
$this->getProvDb()->exec("GRANT ALL ON `{$stagingDb}`.* TO '{$stagingDb}'@'localhost'");
|
||||
$this->exec("mysqldump {$install['db_name']} | mysql {$stagingDb}");
|
||||
$this->exec("mysqldump " . escapeshellarg($install['db_name']) . " | mysql " . escapeshellarg($stagingDb));
|
||||
|
||||
// Update staging wp-config
|
||||
$this->wp($stagingRoot, "config set DB_NAME {$stagingDb}", $sysUser);
|
||||
@@ -141,10 +141,11 @@ class WordPressManager {
|
||||
// ── Delete ────────────────────────────────────────────────────────────────
|
||||
public function delete(int $id): bool {
|
||||
[$install, $sysUser, $docRoot] = $this->resolve($id);
|
||||
$this->exec("rm -rf {$docRoot}");
|
||||
// Remove DB record first so a failed filesystem cleanup doesn't leave a phantom record
|
||||
$this->db->prepare("DELETE FROM wordpress_installs WHERE id=?")->execute([$id]);
|
||||
$this->exec("rm -rf " . escapeshellarg($docRoot));
|
||||
$this->getProvDb()->exec("DROP DATABASE IF EXISTS `{$install['db_name']}`");
|
||||
$this->getProvDb()->exec("DROP USER IF EXISTS '{$install['db_user']}'@'localhost'");
|
||||
$this->db->prepare("DELETE FROM wordpress_installs WHERE id=?")->execute([$id]);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -245,7 +246,12 @@ class WordPressManager {
|
||||
|
||||
private function ensureWpCli(): void {
|
||||
if (!file_exists($this->wpcli)) {
|
||||
file_put_contents('/tmp/wp-cli.phar', file_get_contents('https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar'));
|
||||
$ctx = stream_context_create(['http' => ['timeout' => 30]]);
|
||||
$data = @file_get_contents('https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar', false, $ctx);
|
||||
if (!$data || strlen($data) < 100000) {
|
||||
throw new \RuntimeException("Failed to download WP-CLI (received " . strlen((string)$data) . " bytes)");
|
||||
}
|
||||
file_put_contents('/tmp/wp-cli.phar', $data);
|
||||
rename('/tmp/wp-cli.phar', $this->wpcli);
|
||||
chmod($this->wpcli, 0755);
|
||||
}
|
||||
|
||||
+124
-13
@@ -1166,7 +1166,7 @@
|
||||
|
||||
// ── Resellers ──────────────────────────────────────────────────────────────
|
||||
async function resellers() {
|
||||
const res = await Nova.api('accounts', 'list', { params:{ role: 'reseller' }});
|
||||
const res = await Nova.api('users', 'list', { params:{ role: 'reseller' }});
|
||||
const rows = res?.data || [];
|
||||
return `
|
||||
<div class="card">
|
||||
@@ -1175,14 +1175,18 @@
|
||||
<button class="btn btn-primary btn-sm" onclick="adminAddReseller()">+ Add Reseller</button>
|
||||
</div>
|
||||
<div id="reseller-table">
|
||||
${rows.length ? `<table class="table"><thead><tr><th>Username</th><th>Email</th><th>Accounts</th><th>Status</th><th>Actions</th></tr></thead><tbody>
|
||||
${rows.length ? `<table class="table"><thead><tr><th>Username</th><th>Email</th><th>Status</th><th>Created</th><th>Actions</th></tr></thead><tbody>
|
||||
${rows.map(r => `<tr>
|
||||
<td>${r.username}</td><td>${r.email||'—'}</td>
|
||||
<td>${r.account_count||0}</td>
|
||||
<td>${Nova.badge(r.status,r.status==='active'?'green':'red')}</td>
|
||||
<td><strong>${Nova.escHtml(r.username)}</strong></td>
|
||||
<td>${Nova.escHtml(r.email||'—')}</td>
|
||||
<td>${Nova.badge(r.status, r.status==='active'?'green':'red')}</td>
|
||||
<td class="text-sm text-muted">${r.created_at ? r.created_at.slice(0,10) : '—'}</td>
|
||||
<td style="display:flex;gap:.25rem">
|
||||
<button class="btn btn-xs" onclick="adminChangePass(${r.id},'${r.username}')">Passwd</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="adminSuspend(${r.id},'${r.username}')">Suspend</button>
|
||||
<button class="btn btn-xs" onclick="adminResellerPasswd(${r.id},'${Nova.escHtml(r.username)}')">Passwd</button>
|
||||
${r.status === 'active'
|
||||
? `<button class="btn btn-xs btn-warning" onclick="adminResellerSuspend(${r.id},'${Nova.escHtml(r.username)}')">Suspend</button>`
|
||||
: `<button class="btn btn-xs btn-success" onclick="adminResellerUnsuspend(${r.id})">Unsuspend</button>`}
|
||||
<button class="btn btn-xs btn-danger" onclick="adminResellerDelete(${r.id},'${Nova.escHtml(r.username)}')">Delete</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`
|
||||
@@ -1193,10 +1197,51 @@
|
||||
|
||||
window.adminAddReseller = () => {
|
||||
Nova.modal('Create Reseller Account', `
|
||||
<div class="form-group"><label class="form-label">Username</label><input id="ar-user" class="form-control"></div>
|
||||
<div class="form-group"><label class="form-label">Password</label><input id="ar-pass" type="password" class="form-control"></div>
|
||||
<div class="form-group"><label class="form-label">Email</label><input id="ar-email" type="email" class="form-control"></div>`,
|
||||
`<button class="btn btn-primary" onclick="Nova.api('auth','register',{method:'POST',body:{username:document.getElementById('ar-user').value,password:document.getElementById('ar-pass').value,email:document.getElementById('ar-email').value,role:'reseller'}}).then(r=>{if(r?.success){Nova.toast('Reseller created','success');document.querySelector('.modal-overlay').remove();adminPage('resellers');}else Nova.toast(r?.message,'error');})">Create</button>`);
|
||||
<div class="form-group"><label class="form-label">Username</label><input id="ar-user" class="form-control" autocomplete="off"></div>
|
||||
<div class="form-group"><label class="form-label">Email</label><input id="ar-email" type="email" class="form-control"></div>
|
||||
<div class="form-group"><label class="form-label">Password</label><input id="ar-pass" type="password" class="form-control" autocomplete="new-password"></div>`,
|
||||
`<button class="btn btn-primary" onclick="
|
||||
Nova.api('users','create',{method:'POST',body:{
|
||||
username:document.getElementById('ar-user').value,
|
||||
email:document.getElementById('ar-email').value,
|
||||
password:document.getElementById('ar-pass').value,
|
||||
role:'reseller'
|
||||
}}).then(r=>{
|
||||
if(r?.success){Nova.toast('Reseller created','success');document.querySelector('.modal-overlay').remove();adminPage('resellers');}
|
||||
else Nova.toast(r?.message||'Error','error');
|
||||
})">Create</button>`);
|
||||
};
|
||||
|
||||
window.adminResellerPasswd = (id, user) => {
|
||||
Nova.modal(`Change Password — ${user}`,
|
||||
`<div class="form-group"><label class="form-label">New Password</label><input id="arp-pass" type="password" class="form-control" autocomplete="new-password"></div>`,
|
||||
`<button class="btn btn-primary" onclick="
|
||||
Nova.api('users','change-password',{method:'POST',body:{id:${id},password:document.getElementById('arp-pass').value}}).then(r=>{
|
||||
if(r?.success){Nova.toast('Password updated','success');document.querySelector('.modal-overlay').remove();}
|
||||
else Nova.toast(r?.message||'Error','error');
|
||||
})">Update</button>`);
|
||||
};
|
||||
|
||||
window.adminResellerSuspend = (id, user) => {
|
||||
Nova.confirm(`Suspend reseller ${user}?`, async () => {
|
||||
const r = await Nova.api('users','suspend',{method:'POST',body:{id}});
|
||||
if (r?.success) { Nova.toast('Suspended','success'); adminPage('resellers'); }
|
||||
else Nova.toast(r?.message||'Error','error');
|
||||
});
|
||||
};
|
||||
|
||||
window.adminResellerUnsuspend = async (id) => {
|
||||
const r = await Nova.api('users','unsuspend',{method:'POST',body:{id}});
|
||||
if (r?.success) { Nova.toast('Unsuspended','success'); adminPage('resellers'); }
|
||||
else Nova.toast(r?.message||'Error','error');
|
||||
};
|
||||
|
||||
window.adminResellerDelete = (id, user) => {
|
||||
Nova.confirm(`Delete reseller ${user}? Their accounts will be disowned (moved to admin).`, async () => {
|
||||
const r = await Nova.api('users','delete',{method:'POST',body:{id}});
|
||||
if (r?.success) { Nova.toast('Reseller deleted','success'); adminPage('resellers'); }
|
||||
else Nova.toast(r?.message||'Error','error');
|
||||
}, true);
|
||||
};
|
||||
|
||||
// ── Packages ───────────────────────────────────────────────────────────────
|
||||
@@ -4047,8 +4092,8 @@ async function docker() {
|
||||
${(status.disk||[]).map(d=>`<div class="stat-card"><div class="stat-label">${Nova.escHtml(d.Type||d.type||'?')}</div><div class="stat-value" style="font-size:1rem">${Nova.escHtml(d.TotalCount||d.Size||'—')}</div><div class="stat-sub">${Nova.escHtml(d.Reclaimable||d.reclaimable||'')}</div></div>`).join('')}
|
||||
</div>
|
||||
<div style="display:flex;gap:.5rem;margin-bottom:1rem;flex-wrap:wrap">
|
||||
${tab('containers','Containers')} ${tab('images','Images')} ${tab('volumes','Volumes')} ${tab('networks','Networks')} ${tab('stacks','Compose Stacks')} ${tab('quotas','User Quotas')}
|
||||
<button class="btn btn-sm btn-danger" style="margin-left:auto" onclick="dockerPrune()">System Prune</button>
|
||||
${tab('containers','Containers')} ${tab('images','Images')} ${tab('volumes','Volumes')} ${tab('networks','Networks')} ${tab('stacks','Compose Stacks')} ${tab('catalog','App Catalog')} ${tab('quotas','User Quotas')}
|
||||
<button class="btn btn-sm btn-warning" style="margin-left:auto" onclick="dockerPrune()">System Prune</button>
|
||||
</div>
|
||||
<div id="docker-tab-content"><div class="loading">Loading…</div></div>`;
|
||||
}
|
||||
@@ -4143,11 +4188,29 @@ ${stacks.map(s=>`<tr>
|
||||
<button class="btn btn-xs btn-success" onclick="dockerStackAct(${s.id},'up')">Up</button>
|
||||
<button class="btn btn-xs btn-warning" onclick="dockerStackAct(${s.id},'down')">Down</button>
|
||||
<button class="btn btn-xs btn-ghost" onclick="dockerStackAct(${s.id},'logs')">Logs</button>
|
||||
<button class="btn btn-xs btn-secondary" onclick="dockerStackReinstall(${s.id})">Reinstall</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="dockerStackRemove(${s.id})">Remove</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table></div>`}`;
|
||||
|
||||
} else if (tab === 'catalog') {
|
||||
const r = await Nova.api('docker', 'catalog');
|
||||
const catalog = r?.data?.catalog || {};
|
||||
tc.innerHTML = `
|
||||
<p class="text-muted" style="margin-bottom:1rem">One-click app deployment. Each app runs as an isolated Docker Compose stack. Select an account after clicking Launch.</p>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:1rem">
|
||||
${Object.entries(catalog).map(([key,app])=>`
|
||||
<div class="card" style="transition:var(--transition)" onmouseover="this.style.borderColor='var(--primary)'" onmouseout="this.style.borderColor=''">
|
||||
<div class="card-body" style="text-align:center;padding:1.5rem">
|
||||
<div style="font-size:1.8rem;font-weight:700;margin-bottom:.5rem;color:var(--primary)">${Nova.escHtml(app.icon)}</div>
|
||||
<div style="font-weight:600;margin-bottom:.25rem">${Nova.escHtml(app.name)}</div>
|
||||
<div style="font-size:.78rem;color:var(--text-muted);margin-bottom:.75rem">${Nova.escHtml(app.description)}</div>
|
||||
<button class="btn btn-sm btn-primary" onclick="dockerAdminLaunchApp('${key}')">Launch</button>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
|
||||
} else if (tab === 'quotas') {
|
||||
const r = await Nova.api('accounts', 'list', { params: { limit: 200 } });
|
||||
const users = r?.data || [];
|
||||
@@ -4165,6 +4228,47 @@ ${users.map(u=>`<tr id="docker-quota-row-${u.user_id}">
|
||||
}
|
||||
}
|
||||
|
||||
window.dockerAdminLaunchApp = async (preselect) => {
|
||||
const catRes = await Nova.api('docker', 'catalog');
|
||||
const catalog = catRes?.data?.catalog || {};
|
||||
const acctRes = await Nova.api('accounts', 'list', { params: { limit: 200 } });
|
||||
const accounts = acctRes?.data || [];
|
||||
const appOpts = Object.entries(catalog).map(([k,a])=>`<option value="${k}" ${k===preselect?'selected':''}>${Nova.escHtml(a.name)}</option>`).join('');
|
||||
const acctOpts = accounts.map(a=>`<option value="${a.id}">${Nova.escHtml(a.username)} (${Nova.escHtml(a.domain)})</option>`).join('');
|
||||
|
||||
window.dockerAdminUpdateParams = (key) => {
|
||||
const app = catalog[key]; if (!app) return;
|
||||
const tc = document.getElementById('dal-params'); if (!tc) return;
|
||||
tc.innerHTML = (app.params||[]).map(p=>`
|
||||
<div class="form-group"><label>${Nova.escHtml(p.label)}${p.required?' *':''}</label>
|
||||
<input id="dal-${Nova.escHtml(p.key)}" type="${p.type||'text'}" class="form-control" ${p.placeholder?`placeholder="${Nova.escHtml(p.placeholder)}"`:''}></div>`).join('');
|
||||
};
|
||||
|
||||
const ov = Nova.modal('Launch App (Admin)',
|
||||
`<div class="form-group"><label>Account</label><select id="dal-account" class="form-control">${acctOpts}</select></div>
|
||||
<div class="form-group"><label>Application</label><select id="dal-app" class="form-control" onchange="dockerAdminUpdateParams(this.value)">${appOpts}</select></div>
|
||||
<div id="dal-params"></div>`,
|
||||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="dockerAdminLaunchSubmit()">Launch</button>`
|
||||
);
|
||||
dockerAdminUpdateParams(preselect || Object.keys(catalog)[0] || '');
|
||||
|
||||
window.dockerAdminLaunchSubmit = async () => {
|
||||
const appKey = document.getElementById('dal-app').value;
|
||||
const accountId = parseInt(document.getElementById('dal-account').value);
|
||||
const app = catalog[appKey]; if (!app) return;
|
||||
const params = {};
|
||||
(app.params||[]).forEach(p => { const el = document.getElementById(`dal-${p.key}`); if (el) params[p.key] = el.value.trim(); });
|
||||
const missing = (app.params||[]).filter(p => p.required && !params[p.key]);
|
||||
if (missing.length) { Nova.toast(`Required: ${missing.map(p=>p.label).join(', ')}`, 'error'); return; }
|
||||
ov.remove();
|
||||
Nova.toast(`Launching ${app.name}…`, 'info', 15000);
|
||||
const r = await Nova.api('docker', 'launch', { method: 'POST', body: { app_key: appKey, account_id: accountId, params } });
|
||||
Nova.toast(r?.success ? `${app.name} launched!` : (r?.error || r?.message || 'Launch failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) dockerLoadTab('stacks');
|
||||
};
|
||||
};
|
||||
|
||||
window.dockerContainerAct = async (cid, action) => {
|
||||
const r = await Nova.api('docker', 'container-action', { method: 'POST', body: { container_id: cid, action } });
|
||||
Nova.toast(r?.success ? `Container ${action}ed` : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||||
@@ -4243,6 +4347,13 @@ window.dockerStackAct = async (id, action) => {
|
||||
}
|
||||
};
|
||||
|
||||
window.dockerStackReinstall = (id) => Nova.confirm('Reinstall this stack? Latest images will be pulled and containers restarted. Data volumes are preserved.', async () => {
|
||||
Nova.toast('Reinstalling stack…', 'info', 15000);
|
||||
const r = await Nova.api('docker', 'stack-reinstall', { method: 'POST', body: { stack_id: id } });
|
||||
Nova.toast(r?.success ? 'Stack reinstalled' : (r?.message||'Reinstall failed'), r?.success?'success':'error');
|
||||
if (r?.success) dockerLoadTab('stacks');
|
||||
}, true);
|
||||
|
||||
window.dockerStackRemove = (id) => Nova.confirm('Remove this stack? Docker Compose down will be run first.', async () => {
|
||||
const r = await Nova.api('docker', 'stack-remove', { method: 'DELETE', body: { stack_id: id } });
|
||||
Nova.toast(r?.success ? 'Stack removed' : (r?.message||'Failed'), r?.success?'success':'error');
|
||||
|
||||
@@ -1076,9 +1076,10 @@ async function dockerPage(el) {
|
||||
<div class="stat-card"><div class="stat-label">Max CPUs / App</div><div class="stat-value stat-green">${quota.max_cpus}</div></div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:.5rem;margin-bottom:1rem">
|
||||
<div style="display:flex;gap:.5rem;margin-bottom:1rem;flex-wrap:wrap">
|
||||
<button class="btn btn-sm ${_uDockerTab==='my-apps'?'btn-primary':'btn-ghost'}" onclick="uDockerTab('my-apps')">My Apps</button>
|
||||
<button class="btn btn-sm ${_uDockerTab==='catalog'?'btn-primary':'btn-ghost'}" onclick="uDockerTab('catalog')">App Catalog</button>
|
||||
<button class="btn btn-sm btn-danger" style="margin-left:auto" onclick="uDockerUninstallAll()">Remove All My Apps</button>
|
||||
</div>
|
||||
<div id="udocker-content"><div class="loading">Loading…</div></div>`;
|
||||
|
||||
@@ -1103,6 +1104,18 @@ async function dockerPage(el) {
|
||||
|
||||
window._uDockerTab = 'my-apps';
|
||||
|
||||
window.uDockerUninstallAll = () => Nova.confirm(
|
||||
'Remove ALL your Docker apps? This will stop and delete every container and stack you own. Your hosting account and websites are not affected.',
|
||||
async () => {
|
||||
Nova.loading('Removing all your Docker apps…');
|
||||
const r = await Nova.api('docker', 'uninstall-account', { method: 'POST', body: {} });
|
||||
Nova.loadingDone();
|
||||
Nova.toast(r?.success ? 'All Docker apps removed' : (r?.error || r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) await uDockerReloadStacks();
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
async function uDockerReloadStacks() {
|
||||
const r = await Nova.api('docker', 'stacks');
|
||||
window._uDockerStacks = r?.data?.stacks || [];
|
||||
@@ -1142,6 +1155,7 @@ ${stacks.map(s=>`<tr>
|
||||
? `<button class="btn btn-xs btn-warning" onclick="uStackAct(${s.id},'down')">Stop</button>`
|
||||
: `<button class="btn btn-xs btn-success" onclick="uStackAct(${s.id},'up')">Start</button>`}
|
||||
<button class="btn btn-xs btn-ghost" onclick="uStackLogs(${s.id},'${Nova.escHtml(s.name)}')">Logs</button>
|
||||
<button class="btn btn-xs btn-secondary" onclick="uStackReinstall(${s.id},'${Nova.escHtml(s.name)}')">Reinstall</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="uStackRemove(${s.id},'${Nova.escHtml(s.name)}')">Remove</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
@@ -1180,10 +1194,22 @@ window.uStackLogs = async (stackId, name) => {
|
||||
Nova.modal(`Logs: ${name}`, `<pre style="max-height:400px;overflow:auto;font-size:.78rem;white-space:pre-wrap">${Nova.escHtml(r?.data?.output||'No logs available')}</pre>`);
|
||||
};
|
||||
|
||||
window.uStackReinstall = (stackId, name) => Nova.confirm(
|
||||
`Reinstall "${name}"? This will pull the latest images, restart all containers, and reset to a fresh state. Your data volumes will be preserved.`,
|
||||
async () => {
|
||||
Nova.loading(`Reinstalling ${name}…`);
|
||||
const r = await Nova.api('docker', 'stack-reinstall', { method: 'POST', body: { stack_id: stackId } });
|
||||
Nova.loadingDone();
|
||||
Nova.toast(r?.success ? `${name} reinstalled` : (r?.message || 'Reinstall failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) await uDockerReloadStacks();
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
window.uStackRemove = async (stackId, name) => {
|
||||
if (!confirm(`Remove app "${name}"? This will stop and delete its containers and data.`)) return;
|
||||
Nova.loading('Removing app…');
|
||||
const r = await Nova.api('docker', 'remove-stack', { method: 'POST', body: { stack_id: stackId } });
|
||||
const r = await Nova.api('docker', 'stack-remove', { method: 'POST', body: { stack_id: stackId } });
|
||||
Nova.loadingDone();
|
||||
Nova.toast(r?.success ? 'App removed' : (r?.message||'Failed'), r?.success?'success':'error');
|
||||
if (r?.success) await uDockerReloadStacks();
|
||||
|
||||
Reference in New Issue
Block a user