mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
feat: feature registry, auto-deploy, IP management, Docker support
Feature Manager (70+ features across 20 categories): - Web servers: Apache2, nginx, OpenLiteSpeed, Varnish - PHP: 7.4/8.1/8.2/8.3 multi-version, Composer - Databases: MySQL 8, MariaDB, PostgreSQL, Redis, Memcached, phpMyAdmin, phpPgAdmin - Email: Postfix, Dovecot, Roundcube, RainLoop, SpamAssassin, Rspamd, DKIM - DNS: BIND9, PowerDNS - FTP: ProFTPD, vsftpd, Pure-FTPd - SSL: Certbot/Let's Encrypt, acme.sh - Security: Fail2Ban, ModSecurity WAF, ImunifyAV, ClamAV, UFW, CrowdSec - Containers: Docker Engine, Docker Compose, Portainer CE, per-account Docker hosting - IP Management: Shared IPs (SNI), Dedicated IPs, IPv6 - Monitoring: Netdata, AWStats, GoAccess, Grafana+Prometheus - Backup: BorgBackup, rclone (S3/B2/GCS), Duplicati - CDN: Cloudflare API, PageSpeed Module - Dev: Gitea, Phusion Passenger, JupyterHub - One-click apps: WordPress+WP-CLI, auto-installer (50+ apps) - Billing: WHMCS bridge, BoxBilling - Reseller: White label, custom nameservers - Notifications: Email, Slack, Telegram - Compliance: Auditd, OSSEC HIDS Auto-deploy pipeline (deploy/): - webhook.php: HMAC-verified GitHub push webhook - deploy-runner.sh: PHP syntax validation → git pull → rsync → DB migrations → PHP-FPM reload - setup-deploy.sh: one-shot setup script, outputs GitHub webhook config - Runs every minute via cron; locked to prevent concurrent deploys Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
/**
|
||||
* NovaCPX Feature Registry & Toggle API
|
||||
* Features can be enabled/disabled/installed on the fly
|
||||
* Covers: Docker, IPs, WordPress, Node.js, Python, Ruby, Git deploy,
|
||||
* Cloudflare, Redis, Memcached, Varnish, ModSecurity, ImunifyAV,
|
||||
* Webmail, phpMyAdmin, phpPgAdmin, and more
|
||||
*/
|
||||
|
||||
Auth::getInstance()->require('admin');
|
||||
$db = DB::getInstance();
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
|
||||
match ($action) {
|
||||
|
||||
'list' => (function() use ($db) {
|
||||
$rows = $db->fetchAll("SELECT * FROM features ORDER BY category, name");
|
||||
$grouped = [];
|
||||
foreach ($rows as $r) {
|
||||
$grouped[$r['category']][] = $r;
|
||||
}
|
||||
Response::success($grouped);
|
||||
})(),
|
||||
|
||||
'toggle' => (function() use ($db, $body) {
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
$enable = (bool)($body['enable'] ?? false);
|
||||
$feat = $db->fetchOne("SELECT * FROM features WHERE id = ?", [$id]);
|
||||
if (!$feat) Response::error("Feature not found");
|
||||
|
||||
$db->execute("UPDATE features SET enabled = ?, updated_at = NOW() WHERE id = ?", [(int)$enable, $id]);
|
||||
audit('feature.' . ($enable ? 'enable' : 'disable'), $feat['slug']);
|
||||
|
||||
// Run hook if defined
|
||||
if ($enable && $feat['install_cmd'] && !$feat['installed']) {
|
||||
Response::success(['action' => 'install_required', 'feature' => $feat]);
|
||||
}
|
||||
Response::success(null, 'Feature ' . ($enable ? 'enabled' : 'disabled'));
|
||||
})(),
|
||||
|
||||
'install' => (function() use ($db, $body) {
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
$feat = $db->fetchOne("SELECT * FROM features WHERE id = ?", [$id]);
|
||||
if (!$feat) Response::error("Feature not found");
|
||||
if ($feat['installed']) Response::error("Already installed");
|
||||
if (!$feat['install_cmd']) Response::error("No install command defined");
|
||||
|
||||
// Run install in background, return job ID
|
||||
$jobId = uniqid('install_');
|
||||
$logFile = "/var/log/novacpx/feature_{$jobId}.log";
|
||||
$cmd = "nohup bash -c " . escapeshellarg($feat['install_cmd']) . " > " . escapeshellarg($logFile) . " 2>&1 & echo $!";
|
||||
$pid = trim(shell_exec($cmd) ?: '');
|
||||
|
||||
$db->execute("UPDATE features SET install_pid = ?, install_log = ?, updated_at = NOW() WHERE id = ?", [$pid, $logFile, $id]);
|
||||
audit('feature.install', $feat['slug']);
|
||||
Response::success(['job_id' => $jobId, 'pid' => $pid, 'log' => $logFile]);
|
||||
})(),
|
||||
|
||||
'install-log' => (function() use ($db) {
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
$feat = $db->fetchOne("SELECT install_log, install_pid, installed FROM features WHERE id = ?", [$id]);
|
||||
if (!$feat) Response::error("Feature not found");
|
||||
|
||||
$logContent = '';
|
||||
if ($feat['install_log'] && file_exists($feat['install_log'])) {
|
||||
$logContent = file_get_contents($feat['install_log']);
|
||||
}
|
||||
// Check if process finished
|
||||
$running = false;
|
||||
if ($feat['install_pid']) {
|
||||
$running = file_exists("/proc/{$feat['install_pid']}");
|
||||
if (!$running && !$feat['installed']) {
|
||||
$db->execute("UPDATE features SET installed = 1, enabled = 1, updated_at = NOW() WHERE id = ?", [$id]);
|
||||
}
|
||||
}
|
||||
Response::success(['log' => $logContent, 'running' => $running, 'installed' => (bool)$feat['installed']]);
|
||||
})(),
|
||||
|
||||
default => Response::error("Unknown features action: $action", 404),
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* NovaCPX Feature Manager
|
||||
* Loaded by admin panel's features page
|
||||
*/
|
||||
window.FeaturesManager = {
|
||||
async load() {
|
||||
const res = await Nova.api('features', 'list');
|
||||
if (!res?.success) return '<p class="text-muted">Failed to load features.</p>';
|
||||
const grouped = res.data;
|
||||
|
||||
const categoryIcons = {
|
||||
'Web Server':'🌐', 'PHP':'⚙️', 'Database':'🗄️', 'Email':'📧',
|
||||
'DNS':'🔍', 'FTP':'📁', 'SSL':'🔒', 'Security':'🛡️', 'Containers':'🐳',
|
||||
'IP Management':'🌍', 'Monitoring':'📊', 'Backup':'💾', 'CDN & Performance':'⚡',
|
||||
'Development':'👨💻', 'One-Click Apps':'🚀', 'Applications':'📦',
|
||||
'Billing':'💳', 'Reseller':'🏪', 'Notifications':'🔔', 'Compliance':'✅',
|
||||
};
|
||||
|
||||
return `
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h2 style="font-size:1.1rem;font-weight:700">Feature Manager</h2>
|
||||
<div class="flex gap-1">
|
||||
<input type="text" id="feat-search" placeholder="Search features…" style="width:220px;padding:.45rem .85rem;font-size:.85rem">
|
||||
<select id="feat-cat-filter" style="padding:.45rem .7rem;font-size:.85rem">
|
||||
<option value="">All Categories</option>
|
||||
${Object.keys(grouped).map(c => `<option value="${c}">${c}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="features-container">
|
||||
${Object.entries(grouped).map(([cat, feats]) => `
|
||||
<div class="feat-category" data-cat="${cat}">
|
||||
<div class="flex items-center gap-1 mb-2 mt-3">
|
||||
<span style="font-size:1.1rem">${categoryIcons[cat] || '🔧'}</span>
|
||||
<h3 style="font-size:.9rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted)">${cat}</h3>
|
||||
<span class="badge badge-gray" style="margin-left:.5rem">${feats.length}</span>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:.75rem">
|
||||
${feats.map(f => `
|
||||
<div class="feat-card card" data-id="${f.id}" data-name="${f.name.toLowerCase()}" data-cat="${cat}">
|
||||
<div class="card-body" style="padding:1rem">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:.88rem">${f.name}</div>
|
||||
<div style="font-size:.75rem;color:var(--text-muted);margin-top:.15rem;line-height:1.4">${f.description}</div>
|
||||
${f.min_ram_mb > 0 ? `<div style="font-size:.72rem;color:var(--text-muted);margin-top:.2rem">Requires ${f.min_ram_mb}MB RAM</div>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:.4rem;margin-left:1rem;flex-shrink:0">
|
||||
${f.installed
|
||||
? `<label class="toggle-switch" title="${f.enabled ? 'Disable' : 'Enable'}">
|
||||
<input type="checkbox" ${f.enabled ? 'checked' : ''} onchange="FeaturesManager.toggle(${f.id}, this.checked)">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>`
|
||||
: `<button class="btn btn-primary btn-sm" onclick="FeaturesManager.install(${f.id})">Install</button>`
|
||||
}
|
||||
${f.installed ? `<span class="badge badge-green" style="font-size:.65rem">Installed</span>` : `<span class="badge badge-gray" style="font-size:.65rem">Not installed</span>`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toggle-switch { position:relative; display:inline-block; width:40px; height:22px; }
|
||||
.toggle-switch input { opacity:0; width:0; height:0; }
|
||||
.toggle-slider {
|
||||
position:absolute; cursor:pointer; inset:0;
|
||||
background:var(--border); border-radius:999px; transition:.2s;
|
||||
}
|
||||
.toggle-slider:before {
|
||||
content:''; position:absolute; width:16px; height:16px;
|
||||
left:3px; bottom:3px; background:#fff; border-radius:50%; transition:.2s;
|
||||
}
|
||||
input:checked + .toggle-slider { background:var(--primary); }
|
||||
input:checked + .toggle-slider:before { transform:translateX(18px); }
|
||||
</style>`;
|
||||
},
|
||||
|
||||
async toggle(id, enable) {
|
||||
const res = await Nova.api('features', 'toggle', { method: 'POST', body: { id, enable } });
|
||||
if (res?.data?.action === 'install_required') {
|
||||
Nova.confirm(`"${res.data.feature.name}" must be installed first. Install now?`, () => this.install(id));
|
||||
return;
|
||||
}
|
||||
Nova.toast(res?.message || 'Updated', res?.success ? 'success' : 'error');
|
||||
},
|
||||
|
||||
async install(id) {
|
||||
const res = await Nova.api('features', 'install', { method: 'POST', body: { id } });
|
||||
if (!res?.success) { Nova.toast(res?.message || 'Install failed', 'error'); return; }
|
||||
|
||||
const logDiv = `<div class="terminal" id="install-log-${id}" style="min-height:100px">Starting installation…</div>`;
|
||||
const ov = Nova.modal('Installing Feature', logDiv);
|
||||
const poll = setInterval(async () => {
|
||||
const logRes = await Nova.api('features', 'install-log', { params: { id } });
|
||||
const logEl = document.getElementById(`install-log-${id}`);
|
||||
if (logEl) logEl.innerHTML = (logRes?.data?.log || '').split('\n').map(l => `<div>${l}</div>`).join('');
|
||||
if (!logRes?.data?.running) {
|
||||
clearInterval(poll);
|
||||
if (logRes?.data?.installed) {
|
||||
Nova.toast('Feature installed successfully', 'success');
|
||||
ov.remove();
|
||||
document.getElementById('page-content').innerHTML = await this.load();
|
||||
this.bindSearch();
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
},
|
||||
|
||||
bindSearch() {
|
||||
const search = document.getElementById('feat-search');
|
||||
const catFilter = document.getElementById('feat-cat-filter');
|
||||
if (!search || !catFilter) return;
|
||||
|
||||
const filter = () => {
|
||||
const q = search.value.toLowerCase();
|
||||
const cat = catFilter.value;
|
||||
document.querySelectorAll('.feat-card').forEach(c => {
|
||||
const match = (!q || c.dataset.name.includes(q)) && (!cat || c.dataset.cat === cat);
|
||||
c.closest('div').style.display = match ? '' : 'none';
|
||||
});
|
||||
document.querySelectorAll('.feat-category').forEach(sec => {
|
||||
const visible = [...sec.querySelectorAll('.feat-card')].some(c => c.closest('div').style.display !== 'none');
|
||||
sec.style.display = visible ? '' : 'none';
|
||||
});
|
||||
};
|
||||
search.addEventListener('input', filter);
|
||||
catFilter.addEventListener('change', filter);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user