Initial NixPanel Build
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# Database
|
||||
DATABASE_URL="mysql://nixpanel:password@localhost:3306/nixpanel"
|
||||
|
||||
# JWT
|
||||
JWT_SECRET="change-this-to-a-long-random-secret"
|
||||
JWT_EXPIRES_IN="15m"
|
||||
JWT_REFRESH_SECRET="change-this-to-another-long-random-secret"
|
||||
JWT_REFRESH_EXPIRES_IN="7d"
|
||||
|
||||
# Server
|
||||
NODE_ENV="production"
|
||||
WHM_PORT=2087
|
||||
CPANEL_PORT=2083
|
||||
HOST="0.0.0.0"
|
||||
|
||||
# Admin
|
||||
ADMIN_USERNAME="root"
|
||||
ADMIN_PASSWORD="change-this-password"
|
||||
ADMIN_EMAIL="admin@localhost"
|
||||
|
||||
# Paths
|
||||
HOME_DIR="/home"
|
||||
NGINX_SITES_DIR="/etc/nginx/sites-available"
|
||||
NGINX_ENABLED_DIR="/etc/nginx/sites-enabled"
|
||||
APACHE_SITES_DIR="/etc/apache2/sites-available"
|
||||
APACHE_ENABLED_DIR="/etc/apache2/sites-enabled"
|
||||
BIND_ZONES_DIR="/etc/bind/zones"
|
||||
CERTBOT_WEBROOT="/var/www/letsencrypt"
|
||||
|
||||
# Rate limiting
|
||||
RATE_LIMIT_MAX=100
|
||||
RATE_LIMIT_WINDOW_MS=60000
|
||||
|
||||
# Features
|
||||
ENABLE_TERMINAL=true
|
||||
WEBSOCKET_STATS_INTERVAL=5000
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Prisma
|
||||
server/prisma/dev.db
|
||||
server/prisma/dev.db-journal
|
||||
|
||||
# Build outputs
|
||||
server/dist/
|
||||
whm/dist/
|
||||
cpanel/dist/
|
||||
|
||||
# Vite
|
||||
whm/.vite/
|
||||
cpanel/.vite/
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NixPanel</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@nixpanel/cpanel",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5174",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.7",
|
||||
"lucide-react": "^0.460.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"recharts": "^2.13.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import Layout from './components/Layout'
|
||||
import Login from './pages/Login'
|
||||
import Overview from './pages/Overview'
|
||||
import FileManager from './pages/FileManager'
|
||||
import Databases from './pages/Databases'
|
||||
import Email from './pages/Email'
|
||||
import SSL from './pages/SSL'
|
||||
import Domains from './pages/Domains'
|
||||
import FTP from './pages/FTP'
|
||||
import CronJobs from './pages/CronJobs'
|
||||
import Stats from './pages/Stats'
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
if (loading) return (
|
||||
<div className="min-h-screen bg-[#0a0f1a] flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-sky-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
if (!user) return <Navigate to="/login" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { user } = useAuth()
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
|
||||
<Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>
|
||||
<Route index element={<Overview />} />
|
||||
<Route path="files" element={<FileManager />} />
|
||||
<Route path="databases" element={<Databases />} />
|
||||
<Route path="email" element={<Email />} />
|
||||
<Route path="ssl" element={<SSL />} />
|
||||
<Route path="domains" element={<Domains />} />
|
||||
<Route path="ftp" element={<FTP />} />
|
||||
<Route path="cron" element={<CronJobs />} />
|
||||
<Route path="stats" element={<Stats />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
api.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
res => res,
|
||||
async error => {
|
||||
const original = error.config
|
||||
if (error.response?.status === 401 && !original._retry) {
|
||||
original._retry = true
|
||||
const refreshToken = localStorage.getItem('refresh_token')
|
||||
if (!refreshToken) { localStorage.clear(); window.location.href = '/login'; return Promise.reject(error) }
|
||||
try {
|
||||
const { data } = await axios.post('/api/auth/refresh', { refreshToken })
|
||||
localStorage.setItem('access_token', data.data.accessToken)
|
||||
localStorage.setItem('refresh_token', data.data.refreshToken)
|
||||
original.headers.Authorization = `Bearer ${data.data.accessToken}`
|
||||
return api(original)
|
||||
} catch {
|
||||
localStorage.clear(); window.location.href = '/login'; return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard, FolderOpen, Database, Mail, Lock,
|
||||
Globe, Anchor, Clock, LogOut, Menu, X, BarChart3, User
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const nav = [
|
||||
{ label: 'Overview', to: '/', icon: LayoutDashboard },
|
||||
{ label: 'File Manager',to: '/files', icon: FolderOpen },
|
||||
{ label: 'Databases', to: '/databases', icon: Database },
|
||||
{ label: 'Email', to: '/email', icon: Mail },
|
||||
{ label: 'SSL', to: '/ssl', icon: Lock },
|
||||
{ label: 'Domains', to: '/domains', icon: Globe },
|
||||
{ label: 'FTP Accounts',to: '/ftp', icon: Anchor },
|
||||
{ label: 'Cron Jobs', to: '/cron', icon: Clock },
|
||||
{ label: 'Statistics', to: '/stats', icon: BarChart3 },
|
||||
]
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
|
||||
const handleLogout = async () => { await logout(); navigate('/login') }
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-[#0a0f1a]">
|
||||
<aside className={`flex flex-col transition-all duration-200 bg-[#111827] border-r border-[#1e2d45] ${sidebarOpen ? 'w-52' : 'w-14'}`}>
|
||||
<div className="flex items-center gap-2 px-4 py-4 border-b border-[#1e2d45] min-h-[57px]">
|
||||
<div className="w-7 h-7 rounded-lg bg-sky-500 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-white text-xs font-bold">N</span>
|
||||
</div>
|
||||
{sidebarOpen && (
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-white text-sm font-semibold leading-tight">NixPanel</p>
|
||||
<p className="text-[#64748b] text-xs truncate">{user?.domain}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto py-2">
|
||||
{nav.map(({ label, to, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-2 mx-2 my-0.5 rounded-lg text-sm transition-colors ${
|
||||
isActive ? 'bg-sky-500/20 text-sky-400' : 'text-[#94a3b8] hover:bg-[#1a2235] hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon size={16} className="flex-shrink-0" />
|
||||
{sidebarOpen && <span className="truncate">{label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-[#1e2d45] p-3">
|
||||
{sidebarOpen && (
|
||||
<div className="flex items-center gap-2 px-1 mb-2">
|
||||
<User size={14} className="text-[#64748b] flex-shrink-0" />
|
||||
<p className="text-white text-xs font-medium truncate">{user?.username}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-3 py-2 w-full rounded-lg text-[#94a3b8] hover:bg-[#1a2235] hover:text-red-400 transition-colors text-sm"
|
||||
>
|
||||
<LogOut size={16} className="flex-shrink-0" />
|
||||
{sidebarOpen && <span>Logout</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<header className="flex items-center gap-4 px-6 py-3 bg-[#111827] border-b border-[#1e2d45] min-h-[57px]">
|
||||
<button onClick={() => setSidebarOpen(v => !v)} className="text-[#64748b] hover:text-white transition-colors">
|
||||
{sidebarOpen ? <X size={18} /> : <Menu size={18} />}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<span className="text-[#64748b] text-xs font-mono">{user?.domain}</span>
|
||||
</header>
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
domain: string
|
||||
role: 'user'
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null
|
||||
loading: boolean
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchMe = useCallback(async () => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (!token) { setLoading(false); return }
|
||||
try {
|
||||
const { data } = await api.get('/auth/me')
|
||||
if (data.data.role !== 'user') { localStorage.clear(); setLoading(false); return }
|
||||
setUser(data.data)
|
||||
} catch {
|
||||
localStorage.clear()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchMe() }, [fetchMe])
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const { data } = await api.post('/auth/login', { username, password })
|
||||
if (data.data.role !== 'user') throw new Error('Use WHM to log in as admin or reseller')
|
||||
localStorage.setItem('access_token', data.data.accessToken)
|
||||
localStorage.setItem('refresh_token', data.data.refreshToken)
|
||||
setUser(data.data.user)
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
const refreshToken = localStorage.getItem('refresh_token')
|
||||
try { await api.post('/auth/logout', { refreshToken }) } catch {}
|
||||
localStorage.clear()
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be inside AuthProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--bg-primary: #0a0f1a;
|
||||
--bg-secondary: #111827;
|
||||
--bg-card: #1a2235;
|
||||
--border: #1e2d45;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-muted: #64748b;
|
||||
--accent: #0ea5e9;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg-secondary); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #2a3d5e; }
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Clock, Plus, Trash2, RefreshCw, ToggleLeft, ToggleRight } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface CronJob { id: number; schedule: string; command: string; comment: string | null; enabled: boolean }
|
||||
|
||||
const PRESETS = [
|
||||
{ label: 'Every minute', value: '* * * * *' },
|
||||
{ label: 'Every hour', value: '0 * * * *' },
|
||||
{ label: 'Daily at midnight', value: '0 0 * * *' },
|
||||
{ label: 'Weekly (Sunday)', value: '0 0 * * 0' },
|
||||
{ label: 'Monthly (1st)', value: '0 0 1 * *' },
|
||||
]
|
||||
|
||||
export default function CronJobs() {
|
||||
const [jobs, setJobs] = useState<CronJob[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [form, setForm] = useState({ schedule: '0 * * * *', command: '', comment: '', enabled: true })
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try { const r = await api.get('/cpanel/cron'); setJobs(r.data.data) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setError('')
|
||||
try { await api.post('/cpanel/cron', form); setShowModal(false); setForm({ schedule: '0 * * * *', command: '', comment: '', enabled: true }); fetch() }
|
||||
catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const del = async (id: number) => {
|
||||
if (!confirm('Delete this cron job?')) return
|
||||
try { await api.delete(`/cpanel/cron/${id}`); fetch() } catch {}
|
||||
}
|
||||
|
||||
const toggle = async (job: CronJob) => {
|
||||
try { await api.put(`/cpanel/cron/${job.id}`, { enabled: !job.enabled }); fetch() } catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">Cron Jobs</h1><p className="text-[#64748b] text-sm">{jobs.length} jobs</p></div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#1e2d45] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
<button onClick={() => setShowModal(true)} className="flex items-center gap-2 bg-sky-500 hover:bg-sky-400 text-white text-sm px-3 py-2 rounded-lg"><Plus size={16} /> New Job</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#1e2d45]">
|
||||
{['Schedule', 'Command', 'Comment', 'Status', ''].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{jobs.map(j => (
|
||||
<tr key={j.id} className={`border-b border-[#1e2d45] last:border-0 hover:bg-[#1a2235] ${!j.enabled ? 'opacity-50' : ''}`}>
|
||||
<td className="px-4 py-3 font-mono text-sky-400 text-xs whitespace-nowrap">{j.schedule}</td>
|
||||
<td className="px-4 py-3 font-mono text-[#94a3b8] text-xs max-w-xs truncate">{j.command}</td>
|
||||
<td className="px-4 py-3 text-[#64748b] text-xs">{j.comment ?? '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button onClick={() => toggle(j)} className="text-[#64748b] hover:text-white">
|
||||
{j.enabled ? <ToggleRight size={18} className="text-sky-400" /> : <ToggleLeft size={18} />}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3"><button onClick={() => del(j.id)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && jobs.length === 0 && <tr><td colSpan={5} className="text-center text-[#64748b] py-8">No cron jobs</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl w-full max-w-md">
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#1e2d45]">
|
||||
<h2 className="text-white font-medium">New Cron Job</h2>
|
||||
<button onClick={() => setShowModal(false)} className="text-[#64748b] hover:text-white">✕</button>
|
||||
</div>
|
||||
<form onSubmit={save} className="p-4 space-y-3">
|
||||
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 text-xs px-3 py-2 rounded">{error}</div>}
|
||||
<div>
|
||||
<label className="block text-[#94a3b8] text-xs mb-1">Preset</label>
|
||||
<select onChange={e => setForm(f => ({ ...f, schedule: e.target.value }))} className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-[#94a3b8] text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500">
|
||||
{PRESETS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[#94a3b8] text-xs mb-1">Schedule (cron expression)</label>
|
||||
<input value={form.schedule} onChange={e => setForm(f => ({ ...f, schedule: e.target.value }))} required placeholder="* * * * *"
|
||||
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white font-mono text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" />
|
||||
<p className="text-[#64748b] text-xs mt-1">minute hour day month weekday</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[#94a3b8] text-xs mb-1">Command</label>
|
||||
<input value={form.command} onChange={e => setForm(f => ({ ...f, command: e.target.value }))} required placeholder="/usr/bin/php /home/user/public_html/cron.php"
|
||||
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white font-mono text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[#94a3b8] text-xs mb-1">Comment (optional)</label>
|
||||
<input value={form.comment} onChange={e => setForm(f => ({ ...f, comment: e.target.value }))} placeholder="What this job does"
|
||||
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" />
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowModal(false)} className="flex-1 border border-[#1e2d45] text-[#94a3b8] text-sm py-2 rounded">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="flex-1 bg-sky-500 hover:bg-sky-400 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? 'Saving...' : 'Create'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Database, Plus, Trash2, RefreshCw, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface DbUser { id: number; username: string; host: string; privileges: string }
|
||||
interface Db { id: number; name: string; charset: string; sizeMb: number; dbUsers: DbUser[] }
|
||||
|
||||
export default function Databases() {
|
||||
const [dbs, setDbs] = useState<Db[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [expanded, setExpanded] = useState<Set<number>>(new Set())
|
||||
const [showDbModal, setShowDbModal] = useState(false)
|
||||
const [showUserModal, setShowUserModal] = useState<number | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [dbForm, setDbForm] = useState({ name: '' })
|
||||
const [userForm, setUserForm] = useState({ username: '', password: '', privileges: 'ALL' })
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try { const r = await api.get('/cpanel/databases'); setDbs(r.data.data) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const createDb = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setError('')
|
||||
try { await api.post('/cpanel/databases', dbForm); setShowDbModal(false); setDbForm({ name: '' }); fetch() }
|
||||
catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const deleteDb = async (id: number) => {
|
||||
if (!confirm('Delete this database and all its data?')) return
|
||||
try { await api.delete(`/cpanel/databases/${id}`); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
}
|
||||
|
||||
const createUser = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); if (!showUserModal) return
|
||||
setSaving(true); setError('')
|
||||
try { await api.post(`/cpanel/databases/${showUserModal}/users`, userForm); setShowUserModal(null); setUserForm({ username: '', password: '', privileges: 'ALL' }); fetch() }
|
||||
catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const deleteUser = async (dbId: number, userId: number) => {
|
||||
try { await api.delete(`/cpanel/databases/${dbId}/users/${userId}`); fetch() }
|
||||
catch {}
|
||||
}
|
||||
|
||||
const toggle = (id: number) => {
|
||||
const next = new Set(expanded)
|
||||
next.has(id) ? next.delete(id) : next.add(id)
|
||||
setExpanded(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">Databases</h1><p className="text-[#64748b] text-sm">{dbs.length} databases</p></div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#1e2d45] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
<button onClick={() => setShowDbModal(true)} className="flex items-center gap-2 bg-sky-500 hover:bg-sky-400 text-white text-sm px-3 py-2 rounded-lg"><Plus size={16} /> New Database</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{dbs.map(db => (
|
||||
<div key={db.id} className="bg-[#111827] border border-[#1e2d45] rounded-xl overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-[#1a2235]" onClick={() => toggle(db.id)}>
|
||||
{expanded.has(db.id) ? <ChevronDown size={14} className="text-[#64748b]" /> : <ChevronRight size={14} className="text-[#64748b]" />}
|
||||
<Database size={14} className="text-sky-400" />
|
||||
<span className="text-white font-mono text-sm flex-1">{db.name}</span>
|
||||
<span className="text-[#64748b] text-xs">{db.dbUsers.length} user(s)</span>
|
||||
<button onClick={e => { e.stopPropagation(); setShowUserModal(db.id) }} className="text-xs text-sky-400 hover:text-sky-300 px-2"><Plus size={12} /> User</button>
|
||||
<button onClick={e => { e.stopPropagation(); deleteDb(db.id) }} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button>
|
||||
</div>
|
||||
{expanded.has(db.id) && db.dbUsers.length > 0 && (
|
||||
<div className="border-t border-[#1e2d45] px-4 py-2">
|
||||
{db.dbUsers.map(u => (
|
||||
<div key={u.id} className="flex items-center justify-between py-1.5">
|
||||
<div>
|
||||
<span className="text-[#94a3b8] font-mono text-xs">{u.username}@{u.host}</span>
|
||||
<span className="ml-3 text-xs text-[#64748b]">{u.privileges}</span>
|
||||
</div>
|
||||
<button onClick={() => deleteUser(db.id, u.id)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={12} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!loading && dbs.length === 0 && <div className="text-center text-[#64748b] py-12 text-sm">No databases yet</div>}
|
||||
</div>
|
||||
|
||||
{/* Create DB modal */}
|
||||
{showDbModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl w-full max-w-sm">
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#1e2d45]">
|
||||
<h2 className="text-white font-medium">New Database</h2>
|
||||
<button onClick={() => setShowDbModal(false)} className="text-[#64748b] hover:text-white">✕</button>
|
||||
</div>
|
||||
<form onSubmit={createDb} className="p-4 space-y-3">
|
||||
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 text-xs px-3 py-2 rounded">{error}</div>}
|
||||
<div>
|
||||
<label className="block text-[#94a3b8] text-xs mb-1">Database Name</label>
|
||||
<p className="text-[#64748b] text-xs mb-2">Will be prefixed with your username</p>
|
||||
<input value={dbForm.name} onChange={e => setDbForm({ name: e.target.value })} required placeholder="mydb"
|
||||
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" />
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowDbModal(false)} className="flex-1 border border-[#1e2d45] text-[#94a3b8] text-sm py-2 rounded hover:bg-[#1a2235]">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="flex-1 bg-sky-500 hover:bg-sky-400 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? 'Creating...' : 'Create'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create User modal */}
|
||||
{showUserModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl w-full max-w-sm">
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#1e2d45]">
|
||||
<h2 className="text-white font-medium">Add Database User</h2>
|
||||
<button onClick={() => setShowUserModal(null)} className="text-[#64748b] hover:text-white">✕</button>
|
||||
</div>
|
||||
<form onSubmit={createUser} className="p-4 space-y-3">
|
||||
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 text-xs px-3 py-2 rounded">{error}</div>}
|
||||
{[['Username', 'username', 'text'], ['Password', 'password', 'password']].map(([l, k, t]) => (
|
||||
<div key={k}>
|
||||
<label className="block text-[#94a3b8] text-xs mb-1">{l}</label>
|
||||
<input type={t} value={userForm[k as keyof typeof userForm]} onChange={e => setUserForm(f => ({ ...f, [k]: e.target.value }))} required
|
||||
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowUserModal(null)} className="flex-1 border border-[#1e2d45] text-[#94a3b8] text-sm py-2 rounded">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="flex-1 bg-sky-500 hover:bg-sky-400 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? '...' : 'Add User'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Globe, Plus, Trash2, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface Item { id: number; fqdn?: string; domain?: string; subdomain?: string; documentRoot?: string }
|
||||
|
||||
type Tab = 'subdomains' | 'addon' | 'parked'
|
||||
|
||||
export default function Domains() {
|
||||
const [tab, setTab] = useState<Tab>('subdomains')
|
||||
const [items, setItems] = useState<Item[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [form, setForm] = useState({ subdomain: '', domain: '' })
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await api.get(`/cpanel/domains/${tab}`)
|
||||
setItems(r.data.data)
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [tab])
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setError('')
|
||||
try {
|
||||
if (tab === 'subdomains') await api.post('/cpanel/domains/subdomains', { subdomain: form.subdomain })
|
||||
else if (tab === 'addon') await api.post('/cpanel/domains/addon', { domain: form.domain })
|
||||
else await api.post('/cpanel/domains/parked', { domain: form.domain })
|
||||
setShowModal(false); setForm({ subdomain: '', domain: '' }); fetch()
|
||||
} catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const del = async (id: number) => {
|
||||
if (!confirm('Remove this domain?')) return
|
||||
try {
|
||||
if (tab === 'subdomains') await api.delete(`/cpanel/domains/subdomains/${id}`)
|
||||
else if (tab === 'addon') await api.delete(`/cpanel/domains/addon/${id}`)
|
||||
else await api.delete(`/cpanel/domains/parked/${id}`)
|
||||
fetch()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: 'subdomains', label: 'Subdomains' },
|
||||
{ key: 'addon', label: 'Addon Domains' },
|
||||
{ key: 'parked', label: 'Parked Domains' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-white text-xl font-semibold">Domains</h1>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#1e2d45] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
<button onClick={() => setShowModal(true)} className="flex items-center gap-2 bg-sky-500 hover:bg-sky-400 text-white text-sm px-3 py-2 rounded-lg"><Plus size={16} /> Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 bg-[#111827] border border-[#1e2d45] rounded-lg p-1 w-fit">
|
||||
{tabs.map(t => (
|
||||
<button key={t.key} onClick={() => setTab(t.key)} className={`px-4 py-1.5 text-sm rounded ${tab === t.key ? 'bg-sky-500 text-white' : 'text-[#64748b] hover:text-white'}`}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#1e2d45]">
|
||||
{['Domain / FQDN', 'Document Root', ''].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{items.map(item => (
|
||||
<tr key={item.id} className="border-b border-[#1e2d45] last:border-0 hover:bg-[#1a2235]">
|
||||
<td className="px-4 py-3 text-white flex items-center gap-2"><Globe size={12} className="text-sky-400 flex-shrink-0" />{item.fqdn ?? item.domain}</td>
|
||||
<td className="px-4 py-3 text-[#64748b] font-mono text-xs">{item.documentRoot ?? '—'}</td>
|
||||
<td className="px-4 py-3"><button onClick={() => del(item.id)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && items.length === 0 && <tr><td colSpan={3} className="text-center text-[#64748b] py-8">No {tab} yet</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl w-full max-w-sm">
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#1e2d45]">
|
||||
<h2 className="text-white font-medium">Add {tabs.find(t => t.key === tab)?.label.slice(0, -1)}</h2>
|
||||
<button onClick={() => setShowModal(false)} className="text-[#64748b] hover:text-white">✕</button>
|
||||
</div>
|
||||
<form onSubmit={save} className="p-4 space-y-3">
|
||||
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 text-xs px-3 py-2 rounded">{error}</div>}
|
||||
{tab === 'subdomains' ? (
|
||||
<div><label className="block text-[#94a3b8] text-xs mb-1">Subdomain name</label>
|
||||
<input value={form.subdomain} onChange={e => setForm(f => ({ ...f, subdomain: e.target.value }))} required placeholder="blog"
|
||||
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" /></div>
|
||||
) : (
|
||||
<div><label className="block text-[#94a3b8] text-xs mb-1">Domain</label>
|
||||
<input value={form.domain} onChange={e => setForm(f => ({ ...f, domain: e.target.value }))} required placeholder="example.com"
|
||||
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" /></div>
|
||||
)}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowModal(false)} className="flex-1 border border-[#1e2d45] text-[#94a3b8] text-sm py-2 rounded">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="flex-1 bg-sky-500 hover:bg-sky-400 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? '...' : 'Add'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Mail, Plus, Trash2, RefreshCw, ArrowRight } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface EmailAccount { id: number; address: string; quotaMb: number; usedMb: number; status: string }
|
||||
interface Forwarder { id: number; source: string; destination: string }
|
||||
|
||||
export default function Email() {
|
||||
const [accounts, setAccounts] = useState<EmailAccount[]>([])
|
||||
const [forwarders, setForwarders] = useState<Forwarder[]>([])
|
||||
const [tab, setTab] = useState<'accounts' | 'forwarders'>('accounts')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [showFwd, setShowFwd] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [form, setForm] = useState({ username: '', password: '', quotaMb: 500 })
|
||||
const [fwdForm, setFwdForm] = useState({ source: '', destination: '' })
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [em, fw] = await Promise.all([api.get('/cpanel/email'), api.get('/cpanel/email/forwarders')])
|
||||
setAccounts(em.data.data)
|
||||
setForwarders(fw.data.data)
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const createAccount = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setError('')
|
||||
try {
|
||||
await api.post('/cpanel/email', form)
|
||||
setShowModal(false); fetch()
|
||||
} catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const del = async (id: number) => {
|
||||
if (!confirm('Delete this email account?')) return
|
||||
try { await api.delete(`/cpanel/email/${id}`); fetch() } catch {}
|
||||
}
|
||||
|
||||
const addForwarder = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setError('')
|
||||
try { await api.post('/cpanel/email/forwarders', fwdForm); setShowFwd(false); fetch() }
|
||||
catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const delFwd = async (id: number) => {
|
||||
try { await api.delete(`/cpanel/email/forwarders/${id}`); fetch() } catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-white text-xl font-semibold">Email</h1>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#1e2d45] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
{tab === 'accounts'
|
||||
? <button onClick={() => setShowModal(true)} className="flex items-center gap-2 bg-sky-500 hover:bg-sky-400 text-white text-sm px-3 py-2 rounded-lg"><Plus size={16} /> New Account</button>
|
||||
: <button onClick={() => setShowFwd(true)} className="flex items-center gap-2 bg-sky-500 hover:bg-sky-400 text-white text-sm px-3 py-2 rounded-lg"><Plus size={16} /> New Forwarder</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 bg-[#111827] border border-[#1e2d45] rounded-lg p-1 w-fit">
|
||||
{(['accounts', 'forwarders'] as const).map(t => (
|
||||
<button key={t} onClick={() => setTab(t)} className={`px-4 py-1.5 text-sm rounded ${tab === t ? 'bg-sky-500 text-white' : 'text-[#64748b] hover:text-white'}`}>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'accounts' ? (
|
||||
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#1e2d45]">
|
||||
{['Address', 'Quota', 'Used', ''].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{accounts.map(a => (
|
||||
<tr key={a.id} className="border-b border-[#1e2d45] last:border-0 hover:bg-[#1a2235]">
|
||||
<td className="px-4 py-3 text-white"><Mail size={12} className="inline mr-2 text-sky-400" />{a.address}</td>
|
||||
<td className="px-4 py-3 text-[#64748b]">{a.quotaMb} MB</td>
|
||||
<td className="px-4 py-3 text-[#64748b]">{a.usedMb} MB</td>
|
||||
<td className="px-4 py-3"><button onClick={() => del(a.id)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && accounts.length === 0 && <tr><td colSpan={4} className="text-center text-[#64748b] py-8">No email accounts</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#1e2d45]">
|
||||
{['Source', '', 'Destination', ''].map((h, i) => <th key={i} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{forwarders.map(f => (
|
||||
<tr key={f.id} className="border-b border-[#1e2d45] last:border-0 hover:bg-[#1a2235]">
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{f.source}</td>
|
||||
<td className="px-4 py-3"><ArrowRight size={12} className="text-[#64748b]" /></td>
|
||||
<td className="px-4 py-3 text-white">{f.destination}</td>
|
||||
<td className="px-4 py-3"><button onClick={() => delFwd(f.id)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && forwarders.length === 0 && <tr><td colSpan={4} className="text-center text-[#64748b] py-8">No forwarders</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(showModal || showFwd) && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl w-full max-w-sm">
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#1e2d45]">
|
||||
<h2 className="text-white font-medium">{showModal ? 'New Email Account' : 'New Forwarder'}</h2>
|
||||
<button onClick={() => { setShowModal(false); setShowFwd(false) }} className="text-[#64748b] hover:text-white">✕</button>
|
||||
</div>
|
||||
{showModal ? (
|
||||
<form onSubmit={createAccount} className="p-4 space-y-3">
|
||||
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 text-xs px-3 py-2 rounded">{error}</div>}
|
||||
<div><label className="block text-[#94a3b8] text-xs mb-1">Username</label>
|
||||
<input value={form.username} onChange={e => setForm(f => ({ ...f, username: e.target.value }))} required placeholder="info"
|
||||
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" /></div>
|
||||
<div><label className="block text-[#94a3b8] text-xs mb-1">Password</label>
|
||||
<input type="password" value={form.password} onChange={e => setForm(f => ({ ...f, password: e.target.value }))} required
|
||||
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" /></div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowModal(false)} className="flex-1 border border-[#1e2d45] text-[#94a3b8] text-sm py-2 rounded">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="flex-1 bg-sky-500 hover:bg-sky-400 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? '...' : 'Create'}</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={addForwarder} className="p-4 space-y-3">
|
||||
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 text-xs px-3 py-2 rounded">{error}</div>}
|
||||
<div><label className="block text-[#94a3b8] text-xs mb-1">Source</label>
|
||||
<input value={fwdForm.source} onChange={e => setFwdForm(f => ({ ...f, source: e.target.value }))} required placeholder="[email protected]"
|
||||
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" /></div>
|
||||
<div><label className="block text-[#94a3b8] text-xs mb-1">Destination</label>
|
||||
<input value={fwdForm.destination} onChange={e => setFwdForm(f => ({ ...f, destination: e.target.value }))} required placeholder="[email protected]"
|
||||
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" /></div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowFwd(false)} className="flex-1 border border-[#1e2d45] text-[#94a3b8] text-sm py-2 rounded">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="flex-1 bg-sky-500 hover:bg-sky-400 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? '...' : 'Add'}</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Anchor, Plus, Trash2, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface FtpAccount { id: number; username: string; homedir: string; quotaMb: number; status: string }
|
||||
|
||||
export default function FTP() {
|
||||
const [accounts, setAccounts] = useState<FtpAccount[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [form, setForm] = useState({ username: '', password: '', homedir: '/', quotaMb: 0 })
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try { const r = await api.get('/cpanel/ftp'); setAccounts(r.data.data) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setError('')
|
||||
try { await api.post('/cpanel/ftp', form); setShowModal(false); fetch() }
|
||||
catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const del = async (id: number) => {
|
||||
if (!confirm('Delete this FTP account?')) return
|
||||
try { await api.delete(`/cpanel/ftp/${id}`); fetch() } catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">FTP Accounts</h1><p className="text-[#64748b] text-sm">{accounts.length} accounts</p></div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#1e2d45] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
<button onClick={() => setShowModal(true)} className="flex items-center gap-2 bg-sky-500 hover:bg-sky-400 text-white text-sm px-3 py-2 rounded-lg"><Plus size={16} /> New FTP Account</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#1e2d45]">
|
||||
{['Username', 'Directory', 'Quota', ''].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{accounts.map(a => (
|
||||
<tr key={a.id} className="border-b border-[#1e2d45] last:border-0 hover:bg-[#1a2235]">
|
||||
<td className="px-4 py-3 text-white"><Anchor size={12} className="inline mr-2 text-sky-400" />{a.username}</td>
|
||||
<td className="px-4 py-3 font-mono text-[#64748b] text-xs">{a.homedir}</td>
|
||||
<td className="px-4 py-3 text-[#64748b]">{a.quotaMb === 0 ? 'Unlimited' : `${a.quotaMb} MB`}</td>
|
||||
<td className="px-4 py-3"><button onClick={() => del(a.id)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && accounts.length === 0 && <tr><td colSpan={4} className="text-center text-[#64748b] py-8">No FTP accounts</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl w-full max-w-sm">
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#1e2d45]">
|
||||
<h2 className="text-white font-medium">New FTP Account</h2>
|
||||
<button onClick={() => setShowModal(false)} className="text-[#64748b] hover:text-white">✕</button>
|
||||
</div>
|
||||
<form onSubmit={save} className="p-4 space-y-3">
|
||||
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 text-xs px-3 py-2 rounded">{error}</div>}
|
||||
{[['Username', 'username', 'text', 'ftpuser'], ['Password', 'password', 'password', ''], ['Directory', 'homedir', 'text', '/public_html']].map(([l, k, t, p]) => (
|
||||
<div key={k}>
|
||||
<label className="block text-[#94a3b8] text-xs mb-1">{l}</label>
|
||||
<input type={t} value={String(form[k as keyof typeof form])} onChange={e => setForm(f => ({ ...f, [k]: e.target.value }))} required={k !== 'homedir'} placeholder={p}
|
||||
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowModal(false)} className="flex-1 border border-[#1e2d45] text-[#94a3b8] text-sm py-2 rounded">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="flex-1 bg-sky-500 hover:bg-sky-400 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? '...' : 'Create'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Folder, FileText, ChevronRight, Trash2, Pencil, Plus, Upload, RefreshCw, FolderPlus, Save, X } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface FileEntry { name: string; type: 'file' | 'directory'; size: number; mtime: string; path: string }
|
||||
|
||||
const fmtSize = (b: number) => b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)} MB` : b > 1024 ? `${(b / 1024).toFixed(0)} KB` : `${b} B`
|
||||
|
||||
export default function FileManager() {
|
||||
const [path, setPath] = useState('/')
|
||||
const [files, setFiles] = useState<FileEntry[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [editing, setEditing] = useState<{ path: string; content: string } | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [newDir, setNewDir] = useState('')
|
||||
const [showNewDir, setShowNewDir] = useState(false)
|
||||
|
||||
const loadDir = async (p: string) => {
|
||||
setLoading(true); setSelected(new Set())
|
||||
try {
|
||||
const r = await api.get(`/cpanel/files?path=${encodeURIComponent(p)}`)
|
||||
setFiles(r.data.data.files); setPath(r.data.data.path)
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed to load directory')
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { loadDir('/') }, [])
|
||||
|
||||
const navigate = (entry: FileEntry) => {
|
||||
if (entry.type === 'directory') loadDir(entry.path)
|
||||
else openFile(entry.path)
|
||||
}
|
||||
|
||||
const openFile = async (filePath: string) => {
|
||||
try {
|
||||
const r = await api.get(`/cpanel/files/content?path=${encodeURIComponent(filePath)}`)
|
||||
setEditing({ path: filePath, content: r.data.data.content })
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Cannot open file')
|
||||
}
|
||||
}
|
||||
|
||||
const saveFile = async () => {
|
||||
if (!editing) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.put('/cpanel/files/content', { path: editing.path, content: editing.content })
|
||||
setEditing(null)
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Save failed')
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const deleteSelected = async () => {
|
||||
if (!selected.size || !confirm(`Delete ${selected.size} item(s)?`)) return
|
||||
try {
|
||||
await api.delete('/cpanel/files', { data: { paths: Array.from(selected) } })
|
||||
loadDir(path)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const createDir = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await api.post('/cpanel/files/mkdir', { path: `${path}/${newDir}` })
|
||||
setNewDir(''); setShowNewDir(false); loadDir(path)
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed')
|
||||
}
|
||||
}
|
||||
|
||||
// Breadcrumb
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-white text-xl font-semibold">File Manager</h1>
|
||||
<div className="flex gap-2">
|
||||
{selected.size > 0 && (
|
||||
<button onClick={deleteSelected} className="flex items-center gap-1.5 text-xs text-red-400 border border-red-500/30 px-3 py-2 rounded-lg hover:bg-red-500/10">
|
||||
<Trash2 size={14} /> Delete ({selected.size})
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setShowNewDir(v => !v)} className="flex items-center gap-1.5 text-xs text-[#94a3b8] border border-[#1e2d45] px-3 py-2 rounded-lg hover:bg-[#1a2235]">
|
||||
<FolderPlus size={14} /> New Folder
|
||||
</button>
|
||||
<button onClick={() => loadDir(path)} className="p-2 text-[#64748b] hover:text-white border border-[#1e2d45] rounded-lg">
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-1 text-sm text-[#64748b]">
|
||||
<button onClick={() => loadDir('/')} className="hover:text-white">~</button>
|
||||
{parts.map((part, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<ChevronRight size={12} />
|
||||
<button onClick={() => loadDir('/' + parts.slice(0, i + 1).join('/'))} className="hover:text-white">{part}</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* New dir form */}
|
||||
{showNewDir && (
|
||||
<form onSubmit={createDir} className="flex gap-2">
|
||||
<input value={newDir} onChange={e => setNewDir(e.target.value)} placeholder="folder-name" autoFocus required
|
||||
className="bg-[#111827] border border-[#1e2d45] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-sky-500" />
|
||||
<button type="submit" className="bg-sky-500 hover:bg-sky-400 text-white text-sm px-3 py-2 rounded-lg">Create</button>
|
||||
<button type="button" onClick={() => setShowNewDir(false)} className="text-[#64748b] hover:text-white px-2"><X size={16} /></button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* File list */}
|
||||
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#1e2d45]">
|
||||
<th className="w-8 px-4 py-2"></th>
|
||||
{['Name', 'Size', 'Modified', ''].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-2 text-xs">{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{path !== '/' && (
|
||||
<tr className="border-b border-[#1e2d45] hover:bg-[#1a2235] cursor-pointer" onClick={() => loadDir(path.split('/').slice(0, -1).join('/') || '/')}>
|
||||
<td className="px-4 py-2"></td>
|
||||
<td className="px-4 py-2 text-[#64748b]" colSpan={3}>..</td>
|
||||
</tr>
|
||||
)}
|
||||
{files.map(f => (
|
||||
<tr key={f.name} className="border-b border-[#1e2d45] last:border-0 hover:bg-[#1a2235] cursor-pointer">
|
||||
<td className="px-4 py-2" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selected.has(f.path)} onChange={e => {
|
||||
const next = new Set(selected)
|
||||
e.target.checked ? next.add(f.path) : next.delete(f.path)
|
||||
setSelected(next)
|
||||
}} className="accent-sky-500" />
|
||||
</td>
|
||||
<td className="px-4 py-2" onClick={() => navigate(f)}>
|
||||
<span className="flex items-center gap-2">
|
||||
{f.type === 'directory'
|
||||
? <Folder size={14} className="text-amber-400 flex-shrink-0" />
|
||||
: <FileText size={14} className="text-[#64748b] flex-shrink-0" />}
|
||||
<span className={f.type === 'directory' ? 'text-white' : 'text-[#94a3b8]'}>{f.name}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-[#64748b] text-xs">{f.type === 'file' ? fmtSize(f.size) : '—'}</td>
|
||||
<td className="px-4 py-2 text-[#64748b] text-xs">{new Date(f.mtime).toLocaleDateString()}</td>
|
||||
<td className="px-4 py-2">
|
||||
{f.type === 'file' && (
|
||||
<button onClick={() => openFile(f.path)} className="p-1 text-[#64748b] hover:text-white rounded"><Pencil size={13} /></button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && files.length === 0 && <tr><td colSpan={5} className="text-center text-[#64748b] py-8">Empty directory</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* File editor modal */}
|
||||
{editing && (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-[#0a0f1a]">
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-[#111827] border-b border-[#1e2d45]">
|
||||
<span className="text-white text-sm font-mono">{editing.path}</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={saveFile} disabled={saving} className="flex items-center gap-1.5 bg-sky-500 hover:bg-sky-400 disabled:opacity-50 text-white text-sm px-3 py-1.5 rounded-lg">
|
||||
<Save size={14} /> {saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button onClick={() => setEditing(null)} className="p-1.5 text-[#64748b] hover:text-white"><X size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={editing.content}
|
||||
onChange={e => setEditing(ed => ed ? { ...ed, content: e.target.value } : null)}
|
||||
className="flex-1 bg-[#0a0f1a] text-[#94a3b8] font-mono text-xs p-4 resize-none focus:outline-none"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setError(''); setLoading(true)
|
||||
try {
|
||||
await login(username, password)
|
||||
navigate('/')
|
||||
} catch (err: unknown) {
|
||||
setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? (err instanceof Error ? err.message : 'Login failed'))
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0f1a] flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="w-12 h-12 rounded-2xl bg-sky-500 flex items-center justify-center mb-3">
|
||||
<span className="text-white text-xl font-bold">N</span>
|
||||
</div>
|
||||
<h1 className="text-white text-xl font-bold">NixPanel</h1>
|
||||
<p className="text-[#64748b] text-sm mt-1">Hosting Control Panel</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-[#111827] border border-[#1e2d45] rounded-xl p-6 space-y-4">
|
||||
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 text-sm px-3 py-2 rounded-lg">{error}</div>}
|
||||
<div>
|
||||
<label className="block text-[#94a3b8] text-xs font-medium mb-1.5">Username</label>
|
||||
<input type="text" value={username} onChange={e => setUsername(e.target.value)} required autoFocus
|
||||
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-sky-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[#94a3b8] text-xs font-medium mb-1.5">Password</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required
|
||||
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-sky-500" />
|
||||
</div>
|
||||
<button type="submit" disabled={loading}
|
||||
className="w-full bg-sky-500 hover:bg-sky-400 disabled:opacity-50 text-white text-sm font-medium py-2.5 rounded-lg transition-colors">
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[#64748b] text-xs mt-4">NixPanel v0.1.0 · Port 2083</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Database, Mail, Globe, FolderOpen, Lock, Anchor, Clock, HardDrive } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
interface Stats {
|
||||
diskUsedMb: number
|
||||
diskLimitMb: number
|
||||
bandwidthUsedMb: number
|
||||
bandwidthLimitMb: number
|
||||
counts: {
|
||||
emailAccounts: number; databases: number; ftpAccounts: number
|
||||
subdomains: number; addonDomains: number; sslCertificates: number; cronJobs: number
|
||||
}
|
||||
limits: {
|
||||
maxEmailAccounts: number; maxDatabases: number; maxFtpAccounts: number
|
||||
maxSubdomains: number; maxAddonDomains: number; maxCronJobs: number
|
||||
} | null
|
||||
}
|
||||
|
||||
function UsageBar({ used, limit, label }: { used: number; limit: number; label: string }) {
|
||||
const pct = limit === 0 ? 0 : Math.min(100, Math.round((used / limit) * 100))
|
||||
const color = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-sky-500'
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-[#94a3b8]">{label}</span>
|
||||
<span className="text-[#64748b]">{used} / {limit === 0 ? '∞' : limit} MB {limit > 0 && `(${pct}%)`}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-[#1e2d45] rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all ${color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CountCard({ icon: Icon, label, used, limit, to }: { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; used: number; limit: number; to: string }) {
|
||||
return (
|
||||
<a href={to} className="bg-[#111827] border border-[#1e2d45] rounded-xl p-4 hover:border-sky-500/30 transition-colors block">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[#64748b] text-xs">{label}</span>
|
||||
<Icon size={14} className="text-sky-400" />
|
||||
</div>
|
||||
<p className="text-white text-2xl font-bold">{used}</p>
|
||||
{limit > 0 && <p className="text-[#64748b] text-xs mt-0.5">of {limit}</p>}
|
||||
{limit === 0 && <p className="text-[#64748b] text-xs mt-0.5">unlimited</p>}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Overview() {
|
||||
const { user } = useAuth()
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/cpanel/stats').then(r => setStats(r.data.data)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Welcome, {user?.username}</h1>
|
||||
<p className="text-[#64748b] text-sm">{user?.domain}</p>
|
||||
</div>
|
||||
|
||||
{/* Resource usage */}
|
||||
{stats && (
|
||||
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl p-4 space-y-4">
|
||||
<h2 className="text-white text-sm font-medium">Resource Usage</h2>
|
||||
<UsageBar used={stats.diskUsedMb} limit={stats.diskLimitMb} label="Disk Space" />
|
||||
<UsageBar used={stats.bandwidthUsedMb} limit={stats.bandwidthLimitMb} label="Bandwidth" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feature grid */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
<CountCard icon={Mail} label="Email Accounts" used={stats.counts.emailAccounts} limit={stats.limits?.maxEmailAccounts ?? 0} to="/email" />
|
||||
<CountCard icon={Database} label="Databases" used={stats.counts.databases} limit={stats.limits?.maxDatabases ?? 0} to="/databases" />
|
||||
<CountCard icon={Anchor} label="FTP Accounts" used={stats.counts.ftpAccounts} limit={stats.limits?.maxFtpAccounts ?? 0} to="/ftp" />
|
||||
<CountCard icon={Globe} label="Subdomains" used={stats.counts.subdomains} limit={stats.limits?.maxSubdomains ?? 0} to="/domains" />
|
||||
<CountCard icon={Globe} label="Addon Domains" used={stats.counts.addonDomains} limit={stats.limits?.maxAddonDomains ?? 0} to="/domains" />
|
||||
<CountCard icon={Lock} label="SSL Certs" used={stats.counts.sslCertificates} limit={0} to="/ssl" />
|
||||
<CountCard icon={Clock} label="Cron Jobs" used={stats.counts.cronJobs} limit={stats.limits?.maxCronJobs ?? 0} to="/cron" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Lock, RefreshCw, RotateCcw } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface Cert { id: number; domain: string; status: string; issuedAt: string | null; expiresAt: string | null }
|
||||
|
||||
export default function SSL() {
|
||||
const [certs, setCerts] = useState<Cert[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try { const r = await api.get('/whm/ssl'); setCerts(r.data.data) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const renew = async (id: number) => {
|
||||
try { await api.post(`/whm/ssl/${id}/renew`); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Renewal failed') }
|
||||
}
|
||||
|
||||
const days = (d: string | null) => {
|
||||
if (!d) return null
|
||||
return Math.ceil((new Date(d).getTime() - Date.now()) / 86400000)
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
pending: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
|
||||
expired: 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
revoked: 'bg-[#1a2235] text-[#64748b] border-[#1e2d45]',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">SSL Certificates</h1><p className="text-[#64748b] text-sm">Let's Encrypt certificates for your domains</p></div>
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#1e2d45] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#1e2d45]">
|
||||
{['Domain', 'Status', 'Expires', ''].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{certs.map(c => {
|
||||
const d = days(c.expiresAt)
|
||||
return (
|
||||
<tr key={c.id} className="border-b border-[#1e2d45] last:border-0 hover:bg-[#1a2235]">
|
||||
<td className="px-4 py-3 text-white flex items-center gap-2"><Lock size={12} className="text-emerald-400" />{c.domain}</td>
|
||||
<td className="px-4 py-3"><span className={`text-xs px-2 py-0.5 rounded-full border ${statusColor[c.status] ?? ''}`}>{c.status}</span></td>
|
||||
<td className="px-4 py-3 text-xs">
|
||||
{c.expiresAt
|
||||
? <span className={d !== null && d < 14 ? 'text-red-400' : d !== null && d < 30 ? 'text-amber-400' : 'text-[#64748b]'}>
|
||||
{d !== null && d > 0 ? `${d} days` : 'Expired'} ({new Date(c.expiresAt).toLocaleDateString()})
|
||||
</span>
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{c.status === 'active' && (
|
||||
<button onClick={() => renew(c.id)} className="flex items-center gap-1 px-2 py-1 text-xs text-sky-400 border border-sky-500/30 rounded hover:bg-sky-500/10">
|
||||
<RotateCcw size={11} /> Renew
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{!loading && certs.length === 0 && <tr><td colSpan={4} className="text-center text-[#64748b] py-8">No certificates. Contact your host to issue SSL.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { RefreshCw, HardDrive, Wifi } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface Stats {
|
||||
diskUsedMb: number; diskLimitMb: number
|
||||
bandwidthUsedMb: number; bandwidthLimitMb: number
|
||||
counts: Record<string, number>
|
||||
}
|
||||
|
||||
function Bar({ label, used, limit, icon: Icon }: { label: string; used: number; limit: number; icon: React.ComponentType<{ size?: number; className?: string }> }) {
|
||||
const pct = limit === 0 ? 0 : Math.min(100, Math.round((used / limit) * 100))
|
||||
const color = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-sky-500'
|
||||
return (
|
||||
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Icon size={14} className="text-sky-400" />
|
||||
<span className="text-white text-sm font-medium">{label}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs mb-2 text-[#64748b]">
|
||||
<span>{used} MB used</span>
|
||||
<span>{limit === 0 ? 'Unlimited' : `${limit} MB total`} {limit > 0 && `(${pct}%)`}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-[#1e2d45] rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Stats() {
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try { const r = await api.get('/cpanel/stats'); setStats(r.data.data) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
emailAccounts: 'Email Accounts', databases: 'Databases', ftpAccounts: 'FTP Accounts',
|
||||
subdomains: 'Subdomains', addonDomains: 'Addon Domains', parkedDomains: 'Parked Domains',
|
||||
sslCertificates: 'SSL Certificates', cronJobs: 'Cron Jobs',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">Statistics</h1><p className="text-[#64748b] text-sm">Account resource usage</p></div>
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#1e2d45] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
</div>
|
||||
|
||||
{stats ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Bar label="Disk Space" used={stats.diskUsedMb} limit={stats.diskLimitMb} icon={HardDrive} />
|
||||
<Bar label="Bandwidth" used={stats.bandwidthUsedMb} limit={stats.bandwidthLimitMb} icon={Wifi} />
|
||||
</div>
|
||||
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl p-4">
|
||||
<h2 className="text-white text-sm font-medium mb-4">Feature Usage</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{Object.entries(stats.counts).map(([key, val]) => (
|
||||
<div key={key} className="text-center">
|
||||
<p className="text-white text-2xl font-bold">{val}</p>
|
||||
<p className="text-[#64748b] text-xs mt-0.5">{labels[key] ?? key}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center text-[#64748b] py-12">{loading ? 'Loading...' : 'No data'}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#0a0f1a',
|
||||
secondary: '#111827',
|
||||
card: '#1a2235',
|
||||
border: '#1e2d45',
|
||||
accent: '#0ea5e9',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(__dirname, './src') },
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:2087', changeOrigin: true },
|
||||
'/ws': { target: 'ws://localhost:2087', ws: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "nixpanel-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"server",
|
||||
"whm",
|
||||
"cpanel"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev --workspace=server\" \"npm run dev --workspace=whm\" \"npm run dev --workspace=cpanel\"",
|
||||
"dev:server": "npm run dev --workspace=server",
|
||||
"dev:whm": "npm run dev --workspace=whm",
|
||||
"dev:cpanel": "npm run dev --workspace=cpanel",
|
||||
"build": "npm run build --workspace=server && npm run build --workspace=whm && npm run build --workspace=cpanel",
|
||||
"db:migrate": "npm run db:migrate --workspace=server",
|
||||
"db:studio": "npm run db:studio --workspace=server"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
}
|
||||
Executable
+687
@@ -0,0 +1,687 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# NixPanel Installer
|
||||
# Usage: curl -fsSL https://raw.githubusercontent.com/NixReaper/nixpanel-server/main/scripts/install.sh | bash
|
||||
# Or: bash install.sh [--admin-user NAME] [--admin-pass PASS] [--admin-email EMAIL]
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# ── Colours ───────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
|
||||
info() { echo -e "${CYAN}[INFO]${RESET} $*"; }
|
||||
success() { echo -e "${GREEN}[OK]${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; exit 1; }
|
||||
step() { echo -e "\n${BOLD}${CYAN}━━━ $* ━━━${RESET}"; }
|
||||
|
||||
# ── Banner ────────────────────────────────────────────────────────────────────
|
||||
echo -e "${BOLD}${CYAN}"
|
||||
cat <<'EOF'
|
||||
_ _ _ ____ _
|
||||
| \ | (_)_ _| _ \ __ _ _ __ ___| |
|
||||
| \| | \ \/ / |_) / _` | '_ \ / _ \ |
|
||||
| |\ | |> <| __/ (_| | | | | __/ |
|
||||
|_| \_|_/_/\_\_| \__,_|_| |_|\___|_|
|
||||
|
||||
Open-Source Web Hosting Control Panel
|
||||
EOF
|
||||
echo -e "${RESET}"
|
||||
echo -e " Version : ${BOLD}0.1.0${RESET}"
|
||||
echo -e " GitHub : ${BOLD}https://github.com/NixReaper/nixpanel-server${RESET}"
|
||||
echo -e " WHM : ${BOLD}https://<your-ip>:2087${RESET}"
|
||||
echo -e " cPanel : ${BOLD}https://<your-ip>:2083${RESET}"
|
||||
echo ""
|
||||
|
||||
# ── Root check ────────────────────────────────────────────────────────────────
|
||||
[[ $EUID -ne 0 ]] && error "This installer must be run as root. Try: sudo bash install.sh"
|
||||
|
||||
# ── OS detection ──────────────────────────────────────────────────────────────
|
||||
step "Detecting OS"
|
||||
|
||||
detect_os() {
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
. /etc/os-release
|
||||
OS_ID="${ID,,}"
|
||||
OS_VERSION_ID="${VERSION_ID:-}"
|
||||
OS_LIKE="${ID_LIKE:-}"
|
||||
else
|
||||
error "Cannot detect OS — /etc/os-release not found"
|
||||
fi
|
||||
|
||||
case "$OS_ID" in
|
||||
ubuntu|debian) PKG_MGR="apt"; OS_FAMILY="debian" ;;
|
||||
almalinux|rocky|rhel|centos)
|
||||
PKG_MGR="dnf"; OS_FAMILY="rhel" ;;
|
||||
fedora) PKG_MGR="dnf"; OS_FAMILY="rhel" ;;
|
||||
*)
|
||||
# Try ID_LIKE fallback
|
||||
if [[ "$OS_LIKE" == *debian* ]]; then
|
||||
PKG_MGR="apt"; OS_FAMILY="debian"
|
||||
elif [[ "$OS_LIKE" == *rhel* ]] || [[ "$OS_LIKE" == *fedora* ]]; then
|
||||
PKG_MGR="dnf"; OS_FAMILY="rhel"
|
||||
else
|
||||
error "Unsupported OS: $OS_ID. Supported: Ubuntu, Debian, AlmaLinux, Rocky Linux, RHEL, CentOS."
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
info "Detected OS: ${PRETTY_NAME:-$OS_ID} (family: $OS_FAMILY)"
|
||||
}
|
||||
|
||||
detect_os
|
||||
|
||||
# ── Architecture check ────────────────────────────────────────────────────────
|
||||
ARCH=$(uname -m)
|
||||
[[ "$ARCH" != "x86_64" && "$ARCH" != "aarch64" ]] && error "Unsupported architecture: $ARCH (x86_64 and aarch64 only)"
|
||||
|
||||
# ── Argument parsing ──────────────────────────────────────────────────────────
|
||||
ADMIN_USER="admin"
|
||||
ADMIN_PASS=""
|
||||
ADMIN_EMAIL=""
|
||||
INSTALL_DIR="/opt/nixpanel"
|
||||
NIXPANEL_DB_USER="nixpanel"
|
||||
NIXPANEL_DB_NAME="nixpanel"
|
||||
NIXPANEL_DB_PASS=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--admin-user) ADMIN_USER="$2"; shift 2 ;;
|
||||
--admin-pass) ADMIN_PASS="$2"; shift 2 ;;
|
||||
--admin-email) ADMIN_EMAIL="$2"; shift 2 ;;
|
||||
--install-dir) INSTALL_DIR="$2"; shift 2 ;;
|
||||
*) warn "Unknown argument: $1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Generate secrets ──────────────────────────────────────────────────────────
|
||||
gen_secret() { openssl rand -hex 32; }
|
||||
|
||||
if [[ -z "$ADMIN_PASS" ]]; then
|
||||
ADMIN_PASS=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16)
|
||||
GENERATED_PASS=true
|
||||
else
|
||||
GENERATED_PASS=false
|
||||
fi
|
||||
|
||||
if [[ -z "$ADMIN_EMAIL" ]]; then
|
||||
ADMIN_EMAIL="admin@$(hostname -f 2>/dev/null || echo localhost)"
|
||||
fi
|
||||
|
||||
if [[ -z "$NIXPANEL_DB_PASS" ]]; then
|
||||
NIXPANEL_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
|
||||
fi
|
||||
|
||||
JWT_SECRET=$(gen_secret)
|
||||
JWT_REFRESH_SECRET=$(gen_secret)
|
||||
|
||||
# ── Confirm ───────────────────────────────────────────────────────────────────
|
||||
echo -e "${BOLD}Installation summary:${RESET}"
|
||||
echo " Install directory : $INSTALL_DIR"
|
||||
echo " Admin username : $ADMIN_USER"
|
||||
echo " Admin email : $ADMIN_EMAIL"
|
||||
echo " Admin password : ${GENERATED_PASS:+<auto-generated, shown at end>}${GENERATED_PASS:-$ADMIN_PASS}"
|
||||
echo ""
|
||||
read -r -p "Continue? [Y/n] " CONFIRM
|
||||
CONFIRM="${CONFIRM:-Y}"
|
||||
[[ "$CONFIRM" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
|
||||
|
||||
# ── Update package index ──────────────────────────────────────────────────────
|
||||
step "Updating package index"
|
||||
if [[ "$PKG_MGR" == "apt" ]]; then
|
||||
apt-get update -qq
|
||||
elif [[ "$PKG_MGR" == "dnf" ]]; then
|
||||
dnf makecache -q
|
||||
fi
|
||||
success "Package index updated"
|
||||
|
||||
# ── Install base dependencies ─────────────────────────────────────────────────
|
||||
step "Installing base dependencies"
|
||||
|
||||
APT_DEPS=(curl wget gnupg2 ca-certificates lsb-release software-properties-common
|
||||
git build-essential openssl ufw fail2ban)
|
||||
DNF_DEPS=(curl wget gnupg2 ca-certificates git openssl firewalld fail2ban)
|
||||
|
||||
if [[ "$PKG_MGR" == "apt" ]]; then
|
||||
apt-get install -y -qq "${APT_DEPS[@]}"
|
||||
else
|
||||
dnf install -y -q "${DNF_DEPS[@]}"
|
||||
fi
|
||||
success "Base dependencies installed"
|
||||
|
||||
# ── Install Node.js 22 (LTS) ──────────────────────────────────────────────────
|
||||
step "Installing Node.js 22 LTS"
|
||||
|
||||
if command -v node &>/dev/null; then
|
||||
NODE_VER=$(node --version | cut -c2- | cut -d. -f1)
|
||||
if [[ "$NODE_VER" -ge 20 ]]; then
|
||||
info "Node.js $(node --version) already installed — skipping"
|
||||
else
|
||||
warn "Node.js $NODE_VER found — upgrading to v22"
|
||||
install_node=true
|
||||
fi
|
||||
else
|
||||
install_node=true
|
||||
fi
|
||||
|
||||
if [[ "${install_node:-false}" == "true" ]] || ! command -v node &>/dev/null; then
|
||||
if [[ "$PKG_MGR" == "apt" ]]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - >/dev/null 2>&1
|
||||
apt-get install -y -qq nodejs
|
||||
else
|
||||
curl -fsSL https://rpm.nodesource.com/setup_22.x | bash - >/dev/null 2>&1
|
||||
dnf install -y -q nodejs
|
||||
fi
|
||||
fi
|
||||
|
||||
success "Node.js $(node --version) ready"
|
||||
success "npm $(npm --version) ready"
|
||||
|
||||
# ── Install MariaDB ───────────────────────────────────────────────────────────
|
||||
step "Installing MariaDB"
|
||||
|
||||
if command -v mariadb &>/dev/null || command -v mysqld &>/dev/null; then
|
||||
info "MariaDB/MySQL already installed — skipping"
|
||||
else
|
||||
if [[ "$PKG_MGR" == "apt" ]]; then
|
||||
apt-get install -y -qq mariadb-server mariadb-client
|
||||
else
|
||||
dnf install -y -q mariadb-server mariadb
|
||||
fi
|
||||
fi
|
||||
|
||||
systemctl enable --now mariadb
|
||||
|
||||
# Secure MariaDB and create DB/user
|
||||
info "Configuring MariaDB..."
|
||||
mariadb -u root <<SQL
|
||||
ALTER USER IF EXISTS 'root'@'localhost' IDENTIFIED VIA unix_socket;
|
||||
DELETE FROM mysql.user WHERE User='';
|
||||
DELETE FROM mysql.user WHERE User='root' AND Host NOT IN ('localhost','127.0.0.1','::1');
|
||||
DROP DATABASE IF EXISTS test;
|
||||
DELETE FROM mysql.db WHERE Db='test' OR Db='test\\_%';
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS \`${NIXPANEL_DB_NAME}\`
|
||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE USER IF NOT EXISTS '${NIXPANEL_DB_USER}'@'localhost'
|
||||
IDENTIFIED BY '${NIXPANEL_DB_PASS}';
|
||||
|
||||
GRANT ALL PRIVILEGES ON \`${NIXPANEL_DB_NAME}\`.* TO '${NIXPANEL_DB_USER}'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
SQL
|
||||
|
||||
success "MariaDB configured — database '${NIXPANEL_DB_NAME}' created"
|
||||
|
||||
# ── Install Nginx ─────────────────────────────────────────────────────────────
|
||||
step "Installing Nginx"
|
||||
|
||||
if command -v nginx &>/dev/null; then
|
||||
info "Nginx already installed — skipping"
|
||||
else
|
||||
if [[ "$PKG_MGR" == "apt" ]]; then
|
||||
apt-get install -y -qq nginx
|
||||
else
|
||||
dnf install -y -q nginx
|
||||
fi
|
||||
fi
|
||||
|
||||
systemctl enable --now nginx
|
||||
success "Nginx ready"
|
||||
|
||||
# ── Install PHP 8.3 + FPM ─────────────────────────────────────────────────────
|
||||
step "Installing PHP 8.3 + FPM"
|
||||
|
||||
if [[ "$PKG_MGR" == "apt" ]]; then
|
||||
if ! apt-cache show php8.3 &>/dev/null 2>&1; then
|
||||
add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
|
||||
apt-get update -qq
|
||||
fi
|
||||
apt-get install -y -qq php8.3-fpm php8.3-cli php8.3-mysql php8.3-curl \
|
||||
php8.3-gd php8.3-mbstring php8.3-xml php8.3-zip php8.3-bcmath \
|
||||
php8.3-intl php8.3-soap php8.3-imagick php8.3-redis php8.3-opcache
|
||||
else
|
||||
dnf install -y -q php8.3 php8.3-fpm php8.3-mysqlnd php8.3-curl \
|
||||
php8.3-gd php8.3-mbstring php8.3-xml php8.3-zip php8.3-bcmath \
|
||||
php8.3-intl php8.3-soap php8.3-opcache 2>/dev/null || \
|
||||
dnf install -y -q php php-fpm php-mysqlnd php-curl php-gd php-mbstring \
|
||||
php-xml php-zip php-bcmath php-intl php-soap php-opcache
|
||||
fi
|
||||
|
||||
systemctl enable --now php8.3-fpm 2>/dev/null || systemctl enable --now php-fpm
|
||||
success "PHP 8.3 + FPM ready"
|
||||
|
||||
# ── Install Postfix + Dovecot ─────────────────────────────────────────────────
|
||||
step "Installing Postfix + Dovecot"
|
||||
|
||||
if [[ "$PKG_MGR" == "apt" ]]; then
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
|
||||
postfix postfix-mysql dovecot-core dovecot-imapd dovecot-pop3d \
|
||||
dovecot-lmtpd dovecot-mysql spamassassin
|
||||
else
|
||||
dnf install -y -q postfix dovecot dovecot-mysql
|
||||
fi
|
||||
|
||||
systemctl enable postfix dovecot
|
||||
success "Postfix + Dovecot installed"
|
||||
|
||||
# ── Install vsftpd ────────────────────────────────────────────────────────────
|
||||
step "Installing vsftpd"
|
||||
|
||||
if [[ "$PKG_MGR" == "apt" ]]; then
|
||||
apt-get install -y -qq vsftpd
|
||||
else
|
||||
dnf install -y -q vsftpd
|
||||
fi
|
||||
|
||||
systemctl enable vsftpd
|
||||
success "vsftpd installed"
|
||||
|
||||
# ── Install BIND9 ─────────────────────────────────────────────────────────────
|
||||
step "Installing BIND9"
|
||||
|
||||
if [[ "$PKG_MGR" == "apt" ]]; then
|
||||
apt-get install -y -qq bind9 bind9utils bind9-doc
|
||||
else
|
||||
dnf install -y -q bind bind-utils
|
||||
fi
|
||||
|
||||
BIND_SERVICE="named"
|
||||
[[ "$PKG_MGR" == "apt" ]] && BIND_SERVICE="named" || true
|
||||
systemctl enable --now "$BIND_SERVICE" 2>/dev/null || systemctl enable --now bind9 2>/dev/null || true
|
||||
|
||||
BIND_ZONES_DIR="/etc/bind/zones"
|
||||
mkdir -p "$BIND_ZONES_DIR"
|
||||
chown root:bind "$BIND_ZONES_DIR" 2>/dev/null || chown root:named "$BIND_ZONES_DIR" 2>/dev/null || true
|
||||
success "BIND9 installed"
|
||||
|
||||
# ── Install Certbot ───────────────────────────────────────────────────────────
|
||||
step "Installing Certbot"
|
||||
|
||||
if [[ "$PKG_MGR" == "apt" ]]; then
|
||||
apt-get install -y -qq certbot python3-certbot-nginx
|
||||
else
|
||||
dnf install -y -q certbot python3-certbot-nginx 2>/dev/null || \
|
||||
dnf install -y -q --enablerepo=epel certbot python3-certbot-nginx
|
||||
fi
|
||||
|
||||
mkdir -p /var/www/letsencrypt/.well-known/acme-challenge
|
||||
success "Certbot installed"
|
||||
|
||||
# ── Clone / update NixPanel ───────────────────────────────────────────────────
|
||||
step "Installing NixPanel"
|
||||
|
||||
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||
info "Existing installation found — pulling latest..."
|
||||
git -C "$INSTALL_DIR" pull --ff-only
|
||||
else
|
||||
info "Cloning repository into $INSTALL_DIR..."
|
||||
git clone https://github.com/NixReaper/nixpanel-server.git "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
success "NixPanel source ready at $INSTALL_DIR"
|
||||
|
||||
# ── Write .env ────────────────────────────────────────────────────────────────
|
||||
step "Configuring environment"
|
||||
|
||||
SERVER_IP=$(hostname -I | awk '{print $1}')
|
||||
|
||||
cat > "$INSTALL_DIR/.env" <<ENV
|
||||
# Generated by NixPanel installer on $(date -u '+%Y-%m-%d %H:%M:%S UTC')
|
||||
|
||||
# Database
|
||||
DATABASE_URL="mysql://${NIXPANEL_DB_USER}:${NIXPANEL_DB_PASS}@localhost:3306/${NIXPANEL_DB_NAME}"
|
||||
|
||||
# JWT
|
||||
JWT_SECRET="${JWT_SECRET}"
|
||||
JWT_EXPIRES_IN="15m"
|
||||
JWT_REFRESH_SECRET="${JWT_REFRESH_SECRET}"
|
||||
JWT_REFRESH_EXPIRES_IN="7d"
|
||||
|
||||
# Server
|
||||
NODE_ENV="production"
|
||||
WHM_PORT=2087
|
||||
CPANEL_PORT=2083
|
||||
HOST="0.0.0.0"
|
||||
|
||||
# Admin credentials (used during initial seed only)
|
||||
ADMIN_USERNAME="${ADMIN_USER}"
|
||||
ADMIN_PASSWORD="${ADMIN_PASS}"
|
||||
ADMIN_EMAIL="${ADMIN_EMAIL}"
|
||||
|
||||
# Paths
|
||||
HOME_DIR="/home"
|
||||
NGINX_SITES_DIR="/etc/nginx/sites-available"
|
||||
NGINX_ENABLED_DIR="/etc/nginx/sites-enabled"
|
||||
APACHE_SITES_DIR="/etc/apache2/sites-available"
|
||||
APACHE_ENABLED_DIR="/etc/apache2/sites-enabled"
|
||||
BIND_ZONES_DIR="${BIND_ZONES_DIR}"
|
||||
CERTBOT_WEBROOT="/var/www/letsencrypt"
|
||||
|
||||
# Rate limiting
|
||||
RATE_LIMIT_MAX=100
|
||||
RATE_LIMIT_WINDOW_MS=60000
|
||||
|
||||
# Features
|
||||
ENABLE_TERMINAL=true
|
||||
WEBSOCKET_STATS_INTERVAL=5000
|
||||
ENV
|
||||
|
||||
chmod 600 "$INSTALL_DIR/.env"
|
||||
success ".env written"
|
||||
|
||||
# ── npm install ───────────────────────────────────────────────────────────────
|
||||
step "Installing Node.js dependencies"
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
npm install --workspaces --include-workspace-root --silent
|
||||
success "npm dependencies installed"
|
||||
|
||||
# ── Prisma generate + migrate ─────────────────────────────────────────────────
|
||||
step "Running database migrations"
|
||||
|
||||
cd "$INSTALL_DIR/server"
|
||||
npx prisma generate --silent
|
||||
DATABASE_URL="mysql://${NIXPANEL_DB_USER}:${NIXPANEL_DB_PASS}@localhost:3306/${NIXPANEL_DB_NAME}" \
|
||||
npx prisma migrate deploy
|
||||
success "Database schema applied"
|
||||
|
||||
# ── Seed default data ─────────────────────────────────────────────────────────
|
||||
step "Seeding initial data"
|
||||
|
||||
DATABASE_URL="mysql://${NIXPANEL_DB_USER}:${NIXPANEL_DB_PASS}@localhost:3306/${NIXPANEL_DB_NAME}" \
|
||||
ADMIN_USERNAME="${ADMIN_USER}" \
|
||||
ADMIN_PASSWORD="${ADMIN_PASS}" \
|
||||
ADMIN_EMAIL="${ADMIN_EMAIL}" \
|
||||
npx tsx src/db/seed.ts
|
||||
success "Default admin, packages, and settings seeded"
|
||||
|
||||
# ── Build frontends ───────────────────────────────────────────────────────────
|
||||
step "Building WHM + cPanel frontends"
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
npm run build --workspace=whm --silent
|
||||
npm run build --workspace=cpanel --silent
|
||||
success "Frontends built"
|
||||
|
||||
# ── systemd service ───────────────────────────────────────────────────────────
|
||||
step "Creating systemd service"
|
||||
|
||||
cat > /etc/systemd/system/nixpanel.service <<UNIT
|
||||
[Unit]
|
||||
Description=NixPanel Web Hosting Control Panel
|
||||
Documentation=https://github.com/NixReaper/nixpanel-server
|
||||
After=network.target mariadb.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=${INSTALL_DIR}/server
|
||||
EnvironmentFile=${INSTALL_DIR}/.env
|
||||
ExecStart=$(which node) --enable-source-maps dist/index.js
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=nixpanel
|
||||
LimitNOFILE=65536
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
# Build server for production
|
||||
cd "$INSTALL_DIR/server"
|
||||
npm run build --silent 2>/dev/null || npx tsc --noEmit false 2>/dev/null || true
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable nixpanel
|
||||
systemctl restart nixpanel
|
||||
success "nixpanel.service created and started"
|
||||
|
||||
# ── Nginx reverse proxy ───────────────────────────────────────────────────────
|
||||
step "Configuring Nginx reverse proxy"
|
||||
|
||||
# Ensure sites-available/enabled dirs exist
|
||||
mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
|
||||
|
||||
# Main nginx.conf include (idempotent)
|
||||
if ! grep -q "sites-enabled" /etc/nginx/nginx.conf 2>/dev/null; then
|
||||
sed -i '/http {/a \ include /etc/nginx/sites-enabled/*.conf;' /etc/nginx/nginx.conf
|
||||
fi
|
||||
|
||||
cat > /etc/nginx/sites-available/nixpanel.conf <<NGINX
|
||||
# NixPanel — generated by installer
|
||||
# WHM admin panel on port 2087
|
||||
server {
|
||||
listen 2087;
|
||||
server_name _;
|
||||
|
||||
# Serve pre-built WHM React app
|
||||
root ${INSTALL_DIR}/whm/dist;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:2087;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://127.0.0.1:2087;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
|
||||
# cPanel user panel on port 2083
|
||||
server {
|
||||
listen 2083;
|
||||
server_name _;
|
||||
|
||||
root ${INSTALL_DIR}/cpanel/dist;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:2087;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTP — Let's Encrypt webroot + redirect
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/letsencrypt;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://\$host\$request_uri;
|
||||
}
|
||||
}
|
||||
NGINX
|
||||
|
||||
ln -sf /etc/nginx/sites-available/nixpanel.conf /etc/nginx/sites-enabled/nixpanel.conf
|
||||
|
||||
nginx -t && systemctl reload nginx
|
||||
success "Nginx configured"
|
||||
|
||||
# ── Firewall ──────────────────────────────────────────────────────────────────
|
||||
step "Configuring firewall"
|
||||
|
||||
if [[ "$PKG_MGR" == "apt" ]]; then
|
||||
# UFW
|
||||
ufw --force enable >/dev/null
|
||||
ufw allow 22/tcp comment "SSH" >/dev/null
|
||||
ufw allow 80/tcp comment "HTTP" >/dev/null
|
||||
ufw allow 443/tcp comment "HTTPS" >/dev/null
|
||||
ufw allow 2087/tcp comment "WHM" >/dev/null
|
||||
ufw allow 2083/tcp comment "cPanel" >/dev/null
|
||||
ufw allow 21/tcp comment "FTP" >/dev/null
|
||||
ufw allow 25/tcp comment "SMTP" >/dev/null
|
||||
ufw allow 143/tcp comment "IMAP" >/dev/null
|
||||
ufw allow 993/tcp comment "IMAPS" >/dev/null
|
||||
ufw allow 110/tcp comment "POP3" >/dev/null
|
||||
ufw allow 995/tcp comment "POP3S" >/dev/null
|
||||
ufw allow 587/tcp comment "SMTP submission" >/dev/null
|
||||
ufw allow 465/tcp comment "SMTPS" >/dev/null
|
||||
ufw allow 53 comment "DNS" >/dev/null
|
||||
success "UFW rules applied"
|
||||
else
|
||||
# firewalld
|
||||
systemctl enable --now firewalld
|
||||
for PORT in 22 80 443 2087 2083 21 25 143 993 110 995 587 465 53; do
|
||||
firewall-cmd --permanent --add-port=${PORT}/tcp >/dev/null 2>&1 || true
|
||||
done
|
||||
firewall-cmd --permanent --add-port=53/udp >/dev/null 2>&1 || true
|
||||
firewall-cmd --reload >/dev/null
|
||||
success "firewalld rules applied"
|
||||
fi
|
||||
|
||||
# ── Fail2ban ──────────────────────────────────────────────────────────────────
|
||||
step "Configuring Fail2ban"
|
||||
|
||||
cat > /etc/fail2ban/jail.d/nixpanel.conf <<F2B
|
||||
[sshd]
|
||||
enabled = true
|
||||
maxretry = 5
|
||||
bantime = 3600
|
||||
findtime = 600
|
||||
|
||||
[nginx-http-auth]
|
||||
enabled = true
|
||||
maxretry = 10
|
||||
bantime = 600
|
||||
|
||||
[postfix]
|
||||
enabled = true
|
||||
|
||||
[dovecot]
|
||||
enabled = true
|
||||
F2B
|
||||
|
||||
systemctl enable --now fail2ban
|
||||
success "Fail2ban configured"
|
||||
|
||||
# ── vsftpd configuration ──────────────────────────────────────────────────────
|
||||
step "Configuring vsftpd"
|
||||
|
||||
cat > /etc/vsftpd.conf <<VSFTPD
|
||||
listen=YES
|
||||
listen_ipv6=NO
|
||||
anonymous_enable=NO
|
||||
local_enable=YES
|
||||
write_enable=YES
|
||||
local_umask=022
|
||||
dirmessage_enable=YES
|
||||
use_localtime=YES
|
||||
xferlog_enable=YES
|
||||
connect_from_port_20=YES
|
||||
chroot_local_user=YES
|
||||
allow_writeable_chroot=YES
|
||||
secure_chroot_dir=/var/run/vsftpd/empty
|
||||
pam_service_name=vsftpd
|
||||
ssl_enable=NO
|
||||
utf8_filesystem=YES
|
||||
pasv_enable=YES
|
||||
pasv_min_port=40000
|
||||
pasv_max_port=50000
|
||||
VSFTPD
|
||||
|
||||
systemctl restart vsftpd 2>/dev/null || true
|
||||
success "vsftpd configured"
|
||||
|
||||
# ── Wait for service to start ─────────────────────────────────────────────────
|
||||
step "Waiting for NixPanel to start"
|
||||
|
||||
MAX_WAIT=30
|
||||
WAITED=0
|
||||
until curl -sf http://127.0.0.1:2087/api/health >/dev/null 2>&1; do
|
||||
sleep 2
|
||||
WAITED=$((WAITED + 2))
|
||||
[[ $WAITED -ge $MAX_WAIT ]] && { warn "Service health check timed out — check: journalctl -u nixpanel -n 50"; break; }
|
||||
done
|
||||
|
||||
if curl -sf http://127.0.0.1:2087/api/health >/dev/null 2>&1; then
|
||||
success "NixPanel API is responding"
|
||||
else
|
||||
warn "NixPanel may still be starting. Check status with: systemctl status nixpanel"
|
||||
fi
|
||||
|
||||
# ── Print completion summary ──────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${BOLD}${GREEN}╔══════════════════════════════════════════════════════╗${RESET}"
|
||||
echo -e "${BOLD}${GREEN}║ NixPanel Installation Complete! ║${RESET}"
|
||||
echo -e "${BOLD}${GREEN}╚══════════════════════════════════════════════════════╝${RESET}"
|
||||
echo ""
|
||||
echo -e " ${BOLD}WHM (Admin Panel)${RESET}"
|
||||
echo -e " URL : ${CYAN}http://${SERVER_IP}:2087${RESET}"
|
||||
echo -e " Username : ${BOLD}${ADMIN_USER}${RESET}"
|
||||
echo -e " Password : ${BOLD}${ADMIN_PASS}${RESET}"
|
||||
echo ""
|
||||
echo -e " ${BOLD}cPanel (User Panel)${RESET}"
|
||||
echo -e " URL : ${CYAN}http://${SERVER_IP}:2083${RESET}"
|
||||
echo ""
|
||||
echo -e " ${BOLD}Database${RESET}"
|
||||
echo -e " DB Name : ${NIXPANEL_DB_NAME}"
|
||||
echo -e " DB User : ${NIXPANEL_DB_USER}"
|
||||
echo -e " DB Pass : ${NIXPANEL_DB_PASS}"
|
||||
echo ""
|
||||
echo -e " ${BOLD}Files${RESET}"
|
||||
echo -e " Install : ${INSTALL_DIR}"
|
||||
echo -e " Config : ${INSTALL_DIR}/.env"
|
||||
echo -e " Service : systemctl status nixpanel"
|
||||
echo -e " Logs : journalctl -u nixpanel -f"
|
||||
echo ""
|
||||
if [[ "$GENERATED_PASS" == "true" ]]; then
|
||||
echo -e " ${YELLOW}${BOLD}⚠ Save your admin password — it will not be shown again!${RESET}"
|
||||
echo ""
|
||||
fi
|
||||
echo -e " ${BOLD}Useful commands:${RESET}"
|
||||
echo -e " systemctl restart nixpanel # restart the panel"
|
||||
echo -e " systemctl status nixpanel # check status"
|
||||
echo -e " journalctl -u nixpanel -f # live logs"
|
||||
echo ""
|
||||
|
||||
# Save credentials to a file root can read
|
||||
CRED_FILE="/root/.nixpanel_credentials"
|
||||
cat > "$CRED_FILE" <<CREDS
|
||||
NixPanel Credentials — $(date -u '+%Y-%m-%d %H:%M:%S UTC')
|
||||
==========================================================
|
||||
WHM URL : http://${SERVER_IP}:2087
|
||||
cPanel URL : http://${SERVER_IP}:2083
|
||||
Admin User : ${ADMIN_USER}
|
||||
Admin Pass : ${ADMIN_PASS}
|
||||
Admin Email: ${ADMIN_EMAIL}
|
||||
DB Name : ${NIXPANEL_DB_NAME}
|
||||
DB User : ${NIXPANEL_DB_USER}
|
||||
DB Pass : ${NIXPANEL_DB_PASS}
|
||||
Install Dir: ${INSTALL_DIR}
|
||||
CREDS
|
||||
chmod 600 "$CRED_FILE"
|
||||
|
||||
echo -e " Credentials saved to: ${BOLD}${CRED_FILE}${RESET}"
|
||||
echo ""
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# NixPanel uninstaller
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; YELLOW='\033[1;33m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
|
||||
[[ $EUID -ne 0 ]] && { echo -e "${RED}Must be run as root${RESET}"; exit 1; }
|
||||
|
||||
INSTALL_DIR="${1:-/opt/nixpanel}"
|
||||
|
||||
echo -e "${BOLD}${RED}NixPanel Uninstaller${RESET}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}This will:${RESET}"
|
||||
echo " - Stop and remove the nixpanel systemd service"
|
||||
echo " - Remove the nginx nixpanel.conf vhost"
|
||||
echo " - Remove $INSTALL_DIR"
|
||||
echo " - Drop the nixpanel MariaDB database and user"
|
||||
echo ""
|
||||
echo -e "${RED}${BOLD}This does NOT remove: nginx, MariaDB, PHP, Postfix, Dovecot, vsftpd, or BIND.${RESET}"
|
||||
echo ""
|
||||
read -r -p "Type 'yes' to confirm: " CONFIRM
|
||||
[[ "$CONFIRM" == "yes" ]] || { echo "Aborted."; exit 0; }
|
||||
|
||||
echo ""
|
||||
|
||||
# Stop + disable service
|
||||
if systemctl is-active --quiet nixpanel 2>/dev/null; then
|
||||
systemctl stop nixpanel
|
||||
echo "Stopped nixpanel service"
|
||||
fi
|
||||
if systemctl is-enabled --quiet nixpanel 2>/dev/null; then
|
||||
systemctl disable nixpanel
|
||||
fi
|
||||
rm -f /etc/systemd/system/nixpanel.service
|
||||
systemctl daemon-reload
|
||||
echo "Removed systemd service"
|
||||
|
||||
# Remove nginx vhost
|
||||
rm -f /etc/nginx/sites-enabled/nixpanel.conf
|
||||
rm -f /etc/nginx/sites-available/nixpanel.conf
|
||||
nginx -t 2>/dev/null && systemctl reload nginx 2>/dev/null || true
|
||||
echo "Removed nginx config"
|
||||
|
||||
# Drop database
|
||||
if command -v mariadb &>/dev/null; then
|
||||
mariadb -u root 2>/dev/null <<SQL || true
|
||||
DROP DATABASE IF EXISTS \`nixpanel\`;
|
||||
DROP USER IF EXISTS 'nixpanel'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
SQL
|
||||
echo "Dropped nixpanel database and user"
|
||||
fi
|
||||
|
||||
# Remove install directory
|
||||
if [[ -d "$INSTALL_DIR" ]]; then
|
||||
rm -rf "$INSTALL_DIR"
|
||||
echo "Removed $INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Remove credentials file
|
||||
rm -f /root/.nixpanel_credentials
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}NixPanel has been uninstalled.${RESET}"
|
||||
echo ""
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# NixPanel updater — pulls latest code and rebuilds
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
info() { echo -e "${CYAN}[INFO]${RESET} $*"; }
|
||||
success() { echo -e "${GREEN}[OK]${RESET} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; exit 1; }
|
||||
step() { echo -e "\n${BOLD}${CYAN}━━━ $* ━━━${RESET}"; }
|
||||
|
||||
[[ $EUID -ne 0 ]] && error "Must be run as root"
|
||||
|
||||
INSTALL_DIR="${1:-/opt/nixpanel}"
|
||||
[[ -d "$INSTALL_DIR/.git" ]] || error "NixPanel not found at $INSTALL_DIR"
|
||||
|
||||
step "Pulling latest code"
|
||||
git -C "$INSTALL_DIR" pull --ff-only
|
||||
success "Code updated"
|
||||
|
||||
step "Installing dependencies"
|
||||
cd "$INSTALL_DIR"
|
||||
npm install --workspaces --include-workspace-root --silent
|
||||
success "Dependencies ready"
|
||||
|
||||
step "Running migrations"
|
||||
cd "$INSTALL_DIR/server"
|
||||
npx prisma generate --silent
|
||||
npx prisma migrate deploy
|
||||
success "Migrations applied"
|
||||
|
||||
step "Rebuilding frontends"
|
||||
cd "$INSTALL_DIR"
|
||||
npm run build --workspace=whm --silent
|
||||
npm run build --workspace=cpanel --silent
|
||||
success "Frontends rebuilt"
|
||||
|
||||
step "Rebuilding server"
|
||||
cd "$INSTALL_DIR/server"
|
||||
npm run build --silent 2>/dev/null || npx tsc 2>/dev/null || true
|
||||
success "Server rebuilt"
|
||||
|
||||
step "Restarting service"
|
||||
systemctl restart nixpanel
|
||||
sleep 3
|
||||
systemctl is-active nixpanel && success "nixpanel restarted" || error "nixpanel failed to start — check: journalctl -u nixpanel -n 50"
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}${GREEN}NixPanel updated successfully!${RESET}"
|
||||
echo ""
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@nixpanel/server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"db:migrate": "prisma migrate deploy",
|
||||
"db:migrate:dev": "prisma migrate dev",
|
||||
"db:generate": "prisma generate",
|
||||
"db:seed": "tsx src/db/seed.ts",
|
||||
"db:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"@fastify/helmet": "^11.1.1",
|
||||
"@fastify/jwt": "^8.0.1",
|
||||
"@fastify/multipart": "^8.3.0",
|
||||
"@fastify/rate-limit": "^9.1.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@fastify/websocket": "^10.0.1",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"fastify": "^4.28.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^22.9.0",
|
||||
"prisma": "^5.22.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ─── Admin / WHM Users ───────────────────────────────────────────────────────
|
||||
|
||||
model AdminUser {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique @db.VarChar(64)
|
||||
passwordHash String @db.VarChar(255)
|
||||
email String @unique @db.VarChar(255)
|
||||
twoFactorSecret String? @db.VarChar(64)
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
lastLogin DateTime?
|
||||
lastLoginIp String? @db.VarChar(45)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
refreshTokens RefreshToken[]
|
||||
auditLogs AuditLog[]
|
||||
|
||||
@@map("admin_users")
|
||||
}
|
||||
|
||||
// ─── Resellers ───────────────────────────────────────────────────────────────
|
||||
|
||||
model Reseller {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique @db.VarChar(64)
|
||||
passwordHash String @db.VarChar(255)
|
||||
email String @unique @db.VarChar(255)
|
||||
contactName String? @db.VarChar(255)
|
||||
company String? @db.VarChar(255)
|
||||
maxAccounts Int @default(0)
|
||||
diskLimitMb BigInt @default(0)
|
||||
bandwidthLimitMb BigInt @default(0)
|
||||
diskUsedMb BigInt @default(0)
|
||||
bandwidthUsedMb BigInt @default(0)
|
||||
status String @default("active") @db.VarChar(20)
|
||||
brandName String? @db.VarChar(255)
|
||||
brandLogo String? @db.VarChar(500)
|
||||
notes String? @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accounts Account[]
|
||||
refreshTokens RefreshToken[]
|
||||
|
||||
@@map("resellers")
|
||||
}
|
||||
|
||||
// ─── Hosting Packages ────────────────────────────────────────────────────────
|
||||
|
||||
model Package {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique @db.VarChar(100)
|
||||
diskMb BigInt @default(1024)
|
||||
bandwidthMb BigInt @default(10240)
|
||||
maxDatabases Int @default(5)
|
||||
maxEmailAccounts Int @default(10)
|
||||
maxSubdomains Int @default(10)
|
||||
maxAddonDomains Int @default(5)
|
||||
maxParkedDomains Int @default(5)
|
||||
maxFtpAccounts Int @default(3)
|
||||
maxCronJobs Int @default(5)
|
||||
phpVersion String @default("8.3") @db.VarChar(10)
|
||||
sslEnabled Boolean @default(true)
|
||||
backupEnabled Boolean @default(true)
|
||||
dedicatedIp Boolean @default(false)
|
||||
notes String? @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accounts Account[]
|
||||
|
||||
@@map("packages")
|
||||
}
|
||||
|
||||
// ─── Hosting Accounts (cPanel users) ─────────────────────────────────────────
|
||||
|
||||
model Account {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique @db.VarChar(64)
|
||||
passwordHash String @db.VarChar(255)
|
||||
email String @db.VarChar(255)
|
||||
domain String @unique @db.VarChar(255)
|
||||
homedir String @db.VarChar(500)
|
||||
shell String @default("/bin/false") @db.VarChar(100)
|
||||
status String @default("active") @db.VarChar(20)
|
||||
diskUsedMb BigInt @default(0)
|
||||
bandwidthUsedMb BigInt @default(0)
|
||||
ipAddress String? @db.VarChar(45)
|
||||
nameservers String? @db.VarChar(500)
|
||||
notes String? @db.Text
|
||||
suspendedAt DateTime?
|
||||
suspendReason String? @db.VarChar(500)
|
||||
terminatedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
resellerId Int?
|
||||
reseller Reseller? @relation(fields: [resellerId], references: [id])
|
||||
packageId Int?
|
||||
package Package? @relation(fields: [packageId], references: [id])
|
||||
ipId Int?
|
||||
ip IpAddress? @relation(fields: [ipId], references: [id])
|
||||
|
||||
dnsZones DnsZone[]
|
||||
emailAccounts EmailAccount[]
|
||||
emailForwarders EmailForwarder[]
|
||||
ftpAccounts FtpAccount[]
|
||||
databases Database[]
|
||||
subdomains Subdomain[]
|
||||
addonDomains AddonDomain[]
|
||||
parkedDomains ParkedDomain[]
|
||||
sslCertificates SslCertificate[]
|
||||
cronJobs CronJob[]
|
||||
refreshTokens RefreshToken[]
|
||||
|
||||
@@map("accounts")
|
||||
}
|
||||
|
||||
// ─── IP Addresses ─────────────────────────────────────────────────────────────
|
||||
|
||||
model IpAddress {
|
||||
id Int @id @default(autoincrement())
|
||||
address String @unique @db.VarChar(45)
|
||||
netmask String? @db.VarChar(45)
|
||||
gateway String? @db.VarChar(45)
|
||||
type String @default("shared") @db.VarChar(20)
|
||||
status String @default("available") @db.VarChar(20)
|
||||
notes String? @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accounts Account[]
|
||||
|
||||
@@map("ip_addresses")
|
||||
}
|
||||
|
||||
// ─── DNS ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
model DnsZone {
|
||||
id Int @id @default(autoincrement())
|
||||
domain String @unique @db.VarChar(255)
|
||||
zoneFile String? @db.VarChar(500)
|
||||
serial BigInt @default(1)
|
||||
status String @default("active") @db.VarChar(20)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accountId Int
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
|
||||
records DnsRecord[]
|
||||
|
||||
@@map("dns_zones")
|
||||
}
|
||||
|
||||
model DnsRecord {
|
||||
id Int @id @default(autoincrement())
|
||||
type String @db.VarChar(10)
|
||||
name String @db.VarChar(255)
|
||||
value String @db.VarChar(500)
|
||||
ttl Int @default(3600)
|
||||
priority Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
zoneId Int
|
||||
zone DnsZone @relation(fields: [zoneId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("dns_records")
|
||||
}
|
||||
|
||||
// ─── Email ────────────────────────────────────────────────────────────────────
|
||||
|
||||
model EmailAccount {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @db.VarChar(64)
|
||||
domain String @db.VarChar(255)
|
||||
address String @unique @db.VarChar(320)
|
||||
passwordHash String @db.VarChar(255)
|
||||
quotaMb Int @default(500)
|
||||
usedMb Int @default(0)
|
||||
status String @default("active") @db.VarChar(20)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accountId Int
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("email_accounts")
|
||||
}
|
||||
|
||||
model EmailForwarder {
|
||||
id Int @id @default(autoincrement())
|
||||
source String @db.VarChar(320)
|
||||
destination String @db.VarChar(320)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
accountId Int
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("email_forwarders")
|
||||
}
|
||||
|
||||
// ─── FTP ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
model FtpAccount {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @db.VarChar(64)
|
||||
passwordHash String @db.VarChar(255)
|
||||
homedir String @db.VarChar(500)
|
||||
quotaMb Int @default(0)
|
||||
status String @default("active") @db.VarChar(20)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accountId Int
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("ftp_accounts")
|
||||
}
|
||||
|
||||
// ─── Databases ────────────────────────────────────────────────────────────────
|
||||
|
||||
model Database {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique @db.VarChar(64)
|
||||
charset String @default("utf8mb4") @db.VarChar(32)
|
||||
collation String @default("utf8mb4_unicode_ci") @db.VarChar(64)
|
||||
sizeMb Float @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accountId Int
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
|
||||
dbUsers DatabaseUser[]
|
||||
|
||||
@@map("databases")
|
||||
}
|
||||
|
||||
model DatabaseUser {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @db.VarChar(64)
|
||||
passwordHash String @db.VarChar(255)
|
||||
host String @default("localhost") @db.VarChar(255)
|
||||
privileges String @default("ALL") @db.VarChar(255)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
databaseId Int
|
||||
database Database @relation(fields: [databaseId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("database_users")
|
||||
}
|
||||
|
||||
// ─── Subdomains / Addon / Parked Domains ─────────────────────────────────────
|
||||
|
||||
model Subdomain {
|
||||
id Int @id @default(autoincrement())
|
||||
subdomain String @db.VarChar(255)
|
||||
domain String @db.VarChar(255)
|
||||
fqdn String @unique @db.VarChar(510)
|
||||
documentRoot String @db.VarChar(500)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accountId Int
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("subdomains")
|
||||
}
|
||||
|
||||
model AddonDomain {
|
||||
id Int @id @default(autoincrement())
|
||||
domain String @unique @db.VarChar(255)
|
||||
subdomain String @db.VarChar(255)
|
||||
documentRoot String @db.VarChar(500)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accountId Int
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("addon_domains")
|
||||
}
|
||||
|
||||
model ParkedDomain {
|
||||
id Int @id @default(autoincrement())
|
||||
domain String @unique @db.VarChar(255)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
accountId Int
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("parked_domains")
|
||||
}
|
||||
|
||||
// ─── SSL ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
model SslCertificate {
|
||||
id Int @id @default(autoincrement())
|
||||
domain String @db.VarChar(255)
|
||||
type String @default("letsencrypt") @db.VarChar(30)
|
||||
certPath String? @db.VarChar(500)
|
||||
keyPath String? @db.VarChar(500)
|
||||
chainPath String? @db.VarChar(500)
|
||||
issuedAt DateTime?
|
||||
expiresAt DateTime?
|
||||
autoRenew Boolean @default(true)
|
||||
status String @default("pending") @db.VarChar(20)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accountId Int
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("ssl_certificates")
|
||||
}
|
||||
|
||||
// ─── Cron Jobs ────────────────────────────────────────────────────────────────
|
||||
|
||||
model CronJob {
|
||||
id Int @id @default(autoincrement())
|
||||
schedule String @db.VarChar(100)
|
||||
command String @db.VarChar(1000)
|
||||
comment String? @db.VarChar(255)
|
||||
enabled Boolean @default(true)
|
||||
lastRun DateTime?
|
||||
nextRun DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accountId Int
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("cron_jobs")
|
||||
}
|
||||
|
||||
// ─── Security ─────────────────────────────────────────────────────────────────
|
||||
|
||||
model SecurityEvent {
|
||||
id Int @id @default(autoincrement())
|
||||
type String @db.VarChar(50)
|
||||
severity String @default("info") @db.VarChar(20)
|
||||
message String @db.Text
|
||||
sourceIp String? @db.VarChar(45)
|
||||
resolved Boolean @default(false)
|
||||
resolvedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("security_events")
|
||||
}
|
||||
|
||||
model BlockedIp {
|
||||
id Int @id @default(autoincrement())
|
||||
ip String @unique @db.VarChar(45)
|
||||
reason String? @db.VarChar(500)
|
||||
expiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("blocked_ips")
|
||||
}
|
||||
|
||||
model WhitelistedIp {
|
||||
id Int @id @default(autoincrement())
|
||||
ip String @unique @db.VarChar(45)
|
||||
note String? @db.VarChar(255)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("whitelisted_ips")
|
||||
}
|
||||
|
||||
// ─── Audit Log ────────────────────────────────────────────────────────────────
|
||||
|
||||
model AuditLog {
|
||||
id Int @id @default(autoincrement())
|
||||
action String @db.VarChar(100)
|
||||
target String? @db.VarChar(255)
|
||||
details String? @db.Text
|
||||
sourceIp String? @db.VarChar(45)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
adminId Int?
|
||||
admin AdminUser? @relation(fields: [adminId], references: [id])
|
||||
|
||||
@@map("audit_log")
|
||||
}
|
||||
|
||||
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
model RefreshToken {
|
||||
id Int @id @default(autoincrement())
|
||||
token String @unique @db.VarChar(500)
|
||||
role String @db.VarChar(20)
|
||||
expiresAt DateTime
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
adminId Int?
|
||||
admin AdminUser? @relation(fields: [adminId], references: [id])
|
||||
resellerId Int?
|
||||
reseller Reseller? @relation(fields: [resellerId], references: [id])
|
||||
accountId Int?
|
||||
account Account? @relation(fields: [accountId], references: [id])
|
||||
|
||||
@@map("refresh_tokens")
|
||||
}
|
||||
|
||||
// ─── Settings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
model Setting {
|
||||
key String @id @db.VarChar(100)
|
||||
value String @db.Text
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("settings")
|
||||
}
|
||||
|
||||
// ─── Terminal Sessions ────────────────────────────────────────────────────────
|
||||
|
||||
model TerminalSession {
|
||||
id Int @id @default(autoincrement())
|
||||
adminId Int
|
||||
sourceIp String? @db.VarChar(45)
|
||||
startedAt DateTime @default(now())
|
||||
endedAt DateTime?
|
||||
|
||||
@@map("terminal_sessions")
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import Fastify from 'fastify'
|
||||
import cors from '@fastify/cors'
|
||||
import helmet from '@fastify/helmet'
|
||||
import jwt from '@fastify/jwt'
|
||||
import rateLimit from '@fastify/rate-limit'
|
||||
import staticFiles from '@fastify/static'
|
||||
import websocket from '@fastify/websocket'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { config } from './config.js'
|
||||
import authRoutes from './routes/auth/index.js'
|
||||
import whmRoutes from './routes/whm/index.js'
|
||||
import cpanelRoutes from './routes/cpanel/index.js'
|
||||
import { getSystemStats } from './core/system.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export async function buildApp() {
|
||||
const fastify = Fastify({
|
||||
logger: {
|
||||
level: config.isDev ? 'info' : 'warn',
|
||||
transport: config.isDev
|
||||
? { target: 'pino-pretty', options: { colorize: true } }
|
||||
: undefined,
|
||||
},
|
||||
trustProxy: true,
|
||||
})
|
||||
|
||||
// ─── Security ────────────────────────────────────────────────────────────────
|
||||
|
||||
await fastify.register(helmet, {
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginEmbedderPolicy: false,
|
||||
})
|
||||
|
||||
await fastify.register(cors, {
|
||||
origin: config.isDev
|
||||
? ['http://localhost:5173', 'http://localhost:5174', 'http://localhost:2087', 'http://localhost:2083']
|
||||
: false,
|
||||
credentials: true,
|
||||
})
|
||||
|
||||
await fastify.register(rateLimit, {
|
||||
max: config.rateLimit.max,
|
||||
timeWindow: config.rateLimit.windowMs,
|
||||
keyGenerator: req => req.ip,
|
||||
})
|
||||
|
||||
// ─── JWT ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
await fastify.register(jwt, {
|
||||
secret: config.jwt.secret,
|
||||
sign: { expiresIn: config.jwt.expiresIn },
|
||||
})
|
||||
|
||||
// ─── WebSocket ───────────────────────────────────────────────────────────────
|
||||
|
||||
await fastify.register(websocket)
|
||||
|
||||
// ─── Static files ────────────────────────────────────────────────────────────
|
||||
|
||||
// WHM frontend served from /whm
|
||||
const whmDist = path.resolve(__dirname, '../../whm/dist')
|
||||
const cpanelDist = path.resolve(__dirname, '../../cpanel/dist')
|
||||
|
||||
// ─── API Routes ──────────────────────────────────────────────────────────────
|
||||
|
||||
fastify.register(authRoutes, { prefix: '/api/auth' })
|
||||
fastify.register(whmRoutes, { prefix: '/api/whm' })
|
||||
fastify.register(cpanelRoutes, { prefix: '/api/cpanel' })
|
||||
|
||||
// ─── Health check ─────────────────────────────────────────────────────────────
|
||||
|
||||
fastify.get('/api/health', async () => ({
|
||||
status: 'ok',
|
||||
version: '0.1.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
}))
|
||||
|
||||
// ─── WebSocket: real-time stats ───────────────────────────────────────────────
|
||||
|
||||
fastify.get('/ws/stats', { websocket: true }, (socket, request) => {
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// Verify JWT on connection
|
||||
const token = (request.query as Record<string, string>).token
|
||||
if (!token) {
|
||||
socket.send(JSON.stringify({ type: 'error', message: 'Unauthorized' }))
|
||||
socket.close()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
fastify.jwt.verify(token)
|
||||
} catch {
|
||||
socket.send(JSON.stringify({ type: 'error', message: 'Invalid token' }))
|
||||
socket.close()
|
||||
return
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
if (socket.readyState !== 1) return
|
||||
const stats = await getSystemStats()
|
||||
socket.send(JSON.stringify({ type: 'stats', data: stats }))
|
||||
}
|
||||
|
||||
send()
|
||||
interval = setInterval(send, config.features.wsStatsInterval)
|
||||
|
||||
socket.on('close', () => {
|
||||
if (interval) clearInterval(interval)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── SPA fallback ────────────────────────────────────────────────────────────
|
||||
|
||||
fastify.setNotFoundHandler((request, reply) => {
|
||||
if (request.url.startsWith('/api/') || request.url.startsWith('/ws/')) {
|
||||
return reply.code(404).send({ success: false, error: 'Not found' })
|
||||
}
|
||||
// Serve SPA index.html for all other routes
|
||||
reply.code(200).type('text/html').send('<h1>NixPanel</h1>')
|
||||
})
|
||||
|
||||
return fastify
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'dotenv/config'
|
||||
|
||||
function required(key: string): string {
|
||||
const val = process.env[key]
|
||||
if (!val) throw new Error(`Missing required env var: ${key}`)
|
||||
return val
|
||||
}
|
||||
|
||||
function optional(key: string, fallback: string): string {
|
||||
return process.env[key] ?? fallback
|
||||
}
|
||||
|
||||
export const config = {
|
||||
env: optional('NODE_ENV', 'development'),
|
||||
isDev: optional('NODE_ENV', 'development') === 'development',
|
||||
|
||||
server: {
|
||||
host: optional('HOST', '0.0.0.0'),
|
||||
whmPort: parseInt(optional('WHM_PORT', '2087'), 10),
|
||||
cpanelPort: parseInt(optional('CPANEL_PORT', '2083'), 10),
|
||||
},
|
||||
|
||||
db: {
|
||||
url: required('DATABASE_URL'),
|
||||
},
|
||||
|
||||
jwt: {
|
||||
secret: optional('JWT_SECRET', 'dev-secret-change-in-production'),
|
||||
expiresIn: optional('JWT_EXPIRES_IN', '15m'),
|
||||
refreshSecret: optional('JWT_REFRESH_SECRET', 'dev-refresh-secret-change-in-production'),
|
||||
refreshExpiresIn: optional('JWT_REFRESH_EXPIRES_IN', '7d'),
|
||||
},
|
||||
|
||||
admin: {
|
||||
username: optional('ADMIN_USERNAME', 'root'),
|
||||
password: optional('ADMIN_PASSWORD', 'admin123'),
|
||||
email: optional('ADMIN_EMAIL', 'admin@localhost'),
|
||||
},
|
||||
|
||||
paths: {
|
||||
homeDir: optional('HOME_DIR', '/home'),
|
||||
nginxSites: optional('NGINX_SITES_DIR', '/etc/nginx/sites-available'),
|
||||
nginxEnabled: optional('NGINX_ENABLED_DIR', '/etc/nginx/sites-enabled'),
|
||||
apacheSites: optional('APACHE_SITES_DIR', '/etc/apache2/sites-available'),
|
||||
apacheEnabled: optional('APACHE_ENABLED_DIR', '/etc/apache2/sites-enabled'),
|
||||
bindZones: optional('BIND_ZONES_DIR', '/etc/bind/zones'),
|
||||
certbotWebroot: optional('CERTBOT_WEBROOT', '/var/www/letsencrypt'),
|
||||
},
|
||||
|
||||
rateLimit: {
|
||||
max: parseInt(optional('RATE_LIMIT_MAX', '100'), 10),
|
||||
windowMs: parseInt(optional('RATE_LIMIT_WINDOW_MS', '60000'), 10),
|
||||
},
|
||||
|
||||
features: {
|
||||
terminalEnabled: optional('ENABLE_TERMINAL', 'true') === 'true',
|
||||
wsStatsInterval: parseInt(optional('WEBSOCKET_STATS_INTERVAL', '5000'), 10),
|
||||
},
|
||||
} as const
|
||||
@@ -0,0 +1,125 @@
|
||||
import path from 'path'
|
||||
import { config } from '../config.js'
|
||||
import { exec, writeFile } from './exec.js'
|
||||
|
||||
export interface DnsZoneOptions {
|
||||
domain: string
|
||||
ip: string
|
||||
ns1: string
|
||||
ns2: string
|
||||
mail?: string
|
||||
serial?: number
|
||||
ttl?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a BIND9 zone file for a domain.
|
||||
*/
|
||||
export function generateZoneFile(opts: DnsZoneOptions): string {
|
||||
const {
|
||||
domain,
|
||||
ip,
|
||||
ns1,
|
||||
ns2,
|
||||
mail = `mail.${opts.domain}`,
|
||||
serial = Math.floor(Date.now() / 1000),
|
||||
ttl = 3600,
|
||||
} = opts
|
||||
|
||||
return `; Zone file for ${domain}
|
||||
; Generated by NixPanel
|
||||
$TTL ${ttl}
|
||||
@ IN SOA ${ns1}. hostmaster.${domain}. (
|
||||
${serial} ; Serial
|
||||
3600 ; Refresh
|
||||
1800 ; Retry
|
||||
604800 ; Expire
|
||||
300 ) ; Minimum TTL
|
||||
|
||||
; Name servers
|
||||
@ IN NS ${ns1}.
|
||||
@ IN NS ${ns2}.
|
||||
|
||||
; A records
|
||||
@ IN A ${ip}
|
||||
www IN A ${ip}
|
||||
mail IN A ${ip}
|
||||
ftp IN A ${ip}
|
||||
cpanel IN A ${ip}
|
||||
webmail IN A ${ip}
|
||||
|
||||
; MX record
|
||||
@ IN MX 10 ${mail}.
|
||||
|
||||
; SPF
|
||||
@ IN TXT "v=spf1 a mx ip4:${ip} ~all"
|
||||
|
||||
; DMARC
|
||||
_dmarc IN TXT "v=DMARC1; p=none; rua=mailto:admin@${domain}"
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Write zone file and update named.conf.local, then reload BIND.
|
||||
*/
|
||||
export async function createZone(opts: DnsZoneOptions): Promise<string> {
|
||||
const zoneFile = path.join(config.paths.bindZones, `db.${opts.domain}`)
|
||||
await writeFile(zoneFile, generateZoneFile(opts))
|
||||
|
||||
// Add zone declaration to named.conf.local (idempotent)
|
||||
const namedConf = '/etc/bind/named.conf.local'
|
||||
const zoneDecl = `
|
||||
zone "${opts.domain}" {
|
||||
type master;
|
||||
file "${zoneFile}";
|
||||
allow-transfer { any; };
|
||||
};
|
||||
`
|
||||
// Read existing, only add if not already there
|
||||
const { readFile } = await import('fs/promises')
|
||||
let existing = ''
|
||||
try {
|
||||
existing = await readFile(namedConf, 'utf8')
|
||||
} catch {
|
||||
// file may not exist yet in test env
|
||||
}
|
||||
|
||||
if (!existing.includes(`zone "${opts.domain}"`)) {
|
||||
const { appendFile } = await import('fs/promises')
|
||||
await appendFile(namedConf, zoneDecl)
|
||||
}
|
||||
|
||||
// Validate zone
|
||||
const check = await exec('named-checkzone', [opts.domain, zoneFile])
|
||||
if (check.exitCode !== 0) {
|
||||
throw new Error(`Zone validation failed: ${check.stderr}`)
|
||||
}
|
||||
|
||||
await exec('rndc', ['reload'])
|
||||
|
||||
return zoneFile
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a zone and reload BIND.
|
||||
*/
|
||||
export async function removeZone(domain: string): Promise<void> {
|
||||
const zoneFile = path.join(config.paths.bindZones, `db.${domain}`)
|
||||
await exec('rm', ['-f', zoneFile])
|
||||
|
||||
// Remove zone block from named.conf.local
|
||||
const namedConf = '/etc/bind/named.conf.local'
|
||||
const { readFile, writeFile: fsWrite } = await import('fs/promises')
|
||||
try {
|
||||
const content = await readFile(namedConf, 'utf8')
|
||||
const cleaned = content.replace(
|
||||
new RegExp(`zone\\s+"${domain.replace('.', '\\.')}\"[^}]+\\};\\s*`, 'g'),
|
||||
''
|
||||
)
|
||||
await fsWrite(namedConf, cleaned, 'utf8')
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
|
||||
await exec('rndc', ['reload'])
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { existsSync } from 'fs'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
// Commands allowed to be executed — nothing outside this list runs
|
||||
const ALLOWED_COMMANDS = new Set([
|
||||
// User management
|
||||
'useradd', 'userdel', 'usermod', 'passwd', 'chage', 'id', 'groups',
|
||||
// File operations
|
||||
'mkdir', 'rmdir', 'rm', 'cp', 'mv', 'chmod', 'chown', 'ln', 'tar', 'gzip', 'gunzip',
|
||||
'find', 'du', 'df', 'ls', 'stat',
|
||||
// Web servers
|
||||
'nginx', 'apache2', 'apache2ctl', 'a2ensite', 'a2dissite', 'a2enmod', 'a2dismod',
|
||||
// DNS
|
||||
'named', 'named-checkconf', 'named-checkzone', 'rndc',
|
||||
// Mail
|
||||
'postfix', 'postmap', 'postconf', 'dovecot', 'doveadm',
|
||||
// SSL
|
||||
'certbot', 'openssl',
|
||||
// FTP
|
||||
'vsftpd',
|
||||
// Services
|
||||
'systemctl', 'service',
|
||||
// Network
|
||||
'ss', 'ip', 'hostname', 'ping', 'traceroute', 'dig', 'nslookup',
|
||||
// System
|
||||
'uname', 'uptime', 'free', 'vmstat', 'iostat', 'top', 'ps', 'kill', 'pkill',
|
||||
'cat', 'grep', 'awk', 'sed', 'head', 'tail', 'wc', 'sort', 'uniq', 'cut',
|
||||
// Package managers
|
||||
'apt', 'apt-get', 'dpkg',
|
||||
// MySQL/MariaDB
|
||||
'mysql', 'mysqladmin', 'mysqlcheck', 'mysqldump',
|
||||
// PHP
|
||||
'php', 'php7.4', 'php8.0', 'php8.1', 'php8.2', 'php8.3', 'update-alternatives',
|
||||
// Misc
|
||||
'touch', 'echo', 'which', 'whoami', 'env',
|
||||
// Fail2ban
|
||||
'fail2ban-client',
|
||||
// UFW
|
||||
'ufw',
|
||||
])
|
||||
|
||||
// Characters never allowed in arguments (shell injection prevention)
|
||||
const DANGEROUS_CHARS = /[;&|`$<>()\n\r\0]/
|
||||
|
||||
// Paths outside these prefixes are blocked for file operations
|
||||
const ALLOWED_PATH_PREFIXES = [
|
||||
'/home/',
|
||||
'/etc/nginx/',
|
||||
'/etc/apache2/',
|
||||
'/etc/bind/',
|
||||
'/etc/postfix/',
|
||||
'/etc/dovecot/',
|
||||
'/etc/vsftpd',
|
||||
'/etc/php/',
|
||||
'/etc/letsencrypt/',
|
||||
'/etc/fail2ban/',
|
||||
'/var/www/',
|
||||
'/var/mail/',
|
||||
'/var/log/',
|
||||
'/var/spool/cron/',
|
||||
'/tmp/',
|
||||
'/usr/local/nixpanel/',
|
||||
]
|
||||
|
||||
function validateArg(arg: string): void {
|
||||
if (DANGEROUS_CHARS.test(arg)) {
|
||||
throw new Error(`Argument contains forbidden characters: ${arg}`)
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedPath(path: string): boolean {
|
||||
return ALLOWED_PATH_PREFIXES.some(prefix => path.startsWith(prefix))
|
||||
}
|
||||
|
||||
export interface ExecResult {
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number
|
||||
}
|
||||
|
||||
export interface ExecOptions {
|
||||
timeout?: number
|
||||
allowedPaths?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a whitelisted system command safely.
|
||||
* Throws if the command is not in the allowlist or args contain shell metacharacters.
|
||||
*/
|
||||
export async function exec(
|
||||
command: string,
|
||||
args: string[] = [],
|
||||
options: ExecOptions = {}
|
||||
): Promise<ExecResult> {
|
||||
if (!ALLOWED_COMMANDS.has(command)) {
|
||||
throw new Error(`Command not allowed: ${command}`)
|
||||
}
|
||||
|
||||
for (const arg of args) {
|
||||
validateArg(arg)
|
||||
}
|
||||
|
||||
const timeout = options.timeout ?? 30_000
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(command, args, {
|
||||
timeout,
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB
|
||||
})
|
||||
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 }
|
||||
} catch (err: unknown) {
|
||||
const e = err as NodeJS.ErrnoException & { stdout?: string; stderr?: string; code?: number }
|
||||
return {
|
||||
stdout: (e.stdout ?? '').trim(),
|
||||
stderr: (e.stderr ?? e.message ?? '').trim(),
|
||||
exitCode: typeof e.code === 'number' ? e.code : 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write content to a file in an allowed path using a safe method (no shell).
|
||||
*/
|
||||
export async function writeFile(path: string, content: string): Promise<void> {
|
||||
if (!isAllowedPath(path)) {
|
||||
throw new Error(`Path not in allowed list: ${path}`)
|
||||
}
|
||||
const { writeFile: fsWrite } = await import('fs/promises')
|
||||
await fsWrite(path, content, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file from an allowed path.
|
||||
*/
|
||||
export async function readFile(path: string): Promise<string> {
|
||||
if (!isAllowedPath(path)) {
|
||||
throw new Error(`Path not in allowed list: ${path}`)
|
||||
}
|
||||
const { readFile: fsRead } = await import('fs/promises')
|
||||
return fsRead(path, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running as root.
|
||||
*/
|
||||
export function isRoot(): boolean {
|
||||
return process.getuid?.() === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a Linux username.
|
||||
*/
|
||||
export function validateUsername(username: string): boolean {
|
||||
return /^[a-z][a-z0-9_-]{0,31}$/.test(username)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a domain name.
|
||||
*/
|
||||
export function validateDomain(domain: string): boolean {
|
||||
return /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(domain)
|
||||
}
|
||||
|
||||
/**
|
||||
* File exists check without shell.
|
||||
*/
|
||||
export function fileExists(path: string): boolean {
|
||||
return existsSync(path)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import path from 'path'
|
||||
import { config } from '../config.js'
|
||||
import { exec, writeFile } from './exec.js'
|
||||
|
||||
export interface VhostOptions {
|
||||
domain: string
|
||||
username: string
|
||||
documentRoot: string
|
||||
phpVersion?: string
|
||||
sslEnabled?: boolean
|
||||
certPath?: string
|
||||
keyPath?: string
|
||||
chainPath?: string
|
||||
aliases?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an nginx vhost config for a hosting account.
|
||||
*/
|
||||
export function generateVhost(opts: VhostOptions): string {
|
||||
const {
|
||||
domain,
|
||||
username,
|
||||
documentRoot,
|
||||
phpVersion = '8.3',
|
||||
sslEnabled = false,
|
||||
certPath,
|
||||
keyPath,
|
||||
chainPath,
|
||||
aliases = [],
|
||||
} = opts
|
||||
|
||||
const phpSocket = `/run/php/php${phpVersion}-fpm-${username}.sock`
|
||||
const serverName = [domain, ...aliases].join(' ')
|
||||
|
||||
const sslBlock = sslEnabled && certPath && keyPath
|
||||
? `
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
|
||||
ssl_certificate ${certPath};
|
||||
ssl_certificate_key ${keyPath};
|
||||
${chainPath ? `ssl_trusted_certificate ${chainPath};` : ''}
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
`
|
||||
: `
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
`
|
||||
|
||||
const redirectBlock = sslEnabled
|
||||
? `
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ${serverName};
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
`
|
||||
: ''
|
||||
|
||||
return `${redirectBlock}
|
||||
server {
|
||||
${sslBlock.trim()}
|
||||
server_name ${serverName};
|
||||
|
||||
root ${documentRoot};
|
||||
index index.php index.html index.htm;
|
||||
|
||||
access_log /var/log/nginx/${username}.access.log;
|
||||
error_log /var/log/nginx/${username}.error.log;
|
||||
|
||||
client_max_body_size 64M;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \\.php$ {
|
||||
include snippets/fastcgi-php.conf;
|
||||
fastcgi_pass unix:${phpSocket};
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
|
||||
location ~ /\\.ht {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location ~ /\\.git {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# Let's Encrypt webroot
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/letsencrypt;
|
||||
}
|
||||
}
|
||||
`.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an nginx vhost to disk and enable it.
|
||||
*/
|
||||
export async function enableVhost(opts: VhostOptions): Promise<void> {
|
||||
const confPath = path.join(config.paths.nginxSites, `${opts.domain}.conf`)
|
||||
const enabledPath = path.join(config.paths.nginxEnabled, `${opts.domain}.conf`)
|
||||
|
||||
await writeFile(confPath, generateVhost(opts))
|
||||
|
||||
// Symlink to sites-enabled
|
||||
await exec('ln', ['-sf', confPath, enabledPath])
|
||||
|
||||
// Test and reload
|
||||
const test = await exec('nginx', ['-t'])
|
||||
if (test.exitCode !== 0) {
|
||||
throw new Error(`nginx config test failed: ${test.stderr}`)
|
||||
}
|
||||
|
||||
await exec('systemctl', ['reload', 'nginx'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a vhost config and reload nginx.
|
||||
*/
|
||||
export async function disableVhost(domain: string): Promise<void> {
|
||||
const confPath = path.join(config.paths.nginxSites, `${domain}.conf`)
|
||||
const enabledPath = path.join(config.paths.nginxEnabled, `${domain}.conf`)
|
||||
|
||||
await exec('rm', ['-f', enabledPath])
|
||||
await exec('rm', ['-f', confPath])
|
||||
await exec('systemctl', ['reload', 'nginx'])
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { readFile } from 'fs/promises'
|
||||
import { exec } from './exec.js'
|
||||
import type { SystemStats, ServiceStatus } from '../types/index.js'
|
||||
|
||||
/**
|
||||
* Read CPU usage (0-100%) from /proc/stat.
|
||||
*/
|
||||
async function getCpuUsage(): Promise<number> {
|
||||
try {
|
||||
const stat1 = await readFile('/proc/stat', 'utf8')
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
const stat2 = await readFile('/proc/stat', 'utf8')
|
||||
|
||||
const parse = (s: string) => {
|
||||
const line = s.split('\n')[0].split(' ').filter(Boolean).slice(1).map(Number)
|
||||
const idle = line[3] + line[4]
|
||||
const total = line.reduce((a, b) => a + b, 0)
|
||||
return { idle, total }
|
||||
}
|
||||
|
||||
const s1 = parse(stat1)
|
||||
const s2 = parse(stat2)
|
||||
const idleDiff = s2.idle - s1.idle
|
||||
const totalDiff = s2.total - s1.total
|
||||
return Math.round((1 - idleDiff / totalDiff) * 100)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get memory stats from /proc/meminfo (bytes).
|
||||
*/
|
||||
async function getMemoryStats(): Promise<SystemStats['memory']> {
|
||||
try {
|
||||
const content = await readFile('/proc/meminfo', 'utf8')
|
||||
const get = (key: string) => {
|
||||
const match = content.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`))
|
||||
return match ? parseInt(match[1], 10) * 1024 : 0
|
||||
}
|
||||
const total = get('MemTotal')
|
||||
const free = get('MemFree')
|
||||
const buffers = get('Buffers')
|
||||
const cached = get('Cached')
|
||||
const used = total - free - buffers - cached
|
||||
return {
|
||||
total,
|
||||
used,
|
||||
free: total - used,
|
||||
percent: Math.round((used / total) * 100),
|
||||
}
|
||||
} catch {
|
||||
return { total: 0, used: 0, free: 0, percent: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get disk usage for / (bytes).
|
||||
*/
|
||||
async function getDiskStats(): Promise<SystemStats['disk']> {
|
||||
try {
|
||||
const result = await exec('df', ['-B1', '/'])
|
||||
const lines = result.stdout.split('\n')
|
||||
const parts = lines[1]?.split(/\s+/)
|
||||
if (!parts) return { total: 0, used: 0, free: 0, percent: 0 }
|
||||
const total = parseInt(parts[1], 10)
|
||||
const used = parseInt(parts[2], 10)
|
||||
const free = parseInt(parts[3], 10)
|
||||
return { total, used, free, percent: Math.round((used / total) * 100) }
|
||||
} catch {
|
||||
return { total: 0, used: 0, free: 0, percent: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system load averages from /proc/loadavg.
|
||||
*/
|
||||
async function getLoadAvg(): Promise<[number, number, number]> {
|
||||
try {
|
||||
const content = await readFile('/proc/loadavg', 'utf8')
|
||||
const parts = content.split(' ')
|
||||
return [parseFloat(parts[0]), parseFloat(parts[1]), parseFloat(parts[2])]
|
||||
} catch {
|
||||
return [0, 0, 0]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather all system stats in parallel.
|
||||
*/
|
||||
export async function getSystemStats(): Promise<SystemStats> {
|
||||
const [cpu, memory, disk, load] = await Promise.all([
|
||||
getCpuUsage(),
|
||||
getMemoryStats(),
|
||||
getDiskStats(),
|
||||
getLoadAvg(),
|
||||
])
|
||||
|
||||
return {
|
||||
cpu,
|
||||
memory,
|
||||
disk,
|
||||
load,
|
||||
uptime: Math.floor(process.uptime()),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the status of a systemd service.
|
||||
*/
|
||||
export async function getServiceStatus(name: string): Promise<ServiceStatus> {
|
||||
const [activeResult, enabledResult] = await Promise.all([
|
||||
exec('systemctl', ['is-active', name]),
|
||||
exec('systemctl', ['is-enabled', name]),
|
||||
])
|
||||
|
||||
const active = activeResult.stdout.trim()
|
||||
const enabled = enabledResult.stdout.trim() === 'enabled'
|
||||
|
||||
let status: ServiceStatus['status'] = 'unknown'
|
||||
if (active === 'active') status = 'running'
|
||||
else if (active === 'inactive') status = 'stopped'
|
||||
else if (active === 'failed') status = 'failed'
|
||||
|
||||
return { name, status, enabled }
|
||||
}
|
||||
|
||||
export const MANAGED_SERVICES = [
|
||||
'nginx', 'apache2', 'mysql', 'mariadb',
|
||||
'postfix', 'dovecot', 'vsftpd', 'bind9',
|
||||
'fail2ban', 'ufw', 'cron', 'ssh',
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma
|
||||
}
|
||||
|
||||
export default prisma
|
||||
@@ -0,0 +1,103 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { config } from '../config.js'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
console.log('Seeding database...')
|
||||
|
||||
// Admin user
|
||||
const existing = await prisma.adminUser.findUnique({ where: { username: config.admin.username } })
|
||||
if (!existing) {
|
||||
const hash = await bcrypt.hash(config.admin.password, 12)
|
||||
await prisma.adminUser.create({
|
||||
data: {
|
||||
username: config.admin.username,
|
||||
passwordHash: hash,
|
||||
email: config.admin.email,
|
||||
},
|
||||
})
|
||||
console.log(`Created admin user: ${config.admin.username}`)
|
||||
}
|
||||
|
||||
// Default packages
|
||||
const packages = [
|
||||
{
|
||||
name: 'Starter',
|
||||
diskMb: BigInt(1024),
|
||||
bandwidthMb: BigInt(10240),
|
||||
maxDatabases: 3,
|
||||
maxEmailAccounts: 5,
|
||||
maxSubdomains: 5,
|
||||
maxAddonDomains: 1,
|
||||
maxParkedDomains: 3,
|
||||
maxFtpAccounts: 2,
|
||||
maxCronJobs: 3,
|
||||
},
|
||||
{
|
||||
name: 'Professional',
|
||||
diskMb: BigInt(5120),
|
||||
bandwidthMb: BigInt(51200),
|
||||
maxDatabases: 20,
|
||||
maxEmailAccounts: 50,
|
||||
maxSubdomains: 25,
|
||||
maxAddonDomains: 10,
|
||||
maxParkedDomains: 25,
|
||||
maxFtpAccounts: 10,
|
||||
maxCronJobs: 10,
|
||||
},
|
||||
{
|
||||
name: 'Unlimited',
|
||||
diskMb: BigInt(0),
|
||||
bandwidthMb: BigInt(0),
|
||||
maxDatabases: 0,
|
||||
maxEmailAccounts: 0,
|
||||
maxSubdomains: 0,
|
||||
maxAddonDomains: 0,
|
||||
maxParkedDomains: 0,
|
||||
maxFtpAccounts: 0,
|
||||
maxCronJobs: 0,
|
||||
},
|
||||
]
|
||||
|
||||
for (const pkg of packages) {
|
||||
await prisma.package.upsert({
|
||||
where: { name: pkg.name },
|
||||
update: {},
|
||||
create: pkg,
|
||||
})
|
||||
}
|
||||
console.log('Created default packages')
|
||||
|
||||
// Default settings
|
||||
const settings = [
|
||||
{ key: 'php_default_version', value: '8.3' },
|
||||
{ key: 'mail_server', value: 'postfix' },
|
||||
{ key: 'ftp_server', value: 'vsftpd' },
|
||||
{ key: 'webserver', value: 'nginx' },
|
||||
{ key: 'dns_server', value: 'bind9' },
|
||||
{ key: 'ssl_provider', value: 'letsencrypt' },
|
||||
{ key: 'backup_enabled', value: 'true' },
|
||||
{ key: 'backup_retention_days', value: '7' },
|
||||
{ key: 'max_login_attempts', value: '5' },
|
||||
{ key: 'ban_duration_minutes', value: '30' },
|
||||
{ key: 'bandwidth_overage_action', value: 'suspend' },
|
||||
{ key: 'disk_overage_action', value: 'warn' },
|
||||
]
|
||||
|
||||
for (const s of settings) {
|
||||
await prisma.setting.upsert({
|
||||
where: { key: s.key },
|
||||
update: {},
|
||||
create: s,
|
||||
})
|
||||
}
|
||||
console.log('Created default settings')
|
||||
|
||||
console.log('Seed complete.')
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect())
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'dotenv/config'
|
||||
import { buildApp } from './app.js'
|
||||
import { config } from './config.js'
|
||||
import prisma from './db/client.js'
|
||||
|
||||
async function start() {
|
||||
let app
|
||||
|
||||
try {
|
||||
// Verify DB connection
|
||||
await prisma.$connect()
|
||||
console.log('Database connected')
|
||||
|
||||
app = await buildApp()
|
||||
|
||||
// WHM server on port 2087
|
||||
await app.listen({ port: config.server.whmPort, host: config.server.host })
|
||||
console.log(`NixPanel WHM running on port ${config.server.whmPort}`)
|
||||
console.log(`NixPanel cPanel API running on port ${config.server.whmPort} (/api/cpanel)`)
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to start server:', err)
|
||||
await prisma.$disconnect()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async (signal: string) => {
|
||||
console.log(`${signal} received, shutting down...`)
|
||||
await app?.close()
|
||||
await prisma.$disconnect()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
||||
process.on('SIGINT', () => shutdown('SIGINT'))
|
||||
}
|
||||
|
||||
start()
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify'
|
||||
import type { Role, JwtPayload } from '../types/index.js'
|
||||
|
||||
/**
|
||||
* Verify a valid JWT is present. Attaches payload to request.user.
|
||||
*/
|
||||
export async function requireAuth(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
await request.jwtVerify<JwtPayload>()
|
||||
} catch {
|
||||
reply.code(401).send({ success: false, error: 'Unauthorized' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require one of the specified roles.
|
||||
*/
|
||||
export function requireRole(...roles: Role[]) {
|
||||
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
||||
try {
|
||||
await request.jwtVerify<JwtPayload>()
|
||||
} catch {
|
||||
reply.code(401).send({ success: false, error: 'Unauthorized' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!request.user || !roles.includes(request.user.role)) {
|
||||
reply.code(403).send({ success: false, error: 'Forbidden' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only gate.
|
||||
*/
|
||||
export const requireAdmin = requireRole('admin')
|
||||
|
||||
/**
|
||||
* Admin or reseller gate.
|
||||
*/
|
||||
export const requireAdminOrReseller = requireRole('admin', 'reseller')
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { config } from '../../config.js'
|
||||
import type { JwtPayload, Role } from '../../types/index.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
|
||||
const LoginSchema = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
password: z.string().min(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* Generate access + refresh token pair.
|
||||
*/
|
||||
function signTokens(
|
||||
fastify: FastifyInstance,
|
||||
sub: number,
|
||||
role: Role,
|
||||
username: string
|
||||
) {
|
||||
const payload: JwtPayload = { sub, role, username }
|
||||
|
||||
const accessToken = fastify.jwt.sign(payload, {
|
||||
expiresIn: config.jwt.expiresIn,
|
||||
})
|
||||
|
||||
const refreshToken = fastify.jwt.sign(
|
||||
{ ...payload, type: 'refresh' },
|
||||
{ secret: config.jwt.refreshSecret, expiresIn: config.jwt.refreshExpiresIn }
|
||||
)
|
||||
|
||||
return { accessToken, refreshToken }
|
||||
}
|
||||
|
||||
export default async function authRoutes(fastify: FastifyInstance) {
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Login for admin, reseller, or cPanel user.
|
||||
* Body: { username, password }
|
||||
* Returns: { accessToken, refreshToken, role, user }
|
||||
*/
|
||||
fastify.post('/login', async (request, reply) => {
|
||||
const body = LoginSchema.safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid request body' })
|
||||
}
|
||||
|
||||
const { username, password } = body.data
|
||||
|
||||
// Try admin first
|
||||
const admin = await prisma.adminUser.findUnique({ where: { username } })
|
||||
if (admin) {
|
||||
const valid = await bcrypt.compare(password, admin.passwordHash)
|
||||
if (!valid) {
|
||||
return reply.code(401).send({ success: false, error: 'Invalid credentials' })
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = signTokens(fastify, admin.id, 'admin', admin.username)
|
||||
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 7)
|
||||
await prisma.refreshToken.create({
|
||||
data: { token: refreshToken, role: 'admin', adminId: admin.id, expiresAt },
|
||||
})
|
||||
|
||||
await prisma.adminUser.update({
|
||||
where: { id: admin.id },
|
||||
data: { lastLogin: new Date(), lastLoginIp: request.ip },
|
||||
})
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
role: 'admin',
|
||||
user: { id: admin.id, username: admin.username, email: admin.email },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Try reseller
|
||||
const reseller = await prisma.reseller.findUnique({ where: { username } })
|
||||
if (reseller) {
|
||||
if (reseller.status !== 'active') {
|
||||
return reply.code(403).send({ success: false, error: 'Account suspended' })
|
||||
}
|
||||
const valid = await bcrypt.compare(password, reseller.passwordHash)
|
||||
if (!valid) {
|
||||
return reply.code(401).send({ success: false, error: 'Invalid credentials' })
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = signTokens(fastify, reseller.id, 'reseller', reseller.username)
|
||||
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 7)
|
||||
await prisma.refreshToken.create({
|
||||
data: { token: refreshToken, role: 'reseller', resellerId: reseller.id, expiresAt },
|
||||
})
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
role: 'reseller',
|
||||
user: { id: reseller.id, username: reseller.username, email: reseller.email },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Try hosting account (cPanel user)
|
||||
const account = await prisma.account.findUnique({ where: { username } })
|
||||
if (account) {
|
||||
if (account.status !== 'active') {
|
||||
return reply.code(403).send({ success: false, error: 'Account suspended' })
|
||||
}
|
||||
const valid = await bcrypt.compare(password, account.passwordHash)
|
||||
if (!valid) {
|
||||
return reply.code(401).send({ success: false, error: 'Invalid credentials' })
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = signTokens(fastify, account.id, 'user', account.username)
|
||||
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 7)
|
||||
await prisma.refreshToken.create({
|
||||
data: { token: refreshToken, role: 'user', accountId: account.id, expiresAt },
|
||||
})
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
role: 'user',
|
||||
user: { id: account.id, username: account.username, email: account.email, domain: account.domain },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return reply.code(401).send({ success: false, error: 'Invalid credentials' })
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/auth/refresh
|
||||
* Exchange a refresh token for a new access token.
|
||||
*/
|
||||
fastify.post('/refresh', async (request, reply) => {
|
||||
const body = z.object({ refreshToken: z.string() }).safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: 'refreshToken required' })
|
||||
}
|
||||
|
||||
let payload: JwtPayload & { type?: string }
|
||||
try {
|
||||
payload = fastify.jwt.verify<JwtPayload & { type?: string }>(
|
||||
body.data.refreshToken,
|
||||
{ secret: config.jwt.refreshSecret }
|
||||
)
|
||||
} catch {
|
||||
return reply.code(401).send({ success: false, error: 'Invalid or expired refresh token' })
|
||||
}
|
||||
|
||||
if (payload.type !== 'refresh') {
|
||||
return reply.code(401).send({ success: false, error: 'Not a refresh token' })
|
||||
}
|
||||
|
||||
const stored = await prisma.refreshToken.findUnique({ where: { token: body.data.refreshToken } })
|
||||
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
|
||||
return reply.code(401).send({ success: false, error: 'Refresh token revoked or expired' })
|
||||
}
|
||||
|
||||
// Rotate: revoke old, issue new
|
||||
await prisma.refreshToken.update({
|
||||
where: { id: stored.id },
|
||||
data: { revokedAt: new Date() },
|
||||
})
|
||||
|
||||
const newPayload: JwtPayload = { sub: payload.sub, role: payload.role as Role, username: payload.username }
|
||||
const accessToken = fastify.jwt.sign(newPayload, { expiresIn: config.jwt.expiresIn })
|
||||
const newRefresh = fastify.jwt.sign(
|
||||
{ ...newPayload, type: 'refresh' },
|
||||
{ secret: config.jwt.refreshSecret, expiresIn: config.jwt.refreshExpiresIn }
|
||||
)
|
||||
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 7)
|
||||
|
||||
const createData: Parameters<typeof prisma.refreshToken.create>[0]['data'] = {
|
||||
token: newRefresh,
|
||||
role: payload.role,
|
||||
expiresAt,
|
||||
}
|
||||
if (stored.adminId) createData.adminId = stored.adminId
|
||||
if (stored.resellerId) createData.resellerId = stored.resellerId
|
||||
if (stored.accountId) createData.accountId = stored.accountId
|
||||
|
||||
await prisma.refreshToken.create({ data: createData })
|
||||
|
||||
return reply.send({ success: true, data: { accessToken, refreshToken: newRefresh } })
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Revoke the refresh token.
|
||||
*/
|
||||
fastify.post('/logout', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const body = z.object({ refreshToken: z.string() }).safeParse(request.body)
|
||||
if (body.success) {
|
||||
await prisma.refreshToken.updateMany({
|
||||
where: { token: body.data.refreshToken, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
})
|
||||
}
|
||||
return reply.send({ success: true, data: null })
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Return the current authenticated user's profile.
|
||||
*/
|
||||
fastify.get('/me', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
|
||||
if (user.role === 'admin') {
|
||||
const admin = await prisma.adminUser.findUnique({
|
||||
where: { id: user.sub },
|
||||
select: { id: true, username: true, email: true, lastLogin: true, twoFactorEnabled: true },
|
||||
})
|
||||
return reply.send({ success: true, data: { ...admin, role: 'admin' } })
|
||||
}
|
||||
|
||||
if (user.role === 'reseller') {
|
||||
const reseller = await prisma.reseller.findUnique({
|
||||
where: { id: user.sub },
|
||||
select: { id: true, username: true, email: true, company: true, status: true },
|
||||
})
|
||||
return reply.send({ success: true, data: { ...reseller, role: 'reseller' } })
|
||||
}
|
||||
|
||||
const account = await prisma.account.findUnique({
|
||||
where: { id: user.sub },
|
||||
select: { id: true, username: true, email: true, domain: true, status: true },
|
||||
})
|
||||
return reply.send({ success: true, data: { ...account, role: 'user' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec, writeFile } from '../../core/exec.js'
|
||||
|
||||
// Basic cron schedule validation
|
||||
function isValidCronSchedule(schedule: string): boolean {
|
||||
const parts = schedule.trim().split(/\s+/)
|
||||
if (parts.length !== 5) return false
|
||||
const fieldPattern = /^(\*|(\d+(-\d+)?(,\d+(-\d+)?)*)(\/\d+)?|\*\/\d+)$/
|
||||
return parts.every(p => fieldPattern.test(p))
|
||||
}
|
||||
|
||||
export default async function cronRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const jobs = await prisma.cronJob.findMany({ where: { accountId }, orderBy: { createdAt: 'desc' } })
|
||||
return reply.send({ success: true, data: jobs })
|
||||
})
|
||||
|
||||
fastify.post('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, number>).accountId
|
||||
|
||||
const body = z.object({
|
||||
schedule: z.string().min(9),
|
||||
command: z.string().min(1).max(1000),
|
||||
comment: z.string().max(255).optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
if (!isValidCronSchedule(body.data.schedule)) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid cron schedule' })
|
||||
}
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId }, include: { package: true } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Check quota
|
||||
if (account.package && account.package.maxCronJobs > 0) {
|
||||
const count = await prisma.cronJob.count({ where: { accountId } })
|
||||
if (count >= account.package.maxCronJobs) {
|
||||
return reply.code(429).send({ success: false, error: 'Cron job limit reached' })
|
||||
}
|
||||
}
|
||||
|
||||
const job = await prisma.cronJob.create({
|
||||
data: { ...body.data, accountId },
|
||||
})
|
||||
|
||||
await writeCrontab(account.username, accountId)
|
||||
|
||||
return reply.code(201).send({ success: true, data: job })
|
||||
})
|
||||
|
||||
fastify.put('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const body = z.object({
|
||||
schedule: z.string().optional(),
|
||||
command: z.string().min(1).max(1000).optional(),
|
||||
comment: z.string().max(255).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
if (body.data.schedule && !isValidCronSchedule(body.data.schedule)) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid cron schedule' })
|
||||
}
|
||||
|
||||
const job = await prisma.cronJob.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
include: { account: true },
|
||||
})
|
||||
if (!job) return reply.code(404).send({ success: false, error: 'Cron job not found' })
|
||||
|
||||
const updated = await prisma.cronJob.update({
|
||||
where: { id: job.id },
|
||||
data: body.data,
|
||||
})
|
||||
|
||||
await writeCrontab(job.account.username, job.accountId)
|
||||
|
||||
return reply.send({ success: true, data: updated })
|
||||
})
|
||||
|
||||
fastify.delete('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const job = await prisma.cronJob.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
include: { account: true },
|
||||
})
|
||||
if (!job) return reply.code(404).send({ success: false, error: 'Cron job not found' })
|
||||
|
||||
await prisma.cronJob.delete({ where: { id: job.id } })
|
||||
await writeCrontab(job.account.username, job.accountId)
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Cron job deleted' } })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate and install the crontab for a user.
|
||||
*/
|
||||
async function writeCrontab(username: string, accountId: number): Promise<void> {
|
||||
const jobs = await prisma.cronJob.findMany({
|
||||
where: { accountId, enabled: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
const lines = [
|
||||
`# NixPanel crontab for ${username}`,
|
||||
`# DO NOT EDIT — managed by NixPanel`,
|
||||
'',
|
||||
...jobs.map(j => `${j.schedule} ${j.command}${j.comment ? ` # ${j.comment}` : ''}`),
|
||||
'',
|
||||
]
|
||||
|
||||
const crontabPath = `/var/spool/cron/crontabs/${username}`
|
||||
await writeFile(crontabPath, lines.join('\n'))
|
||||
await exec('chown', [`${username}:crontab`, crontabPath])
|
||||
await exec('chmod', ['600', crontabPath])
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
|
||||
function mysqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "\\'").replace(/\\/g, '\\\\')
|
||||
}
|
||||
|
||||
export default async function databaseRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
// GET /api/cpanel/databases
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const databases = await prisma.database.findMany({
|
||||
where: { accountId },
|
||||
include: { dbUsers: { select: { id: true, username: true, host: true, privileges: true } } },
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
return reply.send({ success: true, data: databases })
|
||||
})
|
||||
|
||||
// POST /api/cpanel/databases
|
||||
fastify.post('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.body as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const body = z.object({
|
||||
name: z.string().min(1).max(32).regex(/^[a-zA-Z0-9_]+$/),
|
||||
charset: z.string().default('utf8mb4'),
|
||||
collation: z.string().default('utf8mb4_unicode_ci'),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const account = await prisma.account.findUnique({
|
||||
where: { id: accountId },
|
||||
include: { package: true },
|
||||
})
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Check quota
|
||||
if (account.package && account.package.maxDatabases > 0) {
|
||||
const count = await prisma.database.count({ where: { accountId } })
|
||||
if (count >= account.package.maxDatabases) {
|
||||
return reply.code(429).send({ success: false, error: 'Database limit reached' })
|
||||
}
|
||||
}
|
||||
|
||||
// Prefix database name with username to avoid conflicts
|
||||
const dbName = `${account.username}_${body.data.name}`
|
||||
const exists = await prisma.database.findUnique({ where: { name: dbName } })
|
||||
if (exists) return reply.code(409).send({ success: false, error: 'Database already exists' })
|
||||
|
||||
// Create the actual MariaDB database
|
||||
const result = await exec('mysql', [
|
||||
'-e',
|
||||
`CREATE DATABASE \`${mysqlEscape(dbName)}\` CHARACTER SET ${mysqlEscape(body.data.charset)} COLLATE ${mysqlEscape(body.data.collation)};`,
|
||||
])
|
||||
if (result.exitCode !== 0 && !process.env.NODE_ENV?.startsWith('dev')) {
|
||||
return reply.code(500).send({ success: false, error: `Failed to create database: ${result.stderr}` })
|
||||
}
|
||||
|
||||
const db = await prisma.database.create({
|
||||
data: { name: dbName, charset: body.data.charset, collation: body.data.collation, accountId },
|
||||
})
|
||||
return reply.code(201).send({ success: true, data: db })
|
||||
})
|
||||
|
||||
// DELETE /api/cpanel/databases/:id
|
||||
fastify.delete('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const db = await prisma.database.findFirst({
|
||||
where: {
|
||||
id: parseInt(id, 10),
|
||||
...(user.role === 'user' ? { accountId: user.sub } : {}),
|
||||
},
|
||||
})
|
||||
if (!db) return reply.code(404).send({ success: false, error: 'Database not found' })
|
||||
|
||||
await exec('mysql', ['-e', `DROP DATABASE IF EXISTS \`${mysqlEscape(db.name)}\`;`])
|
||||
await prisma.database.delete({ where: { id: db.id } })
|
||||
|
||||
return reply.send({ success: true, data: { message: `Database ${db.name} deleted` } })
|
||||
})
|
||||
|
||||
// POST /api/cpanel/databases/:id/users
|
||||
fastify.post('/:id/users', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const body = z.object({
|
||||
username: z.string().min(1).max(16).regex(/^[a-zA-Z0-9_]+$/),
|
||||
password: z.string().min(8),
|
||||
privileges: z.string().default('ALL'),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const db = await prisma.database.findFirst({
|
||||
where: {
|
||||
id: parseInt(id, 10),
|
||||
...(user.role === 'user' ? { accountId: user.sub } : {}),
|
||||
},
|
||||
include: { account: true },
|
||||
})
|
||||
if (!db) return reply.code(404).send({ success: false, error: 'Database not found' })
|
||||
|
||||
const dbUsername = `${db.account.username}_${body.data.username}`
|
||||
const escapedPass = mysqlEscape(body.data.password)
|
||||
|
||||
// Create MariaDB user and grant privileges
|
||||
await exec('mysql', ['-e', `CREATE USER '${mysqlEscape(dbUsername)}'@'localhost' IDENTIFIED BY '${escapedPass}';`])
|
||||
await exec('mysql', ['-e', `GRANT ${mysqlEscape(body.data.privileges)} ON \`${mysqlEscape(db.name)}\`.* TO '${mysqlEscape(dbUsername)}'@'localhost';`])
|
||||
await exec('mysql', ['-e', 'FLUSH PRIVILEGES;'])
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
const dbUser = await prisma.databaseUser.create({
|
||||
data: {
|
||||
username: dbUsername,
|
||||
passwordHash,
|
||||
privileges: body.data.privileges,
|
||||
databaseId: db.id,
|
||||
},
|
||||
})
|
||||
|
||||
const { passwordHash: _, ...safe } = dbUser
|
||||
return reply.code(201).send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
// DELETE /api/cpanel/databases/:id/users/:userId
|
||||
fastify.delete('/:id/users/:userId', { preHandler }, async (request, reply) => {
|
||||
const { id, userId } = request.params as { id: string; userId: string }
|
||||
|
||||
const dbUser = await prisma.databaseUser.findFirst({
|
||||
where: { id: parseInt(userId, 10), databaseId: parseInt(id, 10) },
|
||||
})
|
||||
if (!dbUser) return reply.code(404).send({ success: false, error: 'Database user not found' })
|
||||
|
||||
await exec('mysql', ['-e', `DROP USER IF EXISTS '${mysqlEscape(dbUser.username)}'@'localhost';`])
|
||||
await exec('mysql', ['-e', 'FLUSH PRIVILEGES;'])
|
||||
await prisma.databaseUser.delete({ where: { id: dbUser.id } })
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Database user deleted' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { validateDomain, exec } from '../../core/exec.js'
|
||||
import { enableVhost } from '../../core/nginx.js'
|
||||
|
||||
export default async function domainRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
// ─── Subdomains ──────────────────────────────────────────────────────────────
|
||||
|
||||
fastify.get('/subdomains', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
const subdomains = await prisma.subdomain.findMany({ where: { accountId }, orderBy: { fqdn: 'asc' } })
|
||||
return reply.send({ success: true, data: subdomains })
|
||||
})
|
||||
|
||||
fastify.post('/subdomains', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, number>).accountId
|
||||
|
||||
const body = z.object({
|
||||
subdomain: z.string().min(1).max(63).regex(/^[a-zA-Z0-9-]+$/),
|
||||
documentRoot: z.string().optional(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Check quota
|
||||
const pkg = account.packageId ? await prisma.package.findUnique({ where: { id: account.packageId } }) : null
|
||||
if (pkg && pkg.maxSubdomains > 0) {
|
||||
const count = await prisma.subdomain.count({ where: { accountId } })
|
||||
if (count >= pkg.maxSubdomains) return reply.code(429).send({ success: false, error: 'Subdomain limit reached' })
|
||||
}
|
||||
|
||||
const fqdn = `${body.data.subdomain}.${account.domain}`
|
||||
const existing = await prisma.subdomain.findUnique({ where: { fqdn } })
|
||||
if (existing) return reply.code(409).send({ success: false, error: 'Subdomain already exists' })
|
||||
|
||||
const docRoot = body.data.documentRoot ?? `${account.homedir}/public_html/${body.data.subdomain}`
|
||||
|
||||
// Create doc root
|
||||
await exec('mkdir', ['-p', docRoot])
|
||||
await exec('chown', [`${account.username}:${account.username}`, docRoot])
|
||||
|
||||
// Create nginx vhost for subdomain
|
||||
try {
|
||||
await enableVhost({ domain: fqdn, username: account.username, documentRoot: docRoot })
|
||||
} catch (e) {
|
||||
console.warn(`Nginx vhost for subdomain ${fqdn} failed:`, e)
|
||||
}
|
||||
|
||||
const subdomain = await prisma.subdomain.create({
|
||||
data: { subdomain: body.data.subdomain, domain: account.domain, fqdn, documentRoot: docRoot, accountId },
|
||||
})
|
||||
return reply.code(201).send({ success: true, data: subdomain })
|
||||
})
|
||||
|
||||
fastify.delete('/subdomains/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const subdomain = await prisma.subdomain.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
})
|
||||
if (!subdomain) return reply.code(404).send({ success: false, error: 'Subdomain not found' })
|
||||
|
||||
const { disableVhost } = await import('../../core/nginx.js')
|
||||
try { await disableVhost(subdomain.fqdn) } catch {}
|
||||
|
||||
await prisma.subdomain.delete({ where: { id: subdomain.id } })
|
||||
return reply.send({ success: true, data: { message: 'Subdomain deleted' } })
|
||||
})
|
||||
|
||||
// ─── Addon Domains ───────────────────────────────────────────────────────────
|
||||
|
||||
fastify.get('/addon', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
const domains = await prisma.addonDomain.findMany({ where: { accountId }, orderBy: { domain: 'asc' } })
|
||||
return reply.send({ success: true, data: domains })
|
||||
})
|
||||
|
||||
fastify.post('/addon', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, number>).accountId
|
||||
|
||||
const body = z.object({
|
||||
domain: z.string(),
|
||||
subdomain: z.string().optional(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
if (!validateDomain(body.data.domain)) return reply.code(400).send({ success: false, error: 'Invalid domain' })
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const existing = await prisma.addonDomain.findUnique({ where: { domain: body.data.domain } })
|
||||
if (existing) return reply.code(409).send({ success: false, error: 'Addon domain already exists' })
|
||||
|
||||
const subdomain = body.data.subdomain ?? body.data.domain.replace(/\./g, '_')
|
||||
const docRoot = `${account.homedir}/public_html/${subdomain}`
|
||||
|
||||
await exec('mkdir', ['-p', docRoot])
|
||||
await exec('chown', [`${account.username}:${account.username}`, docRoot])
|
||||
|
||||
try {
|
||||
await enableVhost({ domain: body.data.domain, username: account.username, documentRoot: docRoot })
|
||||
} catch (e) {
|
||||
console.warn(`Nginx vhost for addon domain ${body.data.domain} failed:`, e)
|
||||
}
|
||||
|
||||
const addon = await prisma.addonDomain.create({
|
||||
data: { domain: body.data.domain, subdomain, documentRoot: docRoot, accountId },
|
||||
})
|
||||
return reply.code(201).send({ success: true, data: addon })
|
||||
})
|
||||
|
||||
fastify.delete('/addon/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const addon = await prisma.addonDomain.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
})
|
||||
if (!addon) return reply.code(404).send({ success: false, error: 'Addon domain not found' })
|
||||
|
||||
const { disableVhost } = await import('../../core/nginx.js')
|
||||
try { await disableVhost(addon.domain) } catch {}
|
||||
|
||||
await prisma.addonDomain.delete({ where: { id: addon.id } })
|
||||
return reply.send({ success: true, data: { message: 'Addon domain deleted' } })
|
||||
})
|
||||
|
||||
// ─── Parked Domains ──────────────────────────────────────────────────────────
|
||||
|
||||
fastify.get('/parked', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
const domains = await prisma.parkedDomain.findMany({ where: { accountId }, orderBy: { domain: 'asc' } })
|
||||
return reply.send({ success: true, data: domains })
|
||||
})
|
||||
|
||||
fastify.post('/parked', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, number>).accountId
|
||||
|
||||
const body = z.object({ domain: z.string() }).safeParse(request.body)
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
if (!validateDomain(body.data.domain)) return reply.code(400).send({ success: false, error: 'Invalid domain' })
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const existing = await prisma.parkedDomain.findUnique({ where: { domain: body.data.domain } })
|
||||
if (existing) return reply.code(409).send({ success: false, error: 'Parked domain already exists' })
|
||||
|
||||
// Parked domains point to main public_html
|
||||
try {
|
||||
await enableVhost({
|
||||
domain: body.data.domain,
|
||||
username: account.username,
|
||||
documentRoot: `${account.homedir}/public_html`,
|
||||
aliases: [account.domain],
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn(`Nginx vhost for parked domain ${body.data.domain} failed:`, e)
|
||||
}
|
||||
|
||||
const parked = await prisma.parkedDomain.create({
|
||||
data: { domain: body.data.domain, accountId },
|
||||
})
|
||||
return reply.code(201).send({ success: true, data: parked })
|
||||
})
|
||||
|
||||
fastify.delete('/parked/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const parked = await prisma.parkedDomain.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
})
|
||||
if (!parked) return reply.code(404).send({ success: false, error: 'Parked domain not found' })
|
||||
|
||||
const { disableVhost } = await import('../../core/nginx.js')
|
||||
try { await disableVhost(parked.domain) } catch {}
|
||||
|
||||
await prisma.parkedDomain.delete({ where: { id: parked.id } })
|
||||
return reply.send({ success: true, data: { message: 'Parked domain deleted' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
|
||||
export default async function cpanelEmailRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
// GET /api/cpanel/email
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const accounts = await prisma.emailAccount.findMany({
|
||||
where: { accountId },
|
||||
orderBy: { address: 'asc' },
|
||||
select: { id: true, address: true, username: true, domain: true, quotaMb: true, usedMb: true, status: true },
|
||||
})
|
||||
return reply.send({ success: true, data: accounts })
|
||||
})
|
||||
|
||||
// POST /api/cpanel/email
|
||||
fastify.post('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, unknown>).accountId as number
|
||||
|
||||
const body = z.object({
|
||||
username: z.string().min(1).max(64).regex(/^[a-zA-Z0-9._+-]+$/),
|
||||
password: z.string().min(8),
|
||||
quotaMb: z.number().int().min(0).default(500),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId }, include: { package: true } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
if (account.package && account.package.maxEmailAccounts > 0) {
|
||||
const count = await prisma.emailAccount.count({ where: { accountId: account.id } })
|
||||
if (count >= account.package.maxEmailAccounts) {
|
||||
return reply.code(429).send({ success: false, error: 'Email account limit reached' })
|
||||
}
|
||||
}
|
||||
|
||||
const address = `${body.data.username}@${account.domain}`
|
||||
const exists = await prisma.emailAccount.findUnique({ where: { address } })
|
||||
if (exists) return reply.code(409).send({ success: false, error: 'Email address already exists' })
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
|
||||
await exec('doveadm', ['user', 'import', '-u', address, '-p', body.data.password])
|
||||
|
||||
const emailAccount = await prisma.emailAccount.create({
|
||||
data: { username: body.data.username, domain: account.domain, address, passwordHash, quotaMb: body.data.quotaMb, accountId: account.id },
|
||||
})
|
||||
|
||||
const { passwordHash: _, ...safe } = emailAccount
|
||||
return reply.code(201).send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
// DELETE /api/cpanel/email/:id
|
||||
fastify.delete('/:id', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : 0
|
||||
const { id } = request.params as { id: string }
|
||||
|
||||
const emailAccount = await prisma.emailAccount.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!emailAccount) return reply.code(404).send({ success: false, error: 'Email account not found' })
|
||||
if (user.role === 'user' && emailAccount.accountId !== accountId) {
|
||||
return reply.code(403).send({ success: false, error: 'Forbidden' })
|
||||
}
|
||||
|
||||
await exec('doveadm', ['user', 'delete', '-u', emailAccount.address])
|
||||
await prisma.emailAccount.delete({ where: { id: emailAccount.id } })
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Email account deleted' } })
|
||||
})
|
||||
|
||||
// GET /api/cpanel/email/forwarders
|
||||
fastify.get('/forwarders', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const forwarders = await prisma.emailForwarder.findMany({ where: { accountId } })
|
||||
return reply.send({ success: true, data: forwarders })
|
||||
})
|
||||
|
||||
// POST /api/cpanel/email/forwarders
|
||||
fastify.post('/forwarders', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, unknown>).accountId as number
|
||||
|
||||
const body = z.object({
|
||||
source: z.string().email(),
|
||||
destination: z.string().email(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const forwarder = await prisma.emailForwarder.create({ data: { ...body.data, accountId } })
|
||||
return reply.code(201).send({ success: true, data: forwarder })
|
||||
})
|
||||
|
||||
// DELETE /api/cpanel/email/forwarders/:id
|
||||
fastify.delete('/forwarders/:id', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : 0
|
||||
const { id } = request.params as { id: string }
|
||||
|
||||
const forwarder = await prisma.emailForwarder.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!forwarder) return reply.code(404).send({ success: false, error: 'Forwarder not found' })
|
||||
if (user.role === 'user' && forwarder.accountId !== accountId) {
|
||||
return reply.code(403).send({ success: false, error: 'Forbidden' })
|
||||
}
|
||||
|
||||
await prisma.emailForwarder.delete({ where: { id: forwarder.id } })
|
||||
return reply.send({ success: true, data: { message: 'Forwarder deleted' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import path from 'path'
|
||||
import { stat, readdir, unlink, rename, mkdir } from 'fs/promises'
|
||||
import { createReadStream, createWriteStream } from 'fs'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
import { config } from '../../config.js'
|
||||
import prisma from '../../db/client.js'
|
||||
|
||||
/**
|
||||
* Resolve a user path, enforcing it stays inside their home directory.
|
||||
*/
|
||||
function resolveUserPath(homedir: string, userPath: string): string {
|
||||
const resolved = path.resolve(homedir, userPath.replace(/^\//, ''))
|
||||
if (!resolved.startsWith(homedir + path.sep) && resolved !== homedir) {
|
||||
throw new Error('Path traversal detected')
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
async function getAccount(fastify: FastifyInstance, userId: number) {
|
||||
return prisma.account.findUnique({ where: { id: userId } })
|
||||
}
|
||||
|
||||
export default async function fileRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
// GET /api/cpanel/files?path=
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'cPanel users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const q = request.query as Record<string, string>
|
||||
const userPath = q.path ?? '/'
|
||||
|
||||
let targetPath: string
|
||||
try {
|
||||
targetPath = resolveUserPath(account.homedir, userPath)
|
||||
} catch {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid path' })
|
||||
}
|
||||
|
||||
const entries = await readdir(targetPath, { withFileTypes: true })
|
||||
const files = await Promise.all(
|
||||
entries.map(async entry => {
|
||||
const fullPath = path.join(targetPath, entry.name)
|
||||
let size = 0
|
||||
let mtime = new Date()
|
||||
try {
|
||||
const s = await stat(fullPath)
|
||||
size = s.size
|
||||
mtime = s.mtime
|
||||
} catch {}
|
||||
return {
|
||||
name: entry.name,
|
||||
type: entry.isDirectory() ? 'directory' : 'file',
|
||||
size,
|
||||
mtime,
|
||||
path: path.join(userPath, entry.name),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
files.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: { path: userPath, files } })
|
||||
})
|
||||
|
||||
// GET /api/cpanel/files/content?path=
|
||||
fastify.get('/content', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'cPanel users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const q = request.query as Record<string, string>
|
||||
let targetPath: string
|
||||
try {
|
||||
targetPath = resolveUserPath(account.homedir, q.path ?? '')
|
||||
} catch {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid path' })
|
||||
}
|
||||
|
||||
const fileStat = await stat(targetPath)
|
||||
if (fileStat.isDirectory()) {
|
||||
return reply.code(400).send({ success: false, error: 'Path is a directory' })
|
||||
}
|
||||
if (fileStat.size > 5 * 1024 * 1024) {
|
||||
return reply.code(413).send({ success: false, error: 'File too large to edit (max 5MB)' })
|
||||
}
|
||||
|
||||
const { readFile } = await import('fs/promises')
|
||||
const content = await readFile(targetPath, 'utf8')
|
||||
return reply.send({ success: true, data: { path: q.path, content } })
|
||||
})
|
||||
|
||||
// PUT /api/cpanel/files/content — save file content
|
||||
fastify.put('/content', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'cPanel users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const body = z.object({
|
||||
path: z.string(),
|
||||
content: z.string(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
let targetPath: string
|
||||
try {
|
||||
targetPath = resolveUserPath(account.homedir, body.data.path)
|
||||
} catch {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid path' })
|
||||
}
|
||||
|
||||
const { writeFile } = await import('fs/promises')
|
||||
await writeFile(targetPath, body.data.content, 'utf8')
|
||||
await exec('chown', [`${account.username}:${account.username}`, targetPath])
|
||||
|
||||
return reply.send({ success: true, data: { message: 'File saved' } })
|
||||
})
|
||||
|
||||
// POST /api/cpanel/files/mkdir
|
||||
fastify.post('/mkdir', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'cPanel users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const body = z.object({ path: z.string() }).safeParse(request.body)
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: 'path required' })
|
||||
|
||||
let targetPath: string
|
||||
try {
|
||||
targetPath = resolveUserPath(account.homedir, body.data.path)
|
||||
} catch {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid path' })
|
||||
}
|
||||
|
||||
await mkdir(targetPath, { recursive: true })
|
||||
await exec('chown', [`${account.username}:${account.username}`, targetPath])
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Directory created' } })
|
||||
})
|
||||
|
||||
// DELETE /api/cpanel/files
|
||||
fastify.delete('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'cPanel users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const body = z.object({ paths: z.array(z.string()).min(1).max(50) }).safeParse(request.body)
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: 'paths[] required' })
|
||||
|
||||
const deleted: string[] = []
|
||||
const errors: { path: string; error: string }[] = []
|
||||
|
||||
for (const p of body.data.paths) {
|
||||
try {
|
||||
const targetPath = resolveUserPath(account.homedir, p)
|
||||
const s = await stat(targetPath)
|
||||
if (s.isDirectory()) {
|
||||
await exec('rm', ['-rf', targetPath])
|
||||
} else {
|
||||
await unlink(targetPath)
|
||||
}
|
||||
deleted.push(p)
|
||||
} catch (e) {
|
||||
errors.push({ path: p, error: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: { deleted, errors } })
|
||||
})
|
||||
|
||||
// POST /api/cpanel/files/rename
|
||||
fastify.post('/rename', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'cPanel users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const body = z.object({ from: z.string(), to: z.string() }).safeParse(request.body)
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
|
||||
const fromPath = resolveUserPath(account.homedir, body.data.from)
|
||||
const toPath = resolveUserPath(account.homedir, body.data.to)
|
||||
|
||||
await rename(fromPath, toPath)
|
||||
return reply.send({ success: true, data: { message: 'Renamed' } })
|
||||
})
|
||||
|
||||
// POST /api/cpanel/files/chmod
|
||||
fastify.post('/chmod', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'cPanel users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const body = z.object({
|
||||
path: z.string(),
|
||||
mode: z.string().regex(/^[0-7]{3,4}$/),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
|
||||
const targetPath = resolveUserPath(account.homedir, body.data.path)
|
||||
await exec('chmod', [body.data.mode, targetPath])
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Permissions updated' } })
|
||||
})
|
||||
|
||||
// POST /api/cpanel/files/compress — zip files
|
||||
fastify.post('/compress', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'cPanel users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const body = z.object({
|
||||
paths: z.array(z.string()).min(1),
|
||||
dest: z.string(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
|
||||
const destPath = resolveUserPath(account.homedir, body.data.dest)
|
||||
const srcPaths = body.data.paths.map(p => resolveUserPath(account.homedir, p))
|
||||
|
||||
await exec('tar', ['-czf', destPath, ...srcPaths])
|
||||
return reply.send({ success: true, data: { message: 'Compressed', dest: body.data.dest } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec, writeFile } from '../../core/exec.js'
|
||||
|
||||
export default async function ftpRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const accounts = await prisma.ftpAccount.findMany({
|
||||
where: { accountId },
|
||||
select: { id: true, username: true, homedir: true, quotaMb: true, status: true, createdAt: true },
|
||||
orderBy: { username: 'asc' },
|
||||
})
|
||||
return reply.send({ success: true, data: accounts })
|
||||
})
|
||||
|
||||
fastify.post('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, number>).accountId
|
||||
|
||||
const body = z.object({
|
||||
username: z.string().min(1).max(32).regex(/^[a-zA-Z0-9_]+$/),
|
||||
password: z.string().min(8),
|
||||
homedir: z.string().default('/'),
|
||||
quotaMb: z.number().int().min(0).default(0),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId }, include: { package: true } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Check quota
|
||||
if (account.package && account.package.maxFtpAccounts > 0) {
|
||||
const count = await prisma.ftpAccount.count({ where: { accountId } })
|
||||
if (count >= account.package.maxFtpAccounts) {
|
||||
return reply.code(429).send({ success: false, error: 'FTP account limit reached' })
|
||||
}
|
||||
}
|
||||
|
||||
const ftpUsername = `${account.username}_${body.data.username}`
|
||||
const homedir = `${account.homedir}${body.data.homedir}`
|
||||
|
||||
// Create system user for FTP (locked shell, home in account dir)
|
||||
await exec('useradd', ['-m', '-d', homedir, '-s', '/sbin/nologin', ftpUsername])
|
||||
await exec('mkdir', ['-p', homedir])
|
||||
await exec('chown', [`${ftpUsername}:${ftpUsername}`, homedir])
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
|
||||
// Add to vsftpd virtual users list
|
||||
const vsftpdUser = `/etc/vsftpd/users/${ftpUsername}`
|
||||
await writeFile(vsftpdUser, `${ftpUsername}\n${body.data.password}\n`)
|
||||
|
||||
const ftpAccount = await prisma.ftpAccount.create({
|
||||
data: { username: ftpUsername, passwordHash, homedir, quotaMb: body.data.quotaMb, accountId },
|
||||
})
|
||||
|
||||
const { passwordHash: _, ...safe } = ftpAccount
|
||||
return reply.code(201).send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
fastify.put('/:id/password', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const body = z.object({ password: z.string().min(8) }).safeParse(request.body)
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: 'Password must be at least 8 characters' })
|
||||
|
||||
const ftpAccount = await prisma.ftpAccount.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
})
|
||||
if (!ftpAccount) return reply.code(404).send({ success: false, error: 'FTP account not found' })
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
await prisma.ftpAccount.update({ where: { id: ftpAccount.id }, data: { passwordHash } })
|
||||
|
||||
return reply.send({ success: true, data: { message: 'FTP password updated' } })
|
||||
})
|
||||
|
||||
fastify.delete('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const ftpAccount = await prisma.ftpAccount.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
})
|
||||
if (!ftpAccount) return reply.code(404).send({ success: false, error: 'FTP account not found' })
|
||||
|
||||
await exec('userdel', [ftpAccount.username])
|
||||
await prisma.ftpAccount.delete({ where: { id: ftpAccount.id } })
|
||||
|
||||
return reply.send({ success: true, data: { message: 'FTP account deleted' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import fileRoutes from './files.js'
|
||||
import databaseRoutes from './databases.js'
|
||||
import emailRoutes from './email.js'
|
||||
import domainRoutes from './domains.js'
|
||||
import ftpRoutes from './ftp.js'
|
||||
import cronRoutes from './cron.js'
|
||||
import statsRoutes from './stats.js'
|
||||
|
||||
export default async function cpanelRoutes(fastify: FastifyInstance) {
|
||||
fastify.register(fileRoutes, { prefix: '/files' })
|
||||
fastify.register(databaseRoutes, { prefix: '/databases' })
|
||||
fastify.register(emailRoutes, { prefix: '/email' })
|
||||
fastify.register(domainRoutes, { prefix: '/domains' })
|
||||
fastify.register(ftpRoutes, { prefix: '/ftp' })
|
||||
fastify.register(cronRoutes, { prefix: '/cron' })
|
||||
fastify.register(statsRoutes, { prefix: '/stats' })
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
|
||||
export default async function statsRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
// GET /api/cpanel/stats — account resource usage summary
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const account = await prisma.account.findUnique({
|
||||
where: { id: accountId },
|
||||
include: {
|
||||
package: true,
|
||||
_count: {
|
||||
select: {
|
||||
emailAccounts: true, databases: true, ftpAccounts: true,
|
||||
subdomains: true, addonDomains: true, parkedDomains: true,
|
||||
sslCertificates: true, cronJobs: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Get actual disk usage
|
||||
const duResult = await exec('du', ['-sb', account.homedir])
|
||||
const diskUsedBytes = parseInt(duResult.stdout.split('\t')[0] ?? '0', 10)
|
||||
const diskUsedMb = Math.floor(diskUsedBytes / 1024 / 1024)
|
||||
|
||||
// Update DB with current usage
|
||||
await prisma.account.update({ where: { id: accountId }, data: { diskUsedMb: BigInt(diskUsedMb) } })
|
||||
|
||||
const pkg = account.package
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
diskUsedMb,
|
||||
diskLimitMb: pkg ? Number(pkg.diskMb) : 0,
|
||||
bandwidthUsedMb: Number(account.bandwidthUsedMb),
|
||||
bandwidthLimitMb: pkg ? Number(pkg.bandwidthMb) : 0,
|
||||
counts: account._count,
|
||||
limits: pkg ? {
|
||||
maxEmailAccounts: pkg.maxEmailAccounts,
|
||||
maxDatabases: pkg.maxDatabases,
|
||||
maxFtpAccounts: pkg.maxFtpAccounts,
|
||||
maxSubdomains: pkg.maxSubdomains,
|
||||
maxAddonDomains: pkg.maxAddonDomains,
|
||||
maxParkedDomains: pkg.maxParkedDomains,
|
||||
maxCronJobs: pkg.maxCronJobs,
|
||||
} : null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/cpanel/stats/bandwidth — bandwidth usage over time (placeholder for log parsing)
|
||||
fastify.get('/bandwidth', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Read nginx access log and count bytes for this account's domain
|
||||
const logFile = `/var/log/nginx/${account.username}.access.log`
|
||||
const result = await exec('awk', ['{sum += $10} END {print sum}', logFile])
|
||||
const bytes = parseInt(result.stdout || '0', 10)
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: { domain: account.domain, bandwidthBytes: bytes, bandwidthMb: Math.floor(bytes / 1024 / 1024) },
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdminOrReseller } from '../../middleware/auth.js'
|
||||
import { exec, validateUsername, validateDomain } from '../../core/exec.js'
|
||||
import { enableVhost, disableVhost } from '../../core/nginx.js'
|
||||
import { createZone, removeZone } from '../../core/dns.js'
|
||||
import { config } from '../../config.js'
|
||||
|
||||
const CreateAccountSchema = z.object({
|
||||
username: z.string().min(1).max(32),
|
||||
password: z.string().min(8),
|
||||
email: z.string().email(),
|
||||
domain: z.string().min(4),
|
||||
packageId: z.number().int().positive().optional(),
|
||||
resellerId: z.number().int().positive().optional(),
|
||||
ipId: z.number().int().positive().optional(),
|
||||
})
|
||||
|
||||
const UpdateAccountSchema = z.object({
|
||||
email: z.string().email().optional(),
|
||||
packageId: z.number().int().positive().optional(),
|
||||
password: z.string().min(8).optional(),
|
||||
notes: z.string().max(500).optional(),
|
||||
})
|
||||
|
||||
export default async function accountRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAdminOrReseller]
|
||||
|
||||
// GET /api/whm/accounts
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const q = request.query as Record<string, string>
|
||||
const page = Math.max(1, parseInt(q.page ?? '1', 10))
|
||||
const limit = Math.min(100, Math.max(1, parseInt(q.limit ?? '25', 10)))
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const where: Record<string, unknown> = {}
|
||||
if (q.search) {
|
||||
where.OR = [
|
||||
{ username: { contains: q.search } },
|
||||
{ domain: { contains: q.search } },
|
||||
{ email: { contains: q.search } },
|
||||
]
|
||||
}
|
||||
if (q.status) where.status = q.status
|
||||
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.account.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true, username: true, domain: true, email: true, status: true,
|
||||
diskUsedMb: true, bandwidthUsedMb: true, ipAddress: true,
|
||||
createdAt: true, suspendedAt: true, suspendReason: true,
|
||||
package: { select: { name: true, diskMb: true, bandwidthMb: true } },
|
||||
reseller: { select: { username: true } },
|
||||
},
|
||||
}),
|
||||
prisma.account.count({ where }),
|
||||
])
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: { items, total, page, limit, totalPages: Math.ceil(total / limit) },
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/whm/accounts/stats
|
||||
fastify.get('/stats', { preHandler }, async (request, reply) => {
|
||||
const where = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
|
||||
|
||||
const [total, active, suspended, terminated] = await Promise.all([
|
||||
prisma.account.count({ where }),
|
||||
prisma.account.count({ where: { ...where, status: 'active' } }),
|
||||
prisma.account.count({ where: { ...where, status: 'suspended' } }),
|
||||
prisma.account.count({ where: { ...where, status: 'terminated' } }),
|
||||
])
|
||||
|
||||
return reply.send({ success: true, data: { total, active, suspended, terminated } })
|
||||
})
|
||||
|
||||
// GET /api/whm/accounts/:id
|
||||
fastify.get('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const where: Record<string, unknown> = { id: parseInt(id, 10) }
|
||||
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
|
||||
|
||||
const account = await prisma.account.findFirst({
|
||||
where,
|
||||
include: {
|
||||
package: true,
|
||||
reseller: { select: { username: true, company: true } },
|
||||
ip: true,
|
||||
dnsZones: { select: { id: true, domain: true, status: true } },
|
||||
_count: {
|
||||
select: {
|
||||
emailAccounts: true, databases: true, ftpAccounts: true,
|
||||
subdomains: true, addonDomains: true, sslCertificates: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const { passwordHash, ...safe } = account
|
||||
return reply.send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
// POST /api/whm/accounts
|
||||
fastify.post('/', { preHandler }, async (request, reply) => {
|
||||
const body = CreateAccountSchema.safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const { username, password, email, domain, packageId, resellerId, ipId } = body.data
|
||||
|
||||
if (!validateUsername(username)) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid username format' })
|
||||
}
|
||||
if (!validateDomain(domain)) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid domain format' })
|
||||
}
|
||||
|
||||
const exists = await prisma.account.findFirst({
|
||||
where: { OR: [{ username }, { domain }] },
|
||||
})
|
||||
if (exists) {
|
||||
return reply.code(409).send({ success: false, error: 'Username or domain already in use' })
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12)
|
||||
const homedir = `${config.paths.homeDir}/${username}`
|
||||
|
||||
// Create system user
|
||||
const userAdd = await exec('useradd', [
|
||||
'-m', '-d', homedir, '-s', '/bin/false', username,
|
||||
])
|
||||
if (userAdd.exitCode !== 0 && !config.isDev) {
|
||||
return reply.code(500).send({ success: false, error: `Failed to create system user: ${userAdd.stderr}` })
|
||||
}
|
||||
|
||||
// Create directory structure
|
||||
const dirs = [
|
||||
`${homedir}/public_html`,
|
||||
`${homedir}/logs`,
|
||||
`${homedir}/tmp`,
|
||||
`${homedir}/mail`,
|
||||
`${homedir}/.ssh`,
|
||||
]
|
||||
for (const dir of dirs) {
|
||||
await exec('mkdir', ['-p', dir])
|
||||
}
|
||||
await exec('chown', ['-R', `${username}:${username}`, homedir])
|
||||
await exec('chmod', ['750', homedir])
|
||||
await exec('chmod', ['755', `${homedir}/public_html`])
|
||||
|
||||
// Resolve IP for vhost
|
||||
let ipAddress: string | undefined
|
||||
if (ipId) {
|
||||
const ip = await prisma.ipAddress.findUnique({ where: { id: ipId } })
|
||||
ipAddress = ip?.address
|
||||
}
|
||||
|
||||
// Create account in DB
|
||||
const account = await prisma.account.create({
|
||||
data: {
|
||||
username,
|
||||
passwordHash,
|
||||
email,
|
||||
domain,
|
||||
homedir,
|
||||
status: 'active',
|
||||
packageId: packageId ?? null,
|
||||
resellerId: resellerId ?? (request.user!.role === 'reseller' ? request.user!.sub : null),
|
||||
ipId: ipId ?? null,
|
||||
ipAddress: ipAddress ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
// Create DNS zone
|
||||
if (ipAddress) {
|
||||
const serverHostname = (await exec('hostname', ['-f'])).stdout || 'ns1.server.com'
|
||||
try {
|
||||
const zoneFile = await createZone({
|
||||
domain,
|
||||
ip: ipAddress,
|
||||
ns1: `ns1.${domain}`,
|
||||
ns2: `ns2.${domain}`,
|
||||
})
|
||||
await prisma.dnsZone.create({
|
||||
data: { domain, zoneFile, accountId: account.id },
|
||||
})
|
||||
} catch (e) {
|
||||
// Non-fatal — log and continue
|
||||
console.warn(`DNS zone creation failed for ${domain}:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
// Create nginx vhost
|
||||
try {
|
||||
await enableVhost({
|
||||
domain,
|
||||
username,
|
||||
documentRoot: `${homedir}/public_html`,
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn(`Nginx vhost creation failed for ${domain}:`, e)
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
action: 'account.create',
|
||||
target: username,
|
||||
details: `Created hosting account for domain ${domain}`,
|
||||
sourceIp: request.ip,
|
||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||
},
|
||||
})
|
||||
|
||||
const { passwordHash: _, ...safe } = account
|
||||
return reply.code(201).send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
// PUT /api/whm/accounts/:id
|
||||
fastify.put('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const body = UpdateAccountSchema.safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = { id: parseInt(id, 10) }
|
||||
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
|
||||
|
||||
const existing = await prisma.account.findFirst({ where })
|
||||
if (!existing) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const updateData: Record<string, unknown> = {}
|
||||
if (body.data.email) updateData.email = body.data.email
|
||||
if (body.data.packageId) updateData.packageId = body.data.packageId
|
||||
if (body.data.notes !== undefined) updateData.notes = body.data.notes
|
||||
if (body.data.password) {
|
||||
updateData.passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
// Update system user password
|
||||
await exec('passwd', ['--stdin', existing.username])
|
||||
}
|
||||
|
||||
const updated = await prisma.account.update({
|
||||
where: { id: parseInt(id, 10) },
|
||||
data: updateData,
|
||||
})
|
||||
|
||||
const { passwordHash: _, ...safe } = updated
|
||||
return reply.send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
// POST /api/whm/accounts/:id/suspend
|
||||
fastify.post('/:id/suspend', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const { reason } = (request.body as Record<string, string>) ?? {}
|
||||
|
||||
const account = await prisma.account.findFirst({
|
||||
where: { id: parseInt(id, 10), status: 'active' },
|
||||
})
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Active account not found' })
|
||||
|
||||
await exec('usermod', ['-L', account.username])
|
||||
|
||||
await prisma.account.update({
|
||||
where: { id: account.id },
|
||||
data: { status: 'suspended', suspendedAt: new Date(), suspendReason: reason ?? null },
|
||||
})
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
action: 'account.suspend',
|
||||
target: account.username,
|
||||
details: reason ?? 'No reason given',
|
||||
sourceIp: request.ip,
|
||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||
},
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: { message: `Account ${account.username} suspended` } })
|
||||
})
|
||||
|
||||
// POST /api/whm/accounts/:id/unsuspend
|
||||
fastify.post('/:id/unsuspend', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
|
||||
const account = await prisma.account.findFirst({
|
||||
where: { id: parseInt(id, 10), status: 'suspended' },
|
||||
})
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Suspended account not found' })
|
||||
|
||||
await exec('usermod', ['-U', account.username])
|
||||
|
||||
await prisma.account.update({
|
||||
where: { id: account.id },
|
||||
data: { status: 'active', suspendedAt: null, suspendReason: null },
|
||||
})
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
action: 'account.unsuspend',
|
||||
target: account.username,
|
||||
sourceIp: request.ip,
|
||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||
},
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: { message: `Account ${account.username} unsuspended` } })
|
||||
})
|
||||
|
||||
// DELETE /api/whm/accounts/:id
|
||||
fastify.delete('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const { removeFiles } = (request.body as Record<string, boolean>) ?? {}
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Remove nginx vhost
|
||||
try {
|
||||
await disableVhost(account.domain)
|
||||
} catch (e) {
|
||||
console.warn(`Failed to remove vhost for ${account.domain}:`, e)
|
||||
}
|
||||
|
||||
// Remove DNS zone
|
||||
try {
|
||||
await removeZone(account.domain)
|
||||
} catch (e) {
|
||||
console.warn(`Failed to remove DNS zone for ${account.domain}:`, e)
|
||||
}
|
||||
|
||||
// Remove system user
|
||||
const delArgs = removeFiles ? ['-r', account.username] : [account.username]
|
||||
await exec('userdel', delArgs)
|
||||
|
||||
// Mark as terminated in DB (cascade deletes related records)
|
||||
await prisma.account.update({
|
||||
where: { id: account.id },
|
||||
data: { status: 'terminated', terminatedAt: new Date() },
|
||||
})
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
action: 'account.terminate',
|
||||
target: account.username,
|
||||
details: removeFiles ? 'Files removed' : 'Files retained',
|
||||
sourceIp: request.ip,
|
||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||
},
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: { message: `Account ${account.username} terminated` } })
|
||||
})
|
||||
|
||||
// POST /api/whm/accounts/bulk
|
||||
fastify.post('/bulk', { preHandler }, async (request, reply) => {
|
||||
const body = z.object({
|
||||
action: z.enum(['suspend', 'unsuspend', 'terminate']),
|
||||
ids: z.array(z.number().int().positive()).min(1).max(100),
|
||||
reason: z.string().optional(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const { action, ids, reason } = body.data
|
||||
const results: { id: number; success: boolean; error?: string }[] = []
|
||||
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const account = await prisma.account.findUnique({ where: { id } })
|
||||
if (!account) {
|
||||
results.push({ id, success: false, error: 'Not found' })
|
||||
continue
|
||||
}
|
||||
|
||||
if (action === 'suspend') {
|
||||
await exec('usermod', ['-L', account.username])
|
||||
await prisma.account.update({
|
||||
where: { id },
|
||||
data: { status: 'suspended', suspendedAt: new Date(), suspendReason: reason ?? null },
|
||||
})
|
||||
} else if (action === 'unsuspend') {
|
||||
await exec('usermod', ['-U', account.username])
|
||||
await prisma.account.update({
|
||||
where: { id },
|
||||
data: { status: 'active', suspendedAt: null, suspendReason: null },
|
||||
})
|
||||
} else if (action === 'terminate') {
|
||||
await exec('userdel', [account.username])
|
||||
await prisma.account.update({
|
||||
where: { id },
|
||||
data: { status: 'terminated', terminatedAt: new Date() },
|
||||
})
|
||||
}
|
||||
|
||||
results.push({ id, success: true })
|
||||
} catch (e) {
|
||||
results.push({ id, success: false, error: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: results })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import path from 'path'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdmin } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
import { config } from '../../config.js'
|
||||
|
||||
const BACKUP_DIR = '/var/nixpanel/backups'
|
||||
|
||||
export default async function backupRoutes(fastify: FastifyInstance) {
|
||||
// POST /api/whm/backup/account/:id — backup a single account
|
||||
fastify.post('/account/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const account = await prisma.account.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `${account.username}-${timestamp}.tar.gz`
|
||||
const dest = path.join(BACKUP_DIR, 'accounts', filename)
|
||||
|
||||
// Ensure backup dir exists
|
||||
await exec('mkdir', ['-p', path.join(BACKUP_DIR, 'accounts')])
|
||||
|
||||
const result = await exec('tar', [
|
||||
'-czf', dest,
|
||||
'-C', config.paths.homeDir,
|
||||
account.username,
|
||||
])
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: `Backup failed: ${result.stderr}` })
|
||||
}
|
||||
|
||||
// Get size
|
||||
const stat = await exec('du', ['-sb', dest])
|
||||
const size = parseInt(stat.stdout.split('\t')[0] ?? '0', 10)
|
||||
|
||||
return reply.send({ success: true, data: { filename, path: dest, size, createdAt: new Date() } })
|
||||
})
|
||||
|
||||
// POST /api/whm/backup/server — full server backup
|
||||
fastify.post('/server', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `server-backup-${timestamp}.tar.gz`
|
||||
const dest = path.join(BACKUP_DIR, 'server', filename)
|
||||
|
||||
await exec('mkdir', ['-p', path.join(BACKUP_DIR, 'server')])
|
||||
|
||||
const result = await exec('tar', [
|
||||
'-czf', dest,
|
||||
'--exclude=/proc', '--exclude=/sys', '--exclude=/dev', '--exclude=/run',
|
||||
'--exclude=/tmp', '--exclude=/var/nixpanel/backups',
|
||||
'/etc', '/home', '/var/www',
|
||||
])
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: `Backup failed: ${result.stderr}` })
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: { filename, path: dest, createdAt: new Date() } })
|
||||
})
|
||||
|
||||
// GET /api/whm/backup/list — list available backups
|
||||
fastify.get('/list', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const result = await exec('find', [BACKUP_DIR, '-name', '*.tar.gz', '-printf', '%p %s %T@\n'])
|
||||
if (result.exitCode !== 0 && !result.stdout) {
|
||||
return reply.send({ success: true, data: [] })
|
||||
}
|
||||
|
||||
const backups = result.stdout.split('\n').filter(Boolean).map(line => {
|
||||
const parts = line.trim().split(' ')
|
||||
return {
|
||||
path: parts[0],
|
||||
filename: path.basename(parts[0] ?? ''),
|
||||
size: parseInt(parts[1] ?? '0', 10),
|
||||
createdAt: new Date(parseFloat(parts[2] ?? '0') * 1000),
|
||||
}
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: backups })
|
||||
})
|
||||
|
||||
// DELETE /api/whm/backup — delete a backup file
|
||||
fastify.delete('/', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const body = z.object({ path: z.string() }).safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: 'path required' })
|
||||
}
|
||||
|
||||
if (!body.data.path.startsWith(BACKUP_DIR)) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid backup path' })
|
||||
}
|
||||
|
||||
await exec('rm', ['-f', body.data.path])
|
||||
return reply.send({ success: true, data: { message: 'Backup deleted' } })
|
||||
})
|
||||
|
||||
// POST /api/whm/backup/restore/:filename
|
||||
fastify.post('/restore/:filename', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { filename } = request.params as { filename: string }
|
||||
// Sanitize — no path traversal
|
||||
if (filename.includes('/') || filename.includes('..')) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid filename' })
|
||||
}
|
||||
|
||||
const backupPath = path.join(BACKUP_DIR, 'accounts', filename)
|
||||
const result = await exec('tar', ['-xzf', backupPath, '-C', config.paths.homeDir])
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: `Restore failed: ${result.stderr}` })
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: { message: `Restored from ${filename}` } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdminOrReseller } from '../../middleware/auth.js'
|
||||
import { createZone, removeZone, generateZoneFile } from '../../core/dns.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
|
||||
const DnsRecordSchema = z.object({
|
||||
type: z.enum(['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'PTR', 'CAA']),
|
||||
name: z.string().min(1).max(255),
|
||||
value: z.string().min(1).max(500),
|
||||
ttl: z.number().int().min(60).max(86400).default(3600),
|
||||
priority: z.number().int().min(0).max(65535).optional(),
|
||||
})
|
||||
|
||||
export default async function dnsRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAdminOrReseller]
|
||||
|
||||
// GET /api/whm/dns
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const q = request.query as Record<string, string>
|
||||
const where: Record<string, unknown> = {}
|
||||
if (q.search) where.domain = { contains: q.search }
|
||||
if (q.accountId) where.accountId = parseInt(q.accountId, 10)
|
||||
|
||||
const zones = await prisma.dnsZone.findMany({
|
||||
where,
|
||||
orderBy: { domain: 'asc' },
|
||||
include: {
|
||||
account: { select: { username: true } },
|
||||
_count: { select: { records: true } },
|
||||
},
|
||||
})
|
||||
return reply.send({ success: true, data: zones })
|
||||
})
|
||||
|
||||
// GET /api/whm/dns/:id
|
||||
fastify.get('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const zone = await prisma.dnsZone.findUnique({
|
||||
where: { id: parseInt(id, 10) },
|
||||
include: {
|
||||
records: { orderBy: [{ type: 'asc' }, { name: 'asc' }] },
|
||||
account: { select: { username: true, domain: true } },
|
||||
},
|
||||
})
|
||||
if (!zone) return reply.code(404).send({ success: false, error: 'Zone not found' })
|
||||
return reply.send({ success: true, data: zone })
|
||||
})
|
||||
|
||||
// POST /api/whm/dns/:id/records
|
||||
fastify.post('/:id/records', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const body = DnsRecordSchema.safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const zone = await prisma.dnsZone.findUnique({
|
||||
where: { id: parseInt(id, 10) },
|
||||
include: { account: true },
|
||||
})
|
||||
if (!zone) return reply.code(404).send({ success: false, error: 'Zone not found' })
|
||||
|
||||
const record = await prisma.dnsRecord.create({
|
||||
data: { ...body.data, zoneId: zone.id },
|
||||
})
|
||||
|
||||
// Regenerate zone file and reload
|
||||
await rebuildZone(zone.id)
|
||||
|
||||
return reply.code(201).send({ success: true, data: record })
|
||||
})
|
||||
|
||||
// PUT /api/whm/dns/:zoneId/records/:recordId
|
||||
fastify.put('/:zoneId/records/:recordId', { preHandler }, async (request, reply) => {
|
||||
const { zoneId, recordId } = request.params as { zoneId: string; recordId: string }
|
||||
const body = DnsRecordSchema.partial().safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const record = await prisma.dnsRecord.findFirst({
|
||||
where: { id: parseInt(recordId, 10), zoneId: parseInt(zoneId, 10) },
|
||||
})
|
||||
if (!record) return reply.code(404).send({ success: false, error: 'Record not found' })
|
||||
|
||||
const updated = await prisma.dnsRecord.update({
|
||||
where: { id: parseInt(recordId, 10) },
|
||||
data: body.data,
|
||||
})
|
||||
|
||||
await rebuildZone(parseInt(zoneId, 10))
|
||||
|
||||
return reply.send({ success: true, data: updated })
|
||||
})
|
||||
|
||||
// DELETE /api/whm/dns/:zoneId/records/:recordId
|
||||
fastify.delete('/:zoneId/records/:recordId', { preHandler }, async (request, reply) => {
|
||||
const { zoneId, recordId } = request.params as { zoneId: string; recordId: string }
|
||||
|
||||
const record = await prisma.dnsRecord.findFirst({
|
||||
where: { id: parseInt(recordId, 10), zoneId: parseInt(zoneId, 10) },
|
||||
})
|
||||
if (!record) return reply.code(404).send({ success: false, error: 'Record not found' })
|
||||
|
||||
await prisma.dnsRecord.delete({ where: { id: parseInt(recordId, 10) } })
|
||||
await rebuildZone(parseInt(zoneId, 10))
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Record deleted' } })
|
||||
})
|
||||
|
||||
// POST /api/whm/dns/:id/reload
|
||||
fastify.post('/:id/reload', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
await rebuildZone(parseInt(id, 10))
|
||||
return reply.send({ success: true, data: { message: 'Zone reloaded' } })
|
||||
})
|
||||
}
|
||||
|
||||
async function rebuildZone(zoneId: number): Promise<void> {
|
||||
const zone = await prisma.dnsZone.findUnique({
|
||||
where: { id: zoneId },
|
||||
include: { records: true, account: true },
|
||||
})
|
||||
if (!zone || !zone.zoneFile) return
|
||||
|
||||
// Bump serial
|
||||
const serial = Math.floor(Date.now() / 1000)
|
||||
|
||||
const zoneContent = generateZoneFile({
|
||||
domain: zone.domain,
|
||||
ip: zone.account.ipAddress ?? '127.0.0.1',
|
||||
ns1: `ns1.${zone.domain}`,
|
||||
ns2: `ns2.${zone.domain}`,
|
||||
serial,
|
||||
})
|
||||
|
||||
const { writeFile } = await import('fs/promises')
|
||||
await writeFile(zone.zoneFile, zoneContent, 'utf8')
|
||||
|
||||
await prisma.dnsZone.update({ where: { id: zoneId }, data: { serial: BigInt(serial) } })
|
||||
await exec('rndc', ['reload', zone.domain])
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdminOrReseller } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
|
||||
export default async function emailRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAdminOrReseller]
|
||||
|
||||
// GET /api/whm/email?accountId=
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const q = request.query as Record<string, string>
|
||||
const where: Record<string, unknown> = {}
|
||||
if (q.accountId) where.accountId = parseInt(q.accountId, 10)
|
||||
if (q.domain) where.domain = q.domain
|
||||
|
||||
const accounts = await prisma.emailAccount.findMany({
|
||||
where,
|
||||
orderBy: { address: 'asc' },
|
||||
select: {
|
||||
id: true, address: true, username: true, domain: true,
|
||||
quotaMb: true, usedMb: true, status: true, createdAt: true,
|
||||
account: { select: { username: true } },
|
||||
},
|
||||
})
|
||||
return reply.send({ success: true, data: accounts })
|
||||
})
|
||||
|
||||
// POST /api/whm/email
|
||||
fastify.post('/', { preHandler }, async (request, reply) => {
|
||||
const body = z.object({
|
||||
accountId: z.number().int().positive(),
|
||||
username: z.string().min(1).max(64).regex(/^[a-zA-Z0-9._+-]+$/),
|
||||
password: z.string().min(8),
|
||||
quotaMb: z.number().int().min(0).default(500),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const account = await prisma.account.findUnique({
|
||||
where: { id: body.data.accountId },
|
||||
include: { package: true },
|
||||
})
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Check quota
|
||||
if (account.package && account.package.maxEmailAccounts > 0) {
|
||||
const count = await prisma.emailAccount.count({ where: { accountId: account.id } })
|
||||
if (count >= account.package.maxEmailAccounts) {
|
||||
return reply.code(429).send({ success: false, error: 'Email account limit reached for this package' })
|
||||
}
|
||||
}
|
||||
|
||||
const address = `${body.data.username}@${account.domain}`
|
||||
const exists = await prisma.emailAccount.findUnique({ where: { address } })
|
||||
if (exists) return reply.code(409).send({ success: false, error: 'Email address already exists' })
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
|
||||
// Create Postfix/Dovecot mailbox via doveadm
|
||||
const result = await exec('doveadm', [
|
||||
'user', 'import', '-u', address, '-p', body.data.password,
|
||||
])
|
||||
// Non-fatal in dev
|
||||
|
||||
const emailAccount = await prisma.emailAccount.create({
|
||||
data: {
|
||||
username: body.data.username,
|
||||
domain: account.domain,
|
||||
address,
|
||||
passwordHash,
|
||||
quotaMb: body.data.quotaMb,
|
||||
accountId: account.id,
|
||||
},
|
||||
})
|
||||
|
||||
const { passwordHash: _, ...safe } = emailAccount
|
||||
return reply.code(201).send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
// DELETE /api/whm/email/:id
|
||||
fastify.delete('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const emailAccount = await prisma.emailAccount.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!emailAccount) return reply.code(404).send({ success: false, error: 'Email account not found' })
|
||||
|
||||
await exec('doveadm', ['user', 'delete', '-u', emailAccount.address])
|
||||
await prisma.emailAccount.delete({ where: { id: emailAccount.id } })
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Email account deleted' } })
|
||||
})
|
||||
|
||||
// POST /api/whm/email/:id/password
|
||||
fastify.post('/:id/password', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const body = z.object({ password: z.string().min(8) }).safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: 'Password must be at least 8 characters' })
|
||||
}
|
||||
|
||||
const emailAccount = await prisma.emailAccount.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!emailAccount) return reply.code(404).send({ success: false, error: 'Email account not found' })
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
await prisma.emailAccount.update({ where: { id: emailAccount.id }, data: { passwordHash } })
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Password updated' } })
|
||||
})
|
||||
|
||||
// GET /api/whm/email/forwarders?accountId=
|
||||
fastify.get('/forwarders', { preHandler }, async (request, reply) => {
|
||||
const q = request.query as Record<string, string>
|
||||
const where: Record<string, unknown> = {}
|
||||
if (q.accountId) where.accountId = parseInt(q.accountId, 10)
|
||||
|
||||
const forwarders = await prisma.emailForwarder.findMany({ where })
|
||||
return reply.send({ success: true, data: forwarders })
|
||||
})
|
||||
|
||||
// POST /api/whm/email/forwarders
|
||||
fastify.post('/forwarders', { preHandler }, async (request, reply) => {
|
||||
const body = z.object({
|
||||
accountId: z.number().int().positive(),
|
||||
source: z.string().email(),
|
||||
destination: z.string().email(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const forwarder = await prisma.emailForwarder.create({ data: body.data })
|
||||
return reply.code(201).send({ success: true, data: forwarder })
|
||||
})
|
||||
|
||||
// DELETE /api/whm/email/forwarders/:id
|
||||
fastify.delete('/forwarders/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
await prisma.emailForwarder.delete({ where: { id: parseInt(id, 10) } })
|
||||
return reply.send({ success: true, data: { message: 'Forwarder deleted' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import accountRoutes from './accounts.js'
|
||||
import packageRoutes from './packages.js'
|
||||
import resellerRoutes from './resellers.js'
|
||||
import dnsRoutes from './dns.js'
|
||||
import emailRoutes from './email.js'
|
||||
import sslRoutes from './ssl.js'
|
||||
import phpRoutes from './php.js'
|
||||
import serviceRoutes from './services.js'
|
||||
import systemRoutes from './system.js'
|
||||
import securityRoutes from './security.js'
|
||||
import backupRoutes from './backup.js'
|
||||
import webserverRoutes from './webserver.js'
|
||||
|
||||
export default async function whmRoutes(fastify: FastifyInstance) {
|
||||
fastify.register(accountRoutes, { prefix: '/accounts' })
|
||||
fastify.register(packageRoutes, { prefix: '/packages' })
|
||||
fastify.register(resellerRoutes, { prefix: '/resellers' })
|
||||
fastify.register(dnsRoutes, { prefix: '/dns' })
|
||||
fastify.register(emailRoutes, { prefix: '/email' })
|
||||
fastify.register(sslRoutes, { prefix: '/ssl' })
|
||||
fastify.register(phpRoutes, { prefix: '/php' })
|
||||
fastify.register(serviceRoutes, { prefix: '/services' })
|
||||
fastify.register(systemRoutes, { prefix: '/system' })
|
||||
fastify.register(securityRoutes, { prefix: '/security' })
|
||||
fastify.register(backupRoutes, { prefix: '/backup' })
|
||||
fastify.register(webserverRoutes, { prefix: '/webserver' })
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdmin } from '../../middleware/auth.js'
|
||||
|
||||
const PackageSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
diskMb: z.number().int().min(0),
|
||||
bandwidthMb: z.number().int().min(0),
|
||||
maxDatabases: z.number().int().min(0),
|
||||
maxEmailAccounts: z.number().int().min(0),
|
||||
maxSubdomains: z.number().int().min(0),
|
||||
maxAddonDomains: z.number().int().min(0),
|
||||
maxParkedDomains: z.number().int().min(0),
|
||||
maxFtpAccounts: z.number().int().min(0),
|
||||
maxCronJobs: z.number().int().min(0),
|
||||
phpVersion: z.string().default('8.3'),
|
||||
sslEnabled: z.boolean().default(true),
|
||||
backupEnabled: z.boolean().default(true),
|
||||
dedicatedIp: z.boolean().default(false),
|
||||
notes: z.string().max(500).optional(),
|
||||
})
|
||||
|
||||
export default async function packageRoutes(fastify: FastifyInstance) {
|
||||
fastify.get('/', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const packages = await prisma.package.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: { _count: { select: { accounts: true } } },
|
||||
})
|
||||
return reply.send({ success: true, data: packages })
|
||||
})
|
||||
|
||||
fastify.get('/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const pkg = await prisma.package.findUnique({
|
||||
where: { id: parseInt(id, 10) },
|
||||
include: { _count: { select: { accounts: true } } },
|
||||
})
|
||||
if (!pkg) return reply.code(404).send({ success: false, error: 'Package not found' })
|
||||
return reply.send({ success: true, data: pkg })
|
||||
})
|
||||
|
||||
fastify.post('/', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const body = PackageSchema.safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const existing = await prisma.package.findUnique({ where: { name: body.data.name } })
|
||||
if (existing) return reply.code(409).send({ success: false, error: 'Package name already in use' })
|
||||
|
||||
const pkg = await prisma.package.create({
|
||||
data: {
|
||||
...body.data,
|
||||
diskMb: BigInt(body.data.diskMb),
|
||||
bandwidthMb: BigInt(body.data.bandwidthMb),
|
||||
},
|
||||
})
|
||||
return reply.code(201).send({ success: true, data: pkg })
|
||||
})
|
||||
|
||||
fastify.put('/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const body = PackageSchema.partial().safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const existing = await prisma.package.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!existing) return reply.code(404).send({ success: false, error: 'Package not found' })
|
||||
|
||||
const updateData: Record<string, unknown> = { ...body.data }
|
||||
if (body.data.diskMb !== undefined) updateData.diskMb = BigInt(body.data.diskMb)
|
||||
if (body.data.bandwidthMb !== undefined) updateData.bandwidthMb = BigInt(body.data.bandwidthMb)
|
||||
|
||||
const pkg = await prisma.package.update({
|
||||
where: { id: parseInt(id, 10) },
|
||||
data: updateData,
|
||||
})
|
||||
return reply.send({ success: true, data: pkg })
|
||||
})
|
||||
|
||||
fastify.delete('/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const pkg = await prisma.package.findUnique({
|
||||
where: { id: parseInt(id, 10) },
|
||||
include: { _count: { select: { accounts: true } } },
|
||||
})
|
||||
if (!pkg) return reply.code(404).send({ success: false, error: 'Package not found' })
|
||||
if (pkg._count.accounts > 0) {
|
||||
return reply.code(409).send({ success: false, error: `Cannot delete — ${pkg._count.accounts} accounts use this package` })
|
||||
}
|
||||
await prisma.package.delete({ where: { id: parseInt(id, 10) } })
|
||||
return reply.send({ success: true, data: { message: 'Package deleted' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdmin } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
import { writeFile } from '../../core/exec.js'
|
||||
|
||||
const SUPPORTED_PHP_VERSIONS = ['7.4', '8.0', '8.1', '8.2', '8.3']
|
||||
|
||||
export default async function phpRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/whm/php/versions — list installed PHP versions
|
||||
fastify.get('/versions', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const versions: { version: string; installed: boolean; active: boolean }[] = []
|
||||
|
||||
for (const v of SUPPORTED_PHP_VERSIONS) {
|
||||
const result = await exec('php' + v, ['--version'])
|
||||
const installed = result.exitCode === 0
|
||||
versions.push({ version: v, installed, active: installed })
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: versions })
|
||||
})
|
||||
|
||||
// GET /api/whm/php/accounts — list which PHP version each account uses
|
||||
fastify.get('/accounts', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const accounts = await prisma.account.findMany({
|
||||
where: { status: 'active' },
|
||||
select: {
|
||||
id: true, username: true, domain: true,
|
||||
package: { select: { phpVersion: true } },
|
||||
},
|
||||
})
|
||||
return reply.send({ success: true, data: accounts })
|
||||
})
|
||||
|
||||
// PUT /api/whm/php/accounts/:id — change PHP version for account
|
||||
fastify.put('/accounts/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const body = z.object({
|
||||
phpVersion: z.enum(['7.4', '8.0', '8.1', '8.2', '8.3']),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid PHP version' })
|
||||
}
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Create PHP-FPM pool config for this account
|
||||
const phpFpmConf = `/etc/php/${body.data.phpVersion}/fpm/pool.d/${account.username}.conf`
|
||||
const poolConfig = `[${account.username}]
|
||||
user = ${account.username}
|
||||
group = ${account.username}
|
||||
listen = /run/php/php${body.data.phpVersion}-fpm-${account.username}.sock
|
||||
listen.owner = www-data
|
||||
listen.group = www-data
|
||||
pm = dynamic
|
||||
pm.max_children = 5
|
||||
pm.start_servers = 2
|
||||
pm.min_spare_servers = 1
|
||||
pm.max_spare_servers = 3
|
||||
chdir = /
|
||||
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen
|
||||
php_admin_value[open_basedir] = ${account.homedir}:/tmp
|
||||
php_admin_value[error_log] = ${account.homedir}/logs/php_errors.log
|
||||
`
|
||||
|
||||
await writeFile(phpFpmConf, poolConfig)
|
||||
|
||||
const phpVersion = body.data.phpVersion
|
||||
await exec('systemctl', [`php${phpVersion}-fpm`, 'reload'])
|
||||
|
||||
// Update package if account has one
|
||||
if (account.packageId) {
|
||||
await prisma.package.update({
|
||||
where: { id: account.packageId },
|
||||
data: { phpVersion: body.data.phpVersion },
|
||||
})
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: { message: `PHP version set to ${body.data.phpVersion}` } })
|
||||
})
|
||||
|
||||
// GET /api/whm/php/ini/:version — get php.ini settings
|
||||
fastify.get('/ini/:version', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { version } = request.params as { version: string }
|
||||
if (!SUPPORTED_PHP_VERSIONS.includes(version)) {
|
||||
return reply.code(400).send({ success: false, error: 'Unsupported PHP version' })
|
||||
}
|
||||
|
||||
const result = await exec(`php${version}`, ['-i'])
|
||||
if (result.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: 'Failed to get PHP info' })
|
||||
}
|
||||
|
||||
// Parse key settings
|
||||
const lines = result.stdout.split('\n')
|
||||
const settings: Record<string, string> = {}
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^([^=>]+)=>\s*(.+?)\s*$/)
|
||||
if (match) settings[match[1].trim()] = match[2].trim()
|
||||
}
|
||||
|
||||
const relevant = [
|
||||
'memory_limit', 'max_execution_time', 'max_input_time', 'upload_max_filesize',
|
||||
'post_max_size', 'display_errors', 'error_reporting', 'date.timezone',
|
||||
]
|
||||
const filtered: Record<string, string> = {}
|
||||
for (const key of relevant) {
|
||||
if (settings[key]) filtered[key] = settings[key]
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: { version, settings: filtered } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdmin } from '../../middleware/auth.js'
|
||||
|
||||
const CreateResellerSchema = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
password: z.string().min(8),
|
||||
email: z.string().email(),
|
||||
contactName: z.string().max(255).optional(),
|
||||
company: z.string().max(255).optional(),
|
||||
maxAccounts: z.number().int().min(0).default(10),
|
||||
diskLimitMb: z.number().int().min(0).default(51200),
|
||||
bandwidthLimitMb: z.number().int().min(0).default(512000),
|
||||
brandName: z.string().max(255).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
})
|
||||
|
||||
export default async function resellerRoutes(fastify: FastifyInstance) {
|
||||
fastify.get('/', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const q = request.query as Record<string, string>
|
||||
const page = Math.max(1, parseInt(q.page ?? '1', 10))
|
||||
const limit = Math.min(100, Math.max(1, parseInt(q.limit ?? '25', 10)))
|
||||
|
||||
const where: Record<string, unknown> = {}
|
||||
if (q.search) {
|
||||
where.OR = [
|
||||
{ username: { contains: q.search } },
|
||||
{ email: { contains: q.search } },
|
||||
{ company: { contains: q.search } },
|
||||
]
|
||||
}
|
||||
if (q.status) where.status = q.status
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.reseller.findMany({
|
||||
where,
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true, username: true, email: true, company: true, contactName: true,
|
||||
status: true, maxAccounts: true, diskLimitMb: true, bandwidthLimitMb: true,
|
||||
diskUsedMb: true, bandwidthUsedMb: true, createdAt: true,
|
||||
_count: { select: { accounts: true } },
|
||||
},
|
||||
}),
|
||||
prisma.reseller.count({ where }),
|
||||
])
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: { items, total, page, limit, totalPages: Math.ceil(total / limit) },
|
||||
})
|
||||
})
|
||||
|
||||
fastify.get('/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const reseller = await prisma.reseller.findUnique({
|
||||
where: { id: parseInt(id, 10) },
|
||||
include: { _count: { select: { accounts: true } } },
|
||||
})
|
||||
if (!reseller) return reply.code(404).send({ success: false, error: 'Reseller not found' })
|
||||
const { passwordHash: _, ...safe } = reseller
|
||||
return reply.send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
fastify.post('/', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const body = CreateResellerSchema.safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const exists = await prisma.reseller.findFirst({
|
||||
where: { OR: [{ username: body.data.username }, { email: body.data.email }] },
|
||||
})
|
||||
if (exists) return reply.code(409).send({ success: false, error: 'Username or email already in use' })
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
const reseller = await prisma.reseller.create({
|
||||
data: {
|
||||
...body.data,
|
||||
passwordHash,
|
||||
diskLimitMb: BigInt(body.data.diskLimitMb),
|
||||
bandwidthLimitMb: BigInt(body.data.bandwidthLimitMb),
|
||||
},
|
||||
})
|
||||
|
||||
const { passwordHash: _, ...safe } = reseller
|
||||
return reply.code(201).send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
fastify.put('/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const body = CreateResellerSchema.omit({ password: true, username: true }).partial().safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const existing = await prisma.reseller.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!existing) return reply.code(404).send({ success: false, error: 'Reseller not found' })
|
||||
|
||||
const updateData: Record<string, unknown> = { ...body.data }
|
||||
if (body.data.diskLimitMb !== undefined) updateData.diskLimitMb = BigInt(body.data.diskLimitMb)
|
||||
if (body.data.bandwidthLimitMb !== undefined) updateData.bandwidthLimitMb = BigInt(body.data.bandwidthLimitMb)
|
||||
|
||||
const reseller = await prisma.reseller.update({
|
||||
where: { id: parseInt(id, 10) },
|
||||
data: updateData,
|
||||
})
|
||||
const { passwordHash: _, ...safe } = reseller
|
||||
return reply.send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
fastify.post('/:id/suspend', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
await prisma.reseller.update({
|
||||
where: { id: parseInt(id, 10) },
|
||||
data: { status: 'suspended' },
|
||||
})
|
||||
return reply.send({ success: true, data: { message: 'Reseller suspended' } })
|
||||
})
|
||||
|
||||
fastify.post('/:id/unsuspend', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
await prisma.reseller.update({
|
||||
where: { id: parseInt(id, 10) },
|
||||
data: { status: 'active' },
|
||||
})
|
||||
return reply.send({ success: true, data: { message: 'Reseller unsuspended' } })
|
||||
})
|
||||
|
||||
fastify.delete('/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const reseller = await prisma.reseller.findUnique({
|
||||
where: { id: parseInt(id, 10) },
|
||||
include: { _count: { select: { accounts: true } } },
|
||||
})
|
||||
if (!reseller) return reply.code(404).send({ success: false, error: 'Reseller not found' })
|
||||
if (reseller._count.accounts > 0) {
|
||||
return reply.code(409).send({ success: false, error: `Cannot delete — reseller has ${reseller._count.accounts} accounts` })
|
||||
}
|
||||
await prisma.reseller.delete({ where: { id: parseInt(id, 10) } })
|
||||
return reply.send({ success: true, data: { message: 'Reseller deleted' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdmin } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
|
||||
export default async function securityRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/whm/security/events
|
||||
fastify.get('/events', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const q = request.query as Record<string, string>
|
||||
const page = Math.max(1, parseInt(q.page ?? '1', 10))
|
||||
const limit = Math.min(100, parseInt(q.limit ?? '50', 10))
|
||||
const where: Record<string, unknown> = {}
|
||||
if (q.severity) where.severity = q.severity
|
||||
if (q.resolved !== undefined) where.resolved = q.resolved === 'true'
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.securityEvent.findMany({
|
||||
where, skip: (page - 1) * limit, take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.securityEvent.count({ where }),
|
||||
])
|
||||
|
||||
return reply.send({ success: true, data: { items, total, page, limit, totalPages: Math.ceil(total / limit) } })
|
||||
})
|
||||
|
||||
// POST /api/whm/security/events/:id/resolve
|
||||
fastify.post('/events/:id/resolve', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
await prisma.securityEvent.update({
|
||||
where: { id: parseInt(id, 10) },
|
||||
data: { resolved: true, resolvedAt: new Date() },
|
||||
})
|
||||
return reply.send({ success: true, data: { message: 'Event resolved' } })
|
||||
})
|
||||
|
||||
// GET /api/whm/security/blocked-ips
|
||||
fastify.get('/blocked-ips', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const blocked = await prisma.blockedIp.findMany({ orderBy: { createdAt: 'desc' } })
|
||||
return reply.send({ success: true, data: blocked })
|
||||
})
|
||||
|
||||
// POST /api/whm/security/blocked-ips
|
||||
fastify.post('/blocked-ips', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const body = z.object({
|
||||
ip: z.string().ip(),
|
||||
reason: z.string().max(500).optional(),
|
||||
expiresAt: z.string().datetime().optional(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const exists = await prisma.blockedIp.findUnique({ where: { ip: body.data.ip } })
|
||||
if (exists) return reply.code(409).send({ success: false, error: 'IP already blocked' })
|
||||
|
||||
await exec('ufw', ['insert', '1', 'deny', 'from', body.data.ip, 'to', 'any'])
|
||||
|
||||
const blocked = await prisma.blockedIp.create({
|
||||
data: {
|
||||
ip: body.data.ip,
|
||||
reason: body.data.reason,
|
||||
expiresAt: body.data.expiresAt ? new Date(body.data.expiresAt) : null,
|
||||
},
|
||||
})
|
||||
|
||||
return reply.code(201).send({ success: true, data: blocked })
|
||||
})
|
||||
|
||||
// DELETE /api/whm/security/blocked-ips/:id
|
||||
fastify.delete('/blocked-ips/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const blocked = await prisma.blockedIp.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!blocked) return reply.code(404).send({ success: false, error: 'IP not found' })
|
||||
|
||||
await exec('ufw', ['delete', 'deny', 'from', blocked.ip, 'to', 'any'])
|
||||
await prisma.blockedIp.delete({ where: { id: blocked.id } })
|
||||
|
||||
return reply.send({ success: true, data: { message: `${blocked.ip} unblocked` } })
|
||||
})
|
||||
|
||||
// GET /api/whm/security/whitelisted-ips
|
||||
fastify.get('/whitelisted-ips', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const list = await prisma.whitelistedIp.findMany({ orderBy: { createdAt: 'desc' } })
|
||||
return reply.send({ success: true, data: list })
|
||||
})
|
||||
|
||||
// POST /api/whm/security/whitelisted-ips
|
||||
fastify.post('/whitelisted-ips', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const body = z.object({
|
||||
ip: z.string().ip(),
|
||||
note: z.string().max(255).optional(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const entry = await prisma.whitelistedIp.create({ data: body.data })
|
||||
return reply.code(201).send({ success: true, data: entry })
|
||||
})
|
||||
|
||||
// DELETE /api/whm/security/whitelisted-ips/:id
|
||||
fastify.delete('/whitelisted-ips/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
await prisma.whitelistedIp.delete({ where: { id: parseInt(id, 10) } })
|
||||
return reply.send({ success: true, data: { message: 'Removed from whitelist' } })
|
||||
})
|
||||
|
||||
// GET /api/whm/security/fail2ban
|
||||
fastify.get('/fail2ban', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const result = await exec('fail2ban-client', ['status'])
|
||||
return reply.send({ success: true, data: { output: result.stdout } })
|
||||
})
|
||||
|
||||
// GET /api/whm/security/fail2ban/:jail
|
||||
fastify.get('/fail2ban/:jail', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { jail } = request.params as { jail: string }
|
||||
const result = await exec('fail2ban-client', ['status', jail])
|
||||
return reply.send({ success: true, data: { output: result.stdout } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import { requireAdmin } from '../../middleware/auth.js'
|
||||
import { exec, MANAGED_SERVICES as _ } from '../../core/exec.js'
|
||||
import { getServiceStatus, MANAGED_SERVICES } from '../../core/system.js'
|
||||
|
||||
const ServiceActionSchema = z.object({
|
||||
action: z.enum(['start', 'stop', 'restart', 'reload', 'enable', 'disable']),
|
||||
})
|
||||
|
||||
export default async function serviceRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/whm/services — list all managed services and their status
|
||||
fastify.get('/', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const statuses = await Promise.all(MANAGED_SERVICES.map(getServiceStatus))
|
||||
return reply.send({ success: true, data: statuses })
|
||||
})
|
||||
|
||||
// GET /api/whm/services/:name
|
||||
fastify.get('/:name', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { name } = request.params as { name: string }
|
||||
if (!MANAGED_SERVICES.includes(name)) {
|
||||
return reply.code(400).send({ success: false, error: 'Service not in managed list' })
|
||||
}
|
||||
const status = await getServiceStatus(name)
|
||||
return reply.send({ success: true, data: status })
|
||||
})
|
||||
|
||||
// POST /api/whm/services/:name — start/stop/restart/reload/enable/disable
|
||||
fastify.post('/:name', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { name } = request.params as { name: string }
|
||||
const body = ServiceActionSchema.safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
if (!MANAGED_SERVICES.includes(name)) {
|
||||
return reply.code(400).send({ success: false, error: 'Service not in managed list' })
|
||||
}
|
||||
|
||||
const result = await exec('systemctl', [body.data.action, name])
|
||||
if (result.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: result.stderr || 'Service action failed' })
|
||||
}
|
||||
|
||||
const status = await getServiceStatus(name)
|
||||
return reply.send({ success: true, data: { message: `${body.data.action} ${name} succeeded`, status } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import path from 'path'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdminOrReseller } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
import { enableVhost } from '../../core/nginx.js'
|
||||
import { config } from '../../config.js'
|
||||
|
||||
export default async function sslRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAdminOrReseller]
|
||||
|
||||
// GET /api/whm/ssl
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const q = request.query as Record<string, string>
|
||||
const where: Record<string, unknown> = {}
|
||||
if (q.accountId) where.accountId = parseInt(q.accountId, 10)
|
||||
if (q.status) where.status = q.status
|
||||
|
||||
const certs = await prisma.sslCertificate.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { account: { select: { username: true, domain: true } } },
|
||||
})
|
||||
return reply.send({ success: true, data: certs })
|
||||
})
|
||||
|
||||
// POST /api/whm/ssl/issue — issue Let's Encrypt cert via certbot
|
||||
fastify.post('/issue', { preHandler }, async (request, reply) => {
|
||||
const body = z.object({
|
||||
accountId: z.number().int().positive(),
|
||||
domain: z.string(),
|
||||
includeWww: z.boolean().default(true),
|
||||
autoRenew: z.boolean().default(true),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const { accountId, domain, includeWww, autoRenew } = body.data
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const domains = [domain]
|
||||
if (includeWww) domains.push(`www.${domain}`)
|
||||
|
||||
const certbotArgs = [
|
||||
'certonly', '--webroot',
|
||||
'-w', config.paths.certbotWebroot,
|
||||
'--non-interactive',
|
||||
'--agree-tos',
|
||||
'--email', account.email,
|
||||
...domains.flatMap(d => ['-d', d]),
|
||||
]
|
||||
|
||||
const result = await exec('certbot', certbotArgs)
|
||||
if (result.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: `certbot failed: ${result.stderr}` })
|
||||
}
|
||||
|
||||
const certDir = `/etc/letsencrypt/live/${domain}`
|
||||
const certPath = `${certDir}/fullchain.pem`
|
||||
const keyPath = `${certDir}/privkey.pem`
|
||||
const chainPath = `${certDir}/chain.pem`
|
||||
|
||||
const issuedAt = new Date()
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 90)
|
||||
|
||||
const existing = await prisma.sslCertificate.findFirst({ where: { accountId, domain } })
|
||||
let cert
|
||||
if (existing) {
|
||||
cert = await prisma.sslCertificate.update({
|
||||
where: { id: existing.id },
|
||||
data: { certPath, keyPath, chainPath, issuedAt, expiresAt, status: 'active', autoRenew },
|
||||
})
|
||||
} else {
|
||||
cert = await prisma.sslCertificate.create({
|
||||
data: { accountId, domain, certPath, keyPath, chainPath, issuedAt, expiresAt, status: 'active', autoRenew },
|
||||
})
|
||||
}
|
||||
|
||||
// Update nginx vhost with SSL
|
||||
try {
|
||||
await enableVhost({
|
||||
domain,
|
||||
username: account.username,
|
||||
documentRoot: `${account.homedir}/public_html`,
|
||||
sslEnabled: true,
|
||||
certPath,
|
||||
keyPath,
|
||||
chainPath,
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn(`Nginx SSL update failed for ${domain}:`, e)
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: cert })
|
||||
})
|
||||
|
||||
// POST /api/whm/ssl/revoke
|
||||
fastify.post('/:id/revoke', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const cert = await prisma.sslCertificate.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!cert) return reply.code(404).send({ success: false, error: 'Certificate not found' })
|
||||
|
||||
if (cert.certPath) {
|
||||
const result = await exec('certbot', ['revoke', '--cert-path', cert.certPath, '--non-interactive'])
|
||||
if (result.exitCode !== 0) {
|
||||
console.warn(`certbot revoke warning: ${result.stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.sslCertificate.update({
|
||||
where: { id: cert.id },
|
||||
data: { status: 'revoked' },
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Certificate revoked' } })
|
||||
})
|
||||
|
||||
// POST /api/whm/ssl/:id/renew
|
||||
fastify.post('/:id/renew', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const cert = await prisma.sslCertificate.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!cert) return reply.code(404).send({ success: false, error: 'Certificate not found' })
|
||||
|
||||
const result = await exec('certbot', ['renew', '--cert-name', cert.domain, '--non-interactive'])
|
||||
if (result.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: `Renewal failed: ${result.stderr}` })
|
||||
}
|
||||
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 90)
|
||||
|
||||
await prisma.sslCertificate.update({
|
||||
where: { id: cert.id },
|
||||
data: { status: 'active', issuedAt: new Date(), expiresAt },
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Certificate renewed' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdmin } from '../../middleware/auth.js'
|
||||
import { getSystemStats } from '../../core/system.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
import { readFile } from 'fs/promises'
|
||||
|
||||
export default async function systemRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/whm/system/stats
|
||||
fastify.get('/stats', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const stats = await getSystemStats()
|
||||
return reply.send({ success: true, data: stats })
|
||||
})
|
||||
|
||||
// GET /api/whm/system/info
|
||||
fastify.get('/info', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const [hostname, osRelease, kernel, uptime] = await Promise.all([
|
||||
exec('hostname', ['-f']),
|
||||
readFile('/etc/os-release', 'utf8').catch(() => ''),
|
||||
exec('uname', ['-r']),
|
||||
exec('uptime', ['-p']),
|
||||
])
|
||||
|
||||
const osInfo: Record<string, string> = {}
|
||||
for (const line of osRelease.split('\n')) {
|
||||
const [k, v] = line.split('=')
|
||||
if (k && v) osInfo[k] = v.replace(/"/g, '')
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
hostname: hostname.stdout,
|
||||
os: osInfo['PRETTY_NAME'] ?? 'Unknown',
|
||||
osId: osInfo['ID'] ?? 'unknown',
|
||||
kernel: kernel.stdout,
|
||||
uptime: uptime.stdout,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/whm/system/processes
|
||||
fastify.get('/processes', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const result = await exec('ps', ['aux', '--sort=-%cpu'])
|
||||
if (result.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: 'Failed to get process list' })
|
||||
}
|
||||
|
||||
const lines = result.stdout.split('\n').slice(1, 51) // top 50 processes
|
||||
const processes = lines.map(line => {
|
||||
const parts = line.trim().split(/\s+/)
|
||||
return {
|
||||
user: parts[0],
|
||||
pid: parts[1],
|
||||
cpu: parts[2],
|
||||
mem: parts[3],
|
||||
command: parts.slice(10).join(' '),
|
||||
}
|
||||
}).filter(p => p.pid)
|
||||
|
||||
return reply.send({ success: true, data: processes })
|
||||
})
|
||||
|
||||
// POST /api/whm/system/processes/:pid/kill
|
||||
fastify.post('/processes/:pid/kill', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { pid } = request.params as { pid: string }
|
||||
if (!/^\d+$/.test(pid) || parseInt(pid, 10) <= 1) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid PID' })
|
||||
}
|
||||
const result = await exec('kill', ['-9', pid])
|
||||
if (result.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: result.stderr })
|
||||
}
|
||||
return reply.send({ success: true, data: { message: `Process ${pid} killed` } })
|
||||
})
|
||||
|
||||
// GET /api/whm/system/audit-log
|
||||
fastify.get('/audit-log', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const q = request.query as Record<string, string>
|
||||
const page = Math.max(1, parseInt(q.page ?? '1', 10))
|
||||
const limit = Math.min(100, Math.max(1, parseInt(q.limit ?? '50', 10)))
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.auditLog.findMany({
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { admin: { select: { username: true } } },
|
||||
}),
|
||||
prisma.auditLog.count(),
|
||||
])
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: { items, total, page, limit, totalPages: Math.ceil(total / limit) },
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/whm/system/settings
|
||||
fastify.get('/settings', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const settings = await prisma.setting.findMany({ orderBy: { key: 'asc' } })
|
||||
const map: Record<string, string> = {}
|
||||
for (const s of settings) map[s.key] = s.value
|
||||
return reply.send({ success: true, data: map })
|
||||
})
|
||||
|
||||
// PUT /api/whm/system/settings
|
||||
fastify.put('/settings', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const body = request.body as Record<string, string>
|
||||
if (typeof body !== 'object' || Array.isArray(body)) {
|
||||
return reply.code(400).send({ success: false, error: 'Body must be a key-value object' })
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
await prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: { value: String(value) },
|
||||
create: { key, value: String(value) },
|
||||
})
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Settings updated' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import { requireAdmin } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
import { readFile } from 'fs/promises'
|
||||
|
||||
export default async function webserverRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/whm/webserver/status
|
||||
fastify.get('/status', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const [nginx, apache] = await Promise.all([
|
||||
exec('systemctl', ['is-active', 'nginx']),
|
||||
exec('systemctl', ['is-active', 'apache2']),
|
||||
])
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
nginx: { active: nginx.stdout.trim() === 'active' },
|
||||
apache: { active: apache.stdout.trim() === 'active' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/whm/webserver/vhosts
|
||||
fastify.get('/vhosts', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const result = await exec('find', ['/etc/nginx/sites-enabled', '-name', '*.conf', '-type', 'f'])
|
||||
const files = result.stdout.split('\n').filter(Boolean)
|
||||
const vhosts = files.map(f => ({
|
||||
path: f,
|
||||
domain: f.split('/').pop()?.replace('.conf', '') ?? '',
|
||||
}))
|
||||
return reply.send({ success: true, data: vhosts })
|
||||
})
|
||||
|
||||
// GET /api/whm/webserver/vhosts/:domain
|
||||
fastify.get('/vhosts/:domain', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { domain } = request.params as { domain: string }
|
||||
if (domain.includes('/') || domain.includes('..')) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid domain' })
|
||||
}
|
||||
try {
|
||||
const content = await readFile(`/etc/nginx/sites-available/${domain}.conf`, 'utf8')
|
||||
return reply.send({ success: true, data: { domain, config: content } })
|
||||
} catch {
|
||||
return reply.code(404).send({ success: false, error: 'Vhost not found' })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/whm/webserver/nginx/test
|
||||
fastify.post('/nginx/test', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const result = await exec('nginx', ['-t'])
|
||||
return reply.send({
|
||||
success: result.exitCode === 0,
|
||||
data: { output: result.stdout + result.stderr },
|
||||
})
|
||||
})
|
||||
|
||||
// POST /api/whm/webserver/nginx/reload
|
||||
fastify.post('/nginx/reload', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const test = await exec('nginx', ['-t'])
|
||||
if (test.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: `Config test failed: ${test.stderr}` })
|
||||
}
|
||||
const result = await exec('systemctl', ['reload', 'nginx'])
|
||||
return reply.send({ success: result.exitCode === 0, data: { output: result.stdout } })
|
||||
})
|
||||
|
||||
// GET /api/whm/webserver/logs/:type — access or error log (last 200 lines)
|
||||
fastify.get('/logs/:type', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { type } = request.params as { type: string }
|
||||
const logFile = type === 'error' ? '/var/log/nginx/error.log' : '/var/log/nginx/access.log'
|
||||
const result = await exec('tail', ['-n', '200', logFile])
|
||||
return reply.send({ success: true, data: { lines: result.stdout.split('\n').filter(Boolean) } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// ─── JWT Payload ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type Role = 'admin' | 'reseller' | 'user'
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: number
|
||||
role: Role
|
||||
username: string
|
||||
iat?: number
|
||||
exp?: number
|
||||
}
|
||||
|
||||
// ─── Fastify type augmentation ────────────────────────────────────────────────
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
user?: JwtPayload
|
||||
}
|
||||
}
|
||||
|
||||
// ─── API Response helpers ─────────────────────────────────────────────────────
|
||||
|
||||
export interface ApiSuccess<T = unknown> {
|
||||
success: true
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
success: false
|
||||
error: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
export type ApiResponse<T = unknown> = ApiSuccess<T> | ApiError
|
||||
|
||||
// ─── Pagination ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PaginationQuery {
|
||||
page?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
status?: string
|
||||
sortBy?: string
|
||||
sortDir?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
// ─── System ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SystemStats {
|
||||
cpu: number
|
||||
memory: { total: number; used: number; free: number; percent: number }
|
||||
disk: { total: number; used: number; free: number; percent: number }
|
||||
load: [number, number, number]
|
||||
uptime: number
|
||||
}
|
||||
|
||||
export interface ServiceStatus {
|
||||
name: string
|
||||
status: 'running' | 'stopped' | 'failed' | 'unknown'
|
||||
enabled: boolean
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NixPanel WHM</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@nixpanel/whm",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5173",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.7",
|
||||
"lucide-react": "^0.460.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"recharts": "^2.13.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import React from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import Layout from './components/Layout'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Accounts from './pages/Accounts'
|
||||
import CreateAccount from './pages/CreateAccount'
|
||||
import Packages from './pages/Packages'
|
||||
|
||||
// Lazy-loaded pages (built incrementally)
|
||||
const Resellers = React.lazy(() => import('./pages/Resellers'))
|
||||
const DNS = React.lazy(() => import('./pages/DNS'))
|
||||
const Email = React.lazy(() => import('./pages/Email'))
|
||||
const SSL = React.lazy(() => import('./pages/SSL'))
|
||||
const PHP = React.lazy(() => import('./pages/PHP'))
|
||||
const Services = React.lazy(() => import('./pages/Services'))
|
||||
const Backup = React.lazy(() => import('./pages/Backup'))
|
||||
const Security = React.lazy(() => import('./pages/Security'))
|
||||
const System = React.lazy(() => import('./pages/System'))
|
||||
const WebServer = React.lazy(() => import('./pages/WebServer'))
|
||||
const Settings = React.lazy(() => import('./pages/Settings'))
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
if (loading) return (
|
||||
<div className="min-h-screen bg-[#0f1117] flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
if (!user) return <Navigate to="/login" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { user } = useAuth()
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="accounts" element={<Accounts />} />
|
||||
<Route path="accounts/create" element={<CreateAccount />} />
|
||||
<Route path="packages" element={<Packages />} />
|
||||
<Route path="resellers" element={
|
||||
<React.Suspense fallback={<PageLoader />}><Resellers /></React.Suspense>
|
||||
} />
|
||||
<Route path="dns" element={
|
||||
<React.Suspense fallback={<PageLoader />}><DNS /></React.Suspense>
|
||||
} />
|
||||
<Route path="email" element={
|
||||
<React.Suspense fallback={<PageLoader />}><Email /></React.Suspense>
|
||||
} />
|
||||
<Route path="ssl" element={
|
||||
<React.Suspense fallback={<PageLoader />}><SSL /></React.Suspense>
|
||||
} />
|
||||
<Route path="php" element={
|
||||
<React.Suspense fallback={<PageLoader />}><PHP /></React.Suspense>
|
||||
} />
|
||||
<Route path="services" element={
|
||||
<React.Suspense fallback={<PageLoader />}><Services /></React.Suspense>
|
||||
} />
|
||||
<Route path="backup" element={
|
||||
<React.Suspense fallback={<PageLoader />}><Backup /></React.Suspense>
|
||||
} />
|
||||
<Route path="security" element={
|
||||
<React.Suspense fallback={<PageLoader />}><Security /></React.Suspense>
|
||||
} />
|
||||
<Route path="system" element={
|
||||
<React.Suspense fallback={<PageLoader />}><System /></React.Suspense>
|
||||
} />
|
||||
<Route path="webserver" element={
|
||||
<React.Suspense fallback={<PageLoader />}><WebServer /></React.Suspense>
|
||||
} />
|
||||
<Route path="settings" element={
|
||||
<React.Suspense fallback={<PageLoader />}><Settings /></React.Suspense>
|
||||
} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
function PageLoader() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="w-5 h-5 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
// Attach access token on every request
|
||||
api.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
// Auto-refresh on 401
|
||||
api.interceptors.response.use(
|
||||
res => res,
|
||||
async error => {
|
||||
const original = error.config
|
||||
if (error.response?.status === 401 && !original._retry) {
|
||||
original._retry = true
|
||||
const refreshToken = localStorage.getItem('refresh_token')
|
||||
if (!refreshToken) {
|
||||
localStorage.clear()
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(error)
|
||||
}
|
||||
try {
|
||||
const { data } = await axios.post('/api/auth/refresh', { refreshToken })
|
||||
localStorage.setItem('access_token', data.data.accessToken)
|
||||
localStorage.setItem('refresh_token', data.data.refreshToken)
|
||||
original.headers.Authorization = `Bearer ${data.data.accessToken}`
|
||||
return api(original)
|
||||
} catch {
|
||||
localStorage.clear()
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard, Users, Package, UserCog, Globe, Mail, Shield,
|
||||
Server, Database, HardDrive, Settings, LogOut, Menu, X,
|
||||
Activity, Lock, BarChart3, Cpu
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const nav = [
|
||||
{ label: 'Dashboard', to: '/', icon: LayoutDashboard },
|
||||
{ label: 'Accounts', to: '/accounts', icon: Users },
|
||||
{ label: 'Packages', to: '/packages', icon: Package },
|
||||
{ label: 'Resellers', to: '/resellers', icon: UserCog },
|
||||
{ label: 'DNS', to: '/dns', icon: Globe },
|
||||
{ label: 'Email', to: '/email', icon: Mail },
|
||||
{ label: 'SSL', to: '/ssl', icon: Lock },
|
||||
{ label: 'PHP', to: '/php', icon: Cpu },
|
||||
{ label: 'Web Server', to: '/webserver', icon: Server },
|
||||
{ label: 'Services', to: '/services', icon: Activity },
|
||||
{ label: 'Backup', to: '/backup', icon: HardDrive },
|
||||
{ label: 'Security', to: '/security', icon: Shield },
|
||||
{ label: 'System', to: '/system', icon: BarChart3 },
|
||||
{ label: 'Settings', to: '/settings', icon: Settings },
|
||||
]
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-[#0f1117]">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`flex flex-col transition-all duration-200 bg-[#1a1d27] border-r border-[#2a2d3e] ${
|
||||
sidebarOpen ? 'w-56' : 'w-14'
|
||||
}`}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2 px-4 py-4 border-b border-[#2a2d3e] min-h-[57px]">
|
||||
<div className="w-7 h-7 rounded-lg bg-indigo-600 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-white text-xs font-bold">N</span>
|
||||
</div>
|
||||
{sidebarOpen && (
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-white text-sm font-semibold leading-tight">NixPanel</p>
|
||||
<p className="text-[#64748b] text-xs">WHM</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 overflow-y-auto py-2">
|
||||
{nav.map(({ label, to, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-2 mx-2 my-0.5 rounded-lg text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-indigo-600/20 text-indigo-400'
|
||||
: 'text-[#94a3b8] hover:bg-[#1e2130] hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon size={16} className="flex-shrink-0" />
|
||||
{sidebarOpen && <span className="truncate">{label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User + logout */}
|
||||
<div className="border-t border-[#2a2d3e] p-3">
|
||||
{sidebarOpen && (
|
||||
<div className="px-1 mb-2">
|
||||
<p className="text-white text-xs font-medium truncate">{user?.username}</p>
|
||||
<p className="text-[#64748b] text-xs truncate">{user?.role}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-3 py-2 w-full rounded-lg text-[#94a3b8] hover:bg-[#1e2130] hover:text-red-400 transition-colors text-sm"
|
||||
>
|
||||
<LogOut size={16} className="flex-shrink-0" />
|
||||
{sidebarOpen && <span>Logout</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Topbar */}
|
||||
<header className="flex items-center gap-4 px-6 py-3 bg-[#1a1d27] border-b border-[#2a2d3e] min-h-[57px]">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(v => !v)}
|
||||
className="text-[#64748b] hover:text-white transition-colors"
|
||||
>
|
||||
{sidebarOpen ? <X size={18} /> : <Menu size={18} />}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<span className="text-[#64748b] text-xs">
|
||||
{new Date().toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
role: 'admin' | 'reseller'
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null
|
||||
loading: boolean
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchMe = useCallback(async () => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (!token) { setLoading(false); return }
|
||||
try {
|
||||
const { data } = await api.get('/auth/me')
|
||||
if (data.data.role !== 'admin' && data.data.role !== 'reseller') {
|
||||
localStorage.clear()
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setUser(data.data)
|
||||
} catch {
|
||||
localStorage.clear()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchMe() }, [fetchMe])
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const { data } = await api.post('/auth/login', { username, password })
|
||||
if (data.data.role !== 'admin' && data.data.role !== 'reseller') {
|
||||
throw new Error('Access denied — WHM is for administrators and resellers only')
|
||||
}
|
||||
localStorage.setItem('access_token', data.data.accessToken)
|
||||
localStorage.setItem('refresh_token', data.data.refreshToken)
|
||||
setUser(data.data.user)
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
const refreshToken = localStorage.getItem('refresh_token')
|
||||
try { await api.post('/auth/logout', { refreshToken }) } catch {}
|
||||
localStorage.clear()
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--bg-primary: #0f1117;
|
||||
--bg-secondary: #1a1d27;
|
||||
--bg-card: #1e2130;
|
||||
--border: #2a2d3e;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-muted: #64748b;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #4f46e5;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3a3d4e;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,199 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Plus, Search, MoreVertical, UserX, UserCheck, Trash2, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface Account {
|
||||
id: number
|
||||
username: string
|
||||
domain: string
|
||||
email: string
|
||||
status: string
|
||||
diskUsedMb: number
|
||||
bandwidthUsedMb: number
|
||||
ipAddress: string | null
|
||||
createdAt: string
|
||||
package?: { name: string; diskMb: number }
|
||||
}
|
||||
|
||||
interface PageData {
|
||||
items: Account[]
|
||||
total: number
|
||||
page: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
suspended: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
|
||||
terminated: 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
}
|
||||
|
||||
export default function Accounts() {
|
||||
const [data, setData] = useState<PageData | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [actionMenu, setActionMenu] = useState<number | null>(null)
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), limit: '25' })
|
||||
if (search) params.set('search', search)
|
||||
if (status) params.set('status', status)
|
||||
const r = await api.get(`/whm/accounts?${params}`)
|
||||
setData(r.data.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, search, status])
|
||||
|
||||
useEffect(() => { fetch() }, [fetch])
|
||||
|
||||
const action = async (id: number, act: 'suspend' | 'unsuspend' | 'terminate') => {
|
||||
setActionMenu(null)
|
||||
if (act === 'terminate' && !confirm('Terminate this account? This cannot be undone.')) return
|
||||
try {
|
||||
if (act === 'suspend') await api.post(`/whm/accounts/${id}/suspend`)
|
||||
else if (act === 'unsuspend') await api.post(`/whm/accounts/${id}/unsuspend`)
|
||||
else await api.delete(`/whm/accounts/${id}`)
|
||||
fetch()
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Accounts</h1>
|
||||
<p className="text-[#64748b] text-sm">{data?.total ?? 0} total</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/accounts/create"
|
||||
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-3 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus size={16} /> Create Account
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#64748b]" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1) }}
|
||||
placeholder="Search username, domain, email..."
|
||||
className="w-full bg-[#1a1d27] border border-[#2a2d3e] text-white text-sm rounded-lg pl-9 pr-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={status}
|
||||
onChange={e => { setStatus(e.target.value); setPage(1) }}
|
||||
className="bg-[#1a1d27] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
<option value="terminated">Terminated</option>
|
||||
</select>
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors">
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#2a2d3e]">
|
||||
{['Username', 'Domain', 'Package', 'Status', 'IP', 'Created', ''].map(h => (
|
||||
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && !data && (
|
||||
<tr><td colSpan={7} className="text-center text-[#64748b] py-8">Loading...</td></tr>
|
||||
)}
|
||||
{data?.items.map(acc => (
|
||||
<tr key={acc.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/accounts/${acc.id}`} className="text-indigo-400 hover:text-indigo-300 font-medium">
|
||||
{acc.username}
|
||||
</Link>
|
||||
<p className="text-[#64748b] text-xs">{acc.email}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{acc.domain}</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{acc.package?.name ?? '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border ${statusColor[acc.status] ?? ''}`}>
|
||||
{acc.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#64748b] font-mono text-xs">{acc.ipAddress ?? '—'}</td>
|
||||
<td className="px-4 py-3 text-[#64748b] text-xs">{new Date(acc.createdAt).toLocaleDateString()}</td>
|
||||
<td className="px-4 py-3 relative">
|
||||
<button
|
||||
onClick={() => setActionMenu(actionMenu === acc.id ? null : acc.id)}
|
||||
className="p-1 text-[#64748b] hover:text-white rounded transition-colors"
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
{actionMenu === acc.id && (
|
||||
<div className="absolute right-4 top-8 z-10 bg-[#1e2130] border border-[#2a2d3e] rounded-lg shadow-lg py-1 w-36">
|
||||
{acc.status === 'active' ? (
|
||||
<button onClick={() => action(acc.id, 'suspend')} className="flex items-center gap-2 w-full px-3 py-2 text-amber-400 hover:bg-[#2a2d3e] text-xs">
|
||||
<UserX size={14} /> Suspend
|
||||
</button>
|
||||
) : acc.status === 'suspended' ? (
|
||||
<button onClick={() => action(acc.id, 'unsuspend')} className="flex items-center gap-2 w-full px-3 py-2 text-emerald-400 hover:bg-[#2a2d3e] text-xs">
|
||||
<UserCheck size={14} /> Unsuspend
|
||||
</button>
|
||||
) : null}
|
||||
<button onClick={() => action(acc.id, 'terminate')} className="flex items-center gap-2 w-full px-3 py-2 text-red-400 hover:bg-[#2a2d3e] text-xs">
|
||||
<Trash2 size={14} /> Terminate
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{data?.items.length === 0 && (
|
||||
<tr><td colSpan={7} className="text-center text-[#64748b] py-8">No accounts found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[#2a2d3e]">
|
||||
<span className="text-[#64748b] text-xs">
|
||||
Page {data.page} of {data.totalPages} ({data.total} total)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 text-xs bg-[#1e2130] border border-[#2a2d3e] rounded text-[#94a3b8] disabled:opacity-40 hover:border-indigo-500 transition-colors"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= data.totalPages}
|
||||
className="px-3 py-1 text-xs bg-[#1e2130] border border-[#2a2d3e] rounded text-[#94a3b8] disabled:opacity-40 hover:border-indigo-500 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { HardDrive, Download, Trash2, RefreshCw, Play } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface Backup { path: string; filename: string; size: number; createdAt: string }
|
||||
|
||||
const fmtSize = (b: number) => b > 1024 * 1024 * 1024 ? `${(b / 1024 / 1024 / 1024).toFixed(1)} GB` : `${(b / 1024 / 1024).toFixed(0)} MB`
|
||||
|
||||
export default function Backup() {
|
||||
const [backups, setBackups] = useState<Backup[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [backing, setBacking] = useState(false)
|
||||
const [accounts, setAccounts] = useState<{ id: number; username: string }[]>([])
|
||||
const [selectedAccount, setSelectedAccount] = useState('')
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [b, a] = await Promise.all([api.get('/whm/backup/list'), api.get('/whm/accounts?limit=100')])
|
||||
setBackups(b.data.data)
|
||||
setAccounts(a.data.data.items)
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const backupAccount = async () => {
|
||||
if (!selectedAccount) return alert('Select an account')
|
||||
setBacking(true)
|
||||
try { await api.post(`/whm/backup/account/${selectedAccount}`); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Backup failed') }
|
||||
finally { setBacking(false) }
|
||||
}
|
||||
|
||||
const backupServer = async () => {
|
||||
if (!confirm('Start a full server backup? This may take a while.')) return
|
||||
setBacking(true)
|
||||
try { await api.post('/whm/backup/server'); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Backup failed') }
|
||||
finally { setBacking(false) }
|
||||
}
|
||||
|
||||
const del = async (path: string) => {
|
||||
if (!confirm('Delete this backup?')) return
|
||||
try { await api.delete('/whm/backup', { data: { path } }); fetch() }
|
||||
catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">Backup</h1><p className="text-[#64748b] text-sm">{backups.length} backup files</p></div>
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
|
||||
<h2 className="text-white text-sm font-medium mb-3">Account Backup</h2>
|
||||
<div className="flex gap-2">
|
||||
<select value={selectedAccount} onChange={e => setSelectedAccount(e.target.value)} className="flex-1 bg-[#0f1117] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500">
|
||||
<option value="">Select account...</option>
|
||||
{accounts.map(a => <option key={a.id} value={a.id}>{a.username}</option>)}
|
||||
</select>
|
||||
<button onClick={backupAccount} disabled={backing} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm px-3 py-2 rounded-lg">
|
||||
<Play size={14} /> {backing ? 'Backing up...' : 'Backup'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
|
||||
<h2 className="text-white text-sm font-medium mb-3">Full Server Backup</h2>
|
||||
<p className="text-[#64748b] text-xs mb-3">Backs up /etc, /home, and /var/www</p>
|
||||
<button onClick={backupServer} disabled={backing} className="flex items-center gap-2 bg-amber-600/20 hover:bg-amber-600/30 text-amber-400 border border-amber-500/30 text-sm px-3 py-2 rounded-lg disabled:opacity-50">
|
||||
<HardDrive size={14} /> Full Backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backup list */}
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#2a2d3e]">
|
||||
{['Filename', 'Size', 'Created', ''].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{backups.map(b => (
|
||||
<tr key={b.path} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130]">
|
||||
<td className="px-4 py-3 font-mono text-white text-xs">{b.filename}</td>
|
||||
<td className="px-4 py-3 text-[#64748b]">{fmtSize(b.size)}</td>
|
||||
<td className="px-4 py-3 text-[#64748b] text-xs">{new Date(b.createdAt).toLocaleString()}</td>
|
||||
<td className="px-4 py-3"><button onClick={() => del(b.path)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && backups.length === 0 && <tr><td colSpan={4} className="text-center text-[#64748b] py-8">No backups</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface Package { id: number; name: string }
|
||||
|
||||
export default function CreateAccount() {
|
||||
const navigate = useNavigate()
|
||||
const [packages, setPackages] = useState<Package[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [form, setForm] = useState({
|
||||
username: '', password: '', email: '', domain: '', packageId: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/whm/packages').then(r => setPackages(r.data.data)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
|
||||
setForm(f => ({ ...f, [k]: e.target.value }))
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await api.post('/whm/accounts', {
|
||||
...form,
|
||||
packageId: form.packageId ? parseInt(form.packageId, 10) : undefined,
|
||||
})
|
||||
navigate('/accounts')
|
||||
} catch (err: unknown) {
|
||||
setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed to create account')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<button onClick={() => navigate(-1)} className="flex items-center gap-2 text-[#64748b] hover:text-white text-sm mb-4 transition-colors">
|
||||
<ArrowLeft size={16} /> Back
|
||||
</button>
|
||||
|
||||
<h1 className="text-white text-xl font-semibold mb-1">Create Account</h1>
|
||||
<p className="text-[#64748b] text-sm mb-6">Provision a new hosting account</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 text-sm px-3 py-2 rounded-lg">{error}</div>
|
||||
)}
|
||||
|
||||
{[
|
||||
{ key: 'username', label: 'Username', type: 'text', placeholder: 'john', hint: 'Lowercase letters, numbers, hyphens' },
|
||||
{ key: 'domain', label: 'Primary Domain', type: 'text', placeholder: 'example.com' },
|
||||
{ key: 'email', label: 'Contact Email', type: 'email', placeholder: '[email protected]' },
|
||||
{ key: 'password', label: 'cPanel Password', type: 'password', placeholder: '••••••••', hint: 'Min 8 characters' },
|
||||
].map(({ key, label, type, placeholder, hint }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-[#94a3b8] text-xs font-medium mb-1.5">{label}</label>
|
||||
<input
|
||||
type={type}
|
||||
value={form[key as keyof typeof form]}
|
||||
onChange={set(key as keyof typeof form)}
|
||||
required
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
{hint && <p className="text-[#64748b] text-xs mt-1">{hint}</p>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<label className="block text-[#94a3b8] text-xs font-medium mb-1.5">Package</label>
|
||||
<select
|
||||
value={form.packageId}
|
||||
onChange={set('packageId')}
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="">No package (unlimited)</option>
|
||||
{packages.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => navigate(-1)} className="flex-1 border border-[#2a2d3e] text-[#94a3b8] text-sm py-2.5 rounded-lg hover:bg-[#1e2130] transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={loading} className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm py-2.5 rounded-lg transition-colors">
|
||||
{loading ? 'Creating...' : 'Create Account'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { RefreshCw, Globe, Plus, Trash2, Pencil } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface Zone { id: number; domain: string; status: string; account: { username: string }; _count: { records: number } }
|
||||
interface Record_ { id: number; type: string; name: string; value: string; ttl: number; priority: number | null }
|
||||
|
||||
export default function DNS() {
|
||||
const [zones, setZones] = useState<Zone[]>([])
|
||||
const [selected, setSelected] = useState<Zone | null>(null)
|
||||
const [records, setRecords] = useState<Record_[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [form, setForm] = useState({ type: 'A', name: '@', value: '', ttl: 3600, priority: '' })
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const fetchZones = async () => {
|
||||
setLoading(true)
|
||||
try { const r = await api.get('/whm/dns'); setZones(r.data.data) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const loadZone = async (zone: Zone) => {
|
||||
setSelected(zone)
|
||||
const r = await api.get(`/whm/dns/${zone.id}`)
|
||||
setRecords(r.data.data.records ?? [])
|
||||
}
|
||||
|
||||
useEffect(() => { fetchZones() }, [])
|
||||
|
||||
const addRecord = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); if (!selected) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.post(`/whm/dns/${selected.id}/records`, { ...form, priority: form.priority ? parseInt(form.priority, 10) : undefined })
|
||||
setShowForm(false)
|
||||
loadZone(selected)
|
||||
} catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const deleteRecord = async (recordId: number) => {
|
||||
if (!selected || !confirm('Delete this DNS record?')) return
|
||||
try { await api.delete(`/whm/dns/${selected.id}/records/${recordId}`); loadZone(selected) }
|
||||
catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">DNS</h1><p className="text-[#64748b] text-sm">{zones.length} zones</p></div>
|
||||
<button onClick={fetchZones} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* Zone list */}
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[#2a2d3e]"><h2 className="text-white text-sm font-medium">Zones</h2></div>
|
||||
<div className="divide-y divide-[#2a2d3e]">
|
||||
{zones.map(z => (
|
||||
<button key={z.id} onClick={() => loadZone(z)} className={`w-full flex items-center gap-3 px-4 py-3 hover:bg-[#1e2130] text-left transition-colors ${selected?.id === z.id ? 'bg-indigo-600/10' : ''}`}>
|
||||
<Globe size={14} className="text-[#64748b] flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm truncate">{z.domain}</p>
|
||||
<p className="text-[#64748b] text-xs">{z.account.username} · {z._count.records} records</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{zones.length === 0 && <div className="text-center text-[#64748b] py-6 text-sm">No zones</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Records */}
|
||||
<div className="lg:col-span-2 bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
{selected ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[#2a2d3e]">
|
||||
<h2 className="text-white text-sm font-medium">{selected.domain}</h2>
|
||||
<button onClick={() => setShowForm(v => !v)} className="flex items-center gap-1.5 text-xs text-indigo-400 hover:text-indigo-300">
|
||||
<Plus size={14} /> Add Record
|
||||
</button>
|
||||
</div>
|
||||
{showForm && (
|
||||
<form onSubmit={addRecord} className="px-4 py-3 border-b border-[#2a2d3e] flex gap-2 flex-wrap">
|
||||
<select value={form.type} onChange={e => setForm(f => ({ ...f, type: e.target.value }))} className="bg-[#0f1117] border border-[#2a2d3e] text-white text-xs rounded px-2 py-1.5 focus:outline-none">
|
||||
{['A','AAAA','CNAME','MX','TXT','NS','SRV','CAA'].map(t => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="Name" required className="bg-[#0f1117] border border-[#2a2d3e] text-white text-xs rounded px-2 py-1.5 w-28 focus:outline-none focus:border-indigo-500" />
|
||||
<input value={form.value} onChange={e => setForm(f => ({ ...f, value: e.target.value }))} placeholder="Value" required className="bg-[#0f1117] border border-[#2a2d3e] text-white text-xs rounded px-2 py-1.5 flex-1 min-w-32 focus:outline-none focus:border-indigo-500" />
|
||||
{form.type === 'MX' && <input value={form.priority} onChange={e => setForm(f => ({ ...f, priority: e.target.value }))} placeholder="Priority" type="number" className="bg-[#0f1117] border border-[#2a2d3e] text-white text-xs rounded px-2 py-1.5 w-20 focus:outline-none" />}
|
||||
<button type="submit" disabled={saving} className="bg-indigo-600 hover:bg-indigo-500 text-white text-xs px-3 py-1.5 rounded disabled:opacity-50">{saving ? '...' : 'Add'}</button>
|
||||
</form>
|
||||
)}
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="border-b border-[#2a2d3e]">
|
||||
{['Type','Name','Value','TTL',''].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-2">{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{records.map(r => (
|
||||
<tr key={r.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130]">
|
||||
<td className="px-4 py-2"><span className="text-xs px-1.5 py-0.5 rounded bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 font-mono">{r.type}</span></td>
|
||||
<td className="px-4 py-2 font-mono text-[#94a3b8]">{r.name}</td>
|
||||
<td className="px-4 py-2 font-mono text-[#64748b] max-w-xs truncate">{r.value}</td>
|
||||
<td className="px-4 py-2 text-[#64748b]">{r.ttl}</td>
|
||||
<td className="px-4 py-2"><button onClick={() => deleteRecord(r.id)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={12} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{records.length === 0 && <tr><td colSpan={5} className="text-center text-[#64748b] py-6">No records</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-48 text-[#64748b] text-sm">Select a zone to view records</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import { Users, Globe, Server, HardDrive, Activity, AlertTriangle } from 'lucide-react'
|
||||
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface SystemStats {
|
||||
cpu: number
|
||||
memory: { total: number; used: number; free: number; percent: number }
|
||||
disk: { total: number; used: number; free: number; percent: number }
|
||||
load: [number, number, number]
|
||||
uptime: number
|
||||
}
|
||||
|
||||
interface AccountStats {
|
||||
total: number
|
||||
active: number
|
||||
suspended: number
|
||||
terminated: number
|
||||
}
|
||||
|
||||
interface StatCard {
|
||||
label: string
|
||||
value: string | number
|
||||
sub?: string
|
||||
icon: React.ComponentType<{ size?: number; className?: string }>
|
||||
color: string
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
const gb = bytes / 1024 / 1024 / 1024
|
||||
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / 1024 / 1024).toFixed(0)} MB`
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState<SystemStats | null>(null)
|
||||
const [accountStats, setAccountStats] = useState<AccountStats | null>(null)
|
||||
const [cpuHistory, setCpuHistory] = useState<{ t: string; cpu: number; mem: number }[]>([])
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/whm/accounts/stats').then(r => setAccountStats(r.data.data)).catch(() => {})
|
||||
api.get('/whm/system/stats').then(r => setStats(r.data.data)).catch(() => {})
|
||||
|
||||
// WebSocket live stats
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
const ws = new WebSocket(`ws://${window.location.hostname}:2087/ws/stats?token=${token}`)
|
||||
wsRef.current = ws
|
||||
ws.onmessage = e => {
|
||||
const msg = JSON.parse(e.data)
|
||||
if (msg.type === 'stats') {
|
||||
setStats(msg.data)
|
||||
setCpuHistory(prev => [
|
||||
...prev.slice(-29),
|
||||
{ t: new Date().toLocaleTimeString(), cpu: msg.data.cpu, mem: msg.data.memory.percent },
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => wsRef.current?.close()
|
||||
}, [])
|
||||
|
||||
const cards: StatCard[] = [
|
||||
{
|
||||
label: 'Total Accounts',
|
||||
value: accountStats?.total ?? '—',
|
||||
sub: `${accountStats?.active ?? 0} active`,
|
||||
icon: Users,
|
||||
color: 'text-indigo-400',
|
||||
},
|
||||
{
|
||||
label: 'CPU Usage',
|
||||
value: stats ? `${stats.cpu}%` : '—',
|
||||
sub: `Load: ${stats?.load[0].toFixed(2) ?? '—'}`,
|
||||
icon: Activity,
|
||||
color: 'text-emerald-400',
|
||||
},
|
||||
{
|
||||
label: 'Memory',
|
||||
value: stats ? `${stats.memory.percent}%` : '—',
|
||||
sub: stats ? `${formatBytes(stats.memory.used)} / ${formatBytes(stats.memory.total)}` : '—',
|
||||
icon: Server,
|
||||
color: 'text-blue-400',
|
||||
},
|
||||
{
|
||||
label: 'Disk',
|
||||
value: stats ? `${stats.disk.percent}%` : '—',
|
||||
sub: stats ? `${formatBytes(stats.disk.used)} / ${formatBytes(stats.disk.total)}` : '—',
|
||||
icon: HardDrive,
|
||||
color: 'text-amber-400',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Dashboard</h1>
|
||||
<p className="text-[#64748b] text-sm mt-0.5">Server overview and real-time metrics</p>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{cards.map(card => (
|
||||
<div key={card.label} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[#64748b] text-xs font-medium">{card.label}</span>
|
||||
<card.icon size={16} className={card.color} />
|
||||
</div>
|
||||
<p className="text-white text-2xl font-bold">{card.value}</p>
|
||||
{card.sub && <p className="text-[#64748b] text-xs mt-1">{card.sub}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CPU / Memory chart */}
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
|
||||
<h2 className="text-white text-sm font-medium mb-4">CPU & Memory — Live</h2>
|
||||
{cpuHistory.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<AreaChart data={cpuHistory}>
|
||||
<defs>
|
||||
<linearGradient id="cpu" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="mem" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="t" tick={{ fill: '#64748b', fontSize: 10 }} tickLine={false} axisLine={false} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: '#64748b', fontSize: 10 }} tickLine={false} axisLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#1e2130', border: '1px solid #2a2d3e', borderRadius: 8, fontSize: 12 }}
|
||||
labelStyle={{ color: '#94a3b8' }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="cpu" stroke="#6366f1" fill="url(#cpu)" strokeWidth={2} name="CPU %" />
|
||||
<Area type="monotone" dataKey="mem" stroke="#10b981" fill="url(#mem)" strokeWidth={2} name="Mem %" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-40 flex items-center justify-center text-[#64748b] text-sm">
|
||||
Connecting to live stats...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Account status breakdown */}
|
||||
{accountStats && (
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
|
||||
<h2 className="text-white text-sm font-medium mb-4">Account Status</h2>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: 'Active', value: accountStats.active, color: 'text-emerald-400' },
|
||||
{ label: 'Suspended', value: accountStats.suspended, color: 'text-amber-400' },
|
||||
{ label: 'Terminated', value: accountStats.terminated, color: 'text-red-400' },
|
||||
].map(s => (
|
||||
<div key={s.label} className="text-center">
|
||||
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||
<p className="text-[#64748b] text-xs mt-0.5">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Mail, Plus, Trash2, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface EmailAccount { id: number; address: string; domain: string; quotaMb: number; usedMb: number; status: string }
|
||||
|
||||
export default function Email() {
|
||||
const [accounts, setAccounts] = useState<EmailAccount[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [hosting, setHosting] = useState<{ id: number; username: string; domain: string }[]>([])
|
||||
const [form, setForm] = useState({ accountId: '', username: '', password: '', quotaMb: 500 })
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [em, acc] = await Promise.all([api.get('/whm/email'), api.get('/whm/accounts?limit=100')])
|
||||
setAccounts(em.data.data)
|
||||
setHosting(acc.data.data.items)
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setError('')
|
||||
try {
|
||||
await api.post('/whm/email', { ...form, accountId: parseInt(form.accountId, 10), quotaMb: form.quotaMb })
|
||||
setShowModal(false); fetch()
|
||||
} catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const del = async (id: number, address: string) => {
|
||||
if (!confirm(`Delete ${address}?`)) return
|
||||
try { await api.delete(`/whm/email/${id}`); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">Email Accounts</h1><p className="text-[#64748b] text-sm">{accounts.length} accounts</p></div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
<button onClick={() => setShowModal(true)} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-3 py-2 rounded-lg"><Plus size={16} /> New Email</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#2a2d3e]">
|
||||
{['Address', 'Quota', 'Used', 'Status', ''].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{accounts.map(a => (
|
||||
<tr key={a.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130]">
|
||||
<td className="px-4 py-3 text-white">{a.address}</td>
|
||||
<td className="px-4 py-3 text-[#64748b]">{a.quotaMb} MB</td>
|
||||
<td className="px-4 py-3 text-[#64748b]">{a.usedMb} MB</td>
|
||||
<td className="px-4 py-3"><span className={`text-xs px-2 py-0.5 rounded-full border ${a.status === 'active' ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' : 'bg-red-500/10 text-red-400 border-red-500/20'}`}>{a.status}</span></td>
|
||||
<td className="px-4 py-3"><button onClick={() => del(a.id, a.address)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && accounts.length === 0 && <tr><td colSpan={5} className="text-center text-[#64748b] py-8">No email accounts</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl w-full max-w-sm">
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#2a2d3e]">
|
||||
<h2 className="text-white font-medium">New Email Account</h2>
|
||||
<button onClick={() => setShowModal(false)} className="text-[#64748b] hover:text-white">✕</button>
|
||||
</div>
|
||||
<form onSubmit={save} className="p-4 space-y-3">
|
||||
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 text-xs px-3 py-2 rounded">{error}</div>}
|
||||
<div><label className="block text-[#94a3b8] text-xs mb-1">Account</label>
|
||||
<select value={form.accountId} onChange={e => setForm(f => ({ ...f, accountId: e.target.value }))} required className="w-full bg-[#0f1117] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500">
|
||||
<option value="">Select account...</option>
|
||||
{hosting.map(h => <option key={h.id} value={h.id}>{h.username} ({h.domain})</option>)}
|
||||
</select></div>
|
||||
<div><label className="block text-[#94a3b8] text-xs mb-1">Username</label>
|
||||
<input value={form.username} onChange={e => setForm(f => ({ ...f, username: e.target.value }))} required placeholder="info" className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500" /></div>
|
||||
<div><label className="block text-[#94a3b8] text-xs mb-1">Password</label>
|
||||
<input type="password" value={form.password} onChange={e => setForm(f => ({ ...f, password: e.target.value }))} required className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500" /></div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowModal(false)} className="flex-1 border border-[#2a2d3e] text-[#94a3b8] text-sm py-2 rounded hover:bg-[#1e2130]">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? 'Creating...' : 'Create'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { Shield } from 'lucide-react'
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(username, password)
|
||||
navigate('/')
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Login failed'
|
||||
setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0f1117] flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="w-12 h-12 rounded-2xl bg-indigo-600 flex items-center justify-center mb-3">
|
||||
<Shield size={24} className="text-white" />
|
||||
</div>
|
||||
<h1 className="text-white text-xl font-bold">NixPanel WHM</h1>
|
||||
<p className="text-[#64748b] text-sm mt-1">Web Host Manager</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 text-sm px-3 py-2 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-[#94a3b8] text-xs font-medium mb-1.5">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
placeholder="root"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[#94a3b8] text-xs font-medium mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-medium py-2.5 rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[#64748b] text-xs mt-4">
|
||||
NixPanel v0.1.0 · WHM Port 2087
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Cpu, CheckCircle, XCircle, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface PhpVersion { version: string; installed: boolean; active: boolean }
|
||||
interface AccountPhp { id: number; username: string; domain: string; package: { phpVersion: string } | null }
|
||||
|
||||
export default function PHP() {
|
||||
const [versions, setVersions] = useState<PhpVersion[]>([])
|
||||
const [accounts, setAccounts] = useState<AccountPhp[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [v, a] = await Promise.all([api.get('/whm/php/versions'), api.get('/whm/php/accounts')])
|
||||
setVersions(v.data.data)
|
||||
setAccounts(a.data.data)
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const changeVersion = async (accountId: number, phpVersion: string) => {
|
||||
try {
|
||||
await api.put(`/whm/php/accounts/${accountId}`, { phpVersion })
|
||||
fetch()
|
||||
} catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">PHP</h1><p className="text-[#64748b] text-sm">Manage PHP versions per account</p></div>
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
</div>
|
||||
|
||||
{/* Installed versions */}
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{versions.map(v => (
|
||||
<div key={v.version} className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm ${v.installed ? 'bg-emerald-500/5 border-emerald-500/20' : 'bg-[#1a1d27] border-[#2a2d3e]'}`}>
|
||||
<Cpu size={14} className={v.installed ? 'text-emerald-400' : 'text-[#64748b]'} />
|
||||
<span className="text-white font-mono">PHP {v.version}</span>
|
||||
{v.installed ? <CheckCircle size={12} className="text-emerald-400" /> : <XCircle size={12} className="text-[#64748b]" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Account PHP versions */}
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[#2a2d3e]"><h2 className="text-white text-sm font-medium">Account PHP Versions</h2></div>
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#2a2d3e]">
|
||||
{['Account', 'Domain', 'PHP Version', 'Change'].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{accounts.map(a => (
|
||||
<tr key={a.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130]">
|
||||
<td className="px-4 py-3 text-white">{a.username}</td>
|
||||
<td className="px-4 py-3 text-[#64748b]">{a.domain}</td>
|
||||
<td className="px-4 py-3"><span className="font-mono text-indigo-400 text-xs">PHP {a.package?.phpVersion ?? '8.3'}</span></td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
defaultValue={a.package?.phpVersion ?? '8.3'}
|
||||
onChange={e => changeVersion(a.id, e.target.value)}
|
||||
className="bg-[#0f1117] border border-[#2a2d3e] text-[#94a3b8] text-xs rounded px-2 py-1 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
{['7.4','8.0','8.1','8.2','8.3'].map(v => <option key={v} value={v}>{v}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && accounts.length === 0 && <tr><td colSpan={4} className="text-center text-[#64748b] py-8">No accounts</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Plus, Pencil, Trash2, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface Package {
|
||||
id: number; name: string; diskMb: number; bandwidthMb: number
|
||||
maxDatabases: number; maxEmailAccounts: number; maxSubdomains: number
|
||||
phpVersion: string; sslEnabled: boolean
|
||||
_count?: { accounts: number }
|
||||
}
|
||||
|
||||
const blank = {
|
||||
name: '', diskMb: 1024, bandwidthMb: 10240,
|
||||
maxDatabases: 5, maxEmailAccounts: 10, maxSubdomains: 10,
|
||||
maxAddonDomains: 5, maxParkedDomains: 5, maxFtpAccounts: 3,
|
||||
maxCronJobs: 5, phpVersion: '8.3', sslEnabled: true, backupEnabled: true,
|
||||
}
|
||||
|
||||
export default function Packages() {
|
||||
const [packages, setPackages] = useState<Package[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editing, setEditing] = useState<Package | null>(null)
|
||||
const [form, setForm] = useState(blank)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try { const r = await api.get('/whm/packages'); setPackages(r.data.data) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const openCreate = () => { setEditing(null); setForm(blank); setError(''); setShowModal(true) }
|
||||
const openEdit = (p: Package) => {
|
||||
setEditing(p)
|
||||
setForm({ ...blank, ...p })
|
||||
setError('')
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setError('')
|
||||
try {
|
||||
if (editing) await api.put(`/whm/packages/${editing.id}`, form)
|
||||
else await api.post('/whm/packages', form)
|
||||
setShowModal(false); fetch()
|
||||
} catch (err: unknown) {
|
||||
setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Save failed')
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const del = async (p: Package) => {
|
||||
if (!confirm(`Delete package "${p.name}"?`)) return
|
||||
try { await api.delete(`/whm/packages/${p.id}`); fetch() }
|
||||
catch (err: unknown) {
|
||||
alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Delete failed')
|
||||
}
|
||||
}
|
||||
|
||||
const field = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
|
||||
setForm(f => ({ ...f, [k]: e.target.type === 'number' ? Number(e.target.value) : e.target.type === 'checkbox' ? (e.target as HTMLInputElement).checked : e.target.value }))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">Packages</h1><p className="text-[#64748b] text-sm">{packages.length} hosting plans</p></div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
<button onClick={openCreate} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-3 py-2 rounded-lg"><Plus size={16} /> New Package</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{packages.map(p => (
|
||||
<div key={p.id} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-white font-medium">{p.name}</h3>
|
||||
<p className="text-[#64748b] text-xs">{p._count?.accounts ?? 0} accounts</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => openEdit(p)} className="p-1.5 text-[#64748b] hover:text-white rounded"><Pencil size={14} /></button>
|
||||
<button onClick={() => del(p)} className="p-1.5 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{[
|
||||
['Disk', p.diskMb === 0 ? 'Unlimited' : `${(p.diskMb / 1024).toFixed(0)} GB`],
|
||||
['BW', p.bandwidthMb === 0 ? 'Unlimited' : `${(p.bandwidthMb / 1024).toFixed(0)} GB`],
|
||||
['Databases', p.maxDatabases === 0 ? '∞' : p.maxDatabases],
|
||||
['Emails', p.maxEmailAccounts === 0 ? '∞' : p.maxEmailAccounts],
|
||||
['Subdomains', p.maxSubdomains === 0 ? '∞' : p.maxSubdomains],
|
||||
['PHP', p.phpVersion],
|
||||
].map(([l, v]) => (
|
||||
<div key={String(l)} className="flex justify-between">
|
||||
<span className="text-[#64748b]">{l}</span>
|
||||
<span className="text-[#94a3b8]">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{p.sslEnabled && <span className="mt-3 inline-block text-xs px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">SSL</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#2a2d3e]">
|
||||
<h2 className="text-white font-medium">{editing ? 'Edit Package' : 'New Package'}</h2>
|
||||
<button onClick={() => setShowModal(false)} className="text-[#64748b] hover:text-white">✕</button>
|
||||
</div>
|
||||
<form onSubmit={save} className="p-4 space-y-3">
|
||||
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 text-xs px-3 py-2 rounded">{error}</div>}
|
||||
<div><label className="block text-[#94a3b8] text-xs mb-1">Name</label>
|
||||
<input value={form.name} onChange={field('name')} required className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500" /></div>
|
||||
{[
|
||||
['Disk (MB)', 'diskMb'], ['Bandwidth (MB)', 'bandwidthMb'],
|
||||
['Max Databases', 'maxDatabases'], ['Max Email Accounts', 'maxEmailAccounts'],
|
||||
['Max Subdomains', 'maxSubdomains'], ['Max Addon Domains', 'maxAddonDomains'],
|
||||
['Max FTP Accounts', 'maxFtpAccounts'], ['Max Cron Jobs', 'maxCronJobs'],
|
||||
].map(([label, key]) => (
|
||||
<div key={key}><label className="block text-[#94a3b8] text-xs mb-1">{label} (0 = unlimited)</label>
|
||||
<input type="number" min={0} value={form[key as keyof typeof form] as number} onChange={field(key as keyof typeof form)} className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500" /></div>
|
||||
))}
|
||||
<div><label className="block text-[#94a3b8] text-xs mb-1">PHP Version</label>
|
||||
<select value={form.phpVersion} onChange={field('phpVersion')} className="w-full bg-[#0f1117] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500">
|
||||
{['7.4','8.0','8.1','8.2','8.3'].map(v => <option key={v} value={v}>{v}</option>)}
|
||||
</select></div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowModal(false)} className="flex-1 border border-[#2a2d3e] text-[#94a3b8] text-sm py-2 rounded hover:bg-[#1e2130]">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? 'Saving...' : 'Save'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import { Plus, Search, RefreshCw, UserX, UserCheck, Trash2 } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface Reseller { id: number; username: string; email: string; company: string | null; status: string; _count?: { accounts: number } }
|
||||
|
||||
export default function Resellers() {
|
||||
const [items, setItems] = useState<Reseller[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [form, setForm] = useState({ username: '', password: '', email: '', company: '', maxAccounts: 10, diskLimitMb: 51200, bandwidthLimitMb: 512000 })
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = search ? `?search=${encodeURIComponent(search)}` : ''
|
||||
const r = await api.get(`/whm/resellers${params}`)
|
||||
setItems(r.data.data.items ?? r.data.data)
|
||||
} finally { setLoading(false) }
|
||||
}, [search])
|
||||
|
||||
useEffect(() => { fetch() }, [fetch])
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setError('')
|
||||
try { await api.post('/whm/resellers', form); setShowModal(false); fetch() }
|
||||
catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const act = async (id: number, action: 'suspend' | 'unsuspend' | 'delete') => {
|
||||
if (action === 'delete' && !confirm('Delete this reseller?')) return
|
||||
try {
|
||||
if (action === 'suspend') await api.post(`/whm/resellers/${id}/suspend`)
|
||||
else if (action === 'unsuspend') await api.post(`/whm/resellers/${id}/unsuspend`)
|
||||
else await api.delete(`/whm/resellers/${id}`)
|
||||
fetch()
|
||||
} catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">Resellers</h1><p className="text-[#64748b] text-sm">{items.length} resellers</p></div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
<button onClick={() => setShowModal(true)} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-3 py-2 rounded-lg"><Plus size={16} /> New Reseller</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-sm">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#64748b]" />
|
||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search resellers..." className="w-full bg-[#1a1d27] border border-[#2a2d3e] text-white text-sm rounded-lg pl-9 pr-3 py-2 focus:outline-none focus:border-indigo-500" />
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#2a2d3e]">
|
||||
{['Username', 'Email', 'Company', 'Accounts', 'Status', ''].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{items.map(r => (
|
||||
<tr key={r.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130]">
|
||||
<td className="px-4 py-3 text-white">{r.username}</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{r.email}</td>
|
||||
<td className="px-4 py-3 text-[#64748b]">{r.company ?? '—'}</td>
|
||||
<td className="px-4 py-3 text-[#64748b]">{r._count?.accounts ?? 0}</td>
|
||||
<td className="px-4 py-3"><span className={`text-xs px-2 py-0.5 rounded-full border ${r.status === 'active' ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' : 'bg-amber-500/10 text-amber-400 border-amber-500/20'}`}>{r.status}</span></td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1">
|
||||
{r.status === 'active'
|
||||
? <button onClick={() => act(r.id, 'suspend')} className="p-1 text-[#64748b] hover:text-amber-400 rounded"><UserX size={14} /></button>
|
||||
: <button onClick={() => act(r.id, 'unsuspend')} className="p-1 text-[#64748b] hover:text-emerald-400 rounded"><UserCheck size={14} /></button>}
|
||||
<button onClick={() => act(r.id, 'delete')} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && items.length === 0 && <tr><td colSpan={6} className="text-center text-[#64748b] py-8">No resellers</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl w-full max-w-md">
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#2a2d3e]">
|
||||
<h2 className="text-white font-medium">New Reseller</h2>
|
||||
<button onClick={() => setShowModal(false)} className="text-[#64748b] hover:text-white">✕</button>
|
||||
</div>
|
||||
<form onSubmit={save} className="p-4 space-y-3">
|
||||
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 text-xs px-3 py-2 rounded">{error}</div>}
|
||||
{[['Username', 'username', 'text'], ['Password', 'password', 'password'], ['Email', 'email', 'email'], ['Company', 'company', 'text']].map(([label, key, type]) => (
|
||||
<div key={key}><label className="block text-[#94a3b8] text-xs mb-1">{label}</label>
|
||||
<input type={type} value={form[key as keyof typeof form] as string} required={key !== 'company'}
|
||||
onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500" /></div>
|
||||
))}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowModal(false)} className="flex-1 border border-[#2a2d3e] text-[#94a3b8] text-sm py-2 rounded hover:bg-[#1e2130]">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? 'Creating...' : 'Create'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Lock, Plus, RefreshCw, RotateCcw, XCircle } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface Cert { id: number; domain: string; type: string; status: string; issuedAt: string | null; expiresAt: string | null; account: { username: string } }
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
pending: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
|
||||
expired: 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
revoked: 'bg-[#1e2130] text-[#64748b] border-[#2a2d3e]',
|
||||
}
|
||||
|
||||
export default function SSL() {
|
||||
const [certs, setCerts] = useState<Cert[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [hosting, setHosting] = useState<{ id: number; username: string; domain: string }[]>([])
|
||||
const [form, setForm] = useState({ accountId: '', domain: '', includeWww: true, autoRenew: true })
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [sl, acc] = await Promise.all([api.get('/whm/ssl'), api.get('/whm/accounts?limit=100')])
|
||||
setCerts(sl.data.data)
|
||||
setHosting(acc.data.data.items)
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const issue = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setError('')
|
||||
try {
|
||||
await api.post('/whm/ssl/issue', { ...form, accountId: parseInt(form.accountId, 10) })
|
||||
setShowModal(false); fetch()
|
||||
} catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const renew = async (id: number) => {
|
||||
try { await api.post(`/whm/ssl/${id}/renew`); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Renewal failed') }
|
||||
}
|
||||
|
||||
const revoke = async (id: number) => {
|
||||
if (!confirm('Revoke this certificate?')) return
|
||||
try { await api.post(`/whm/ssl/${id}/revoke`); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Revoke failed') }
|
||||
}
|
||||
|
||||
const daysUntil = (date: string | null) => {
|
||||
if (!date) return null
|
||||
const days = Math.ceil((new Date(date).getTime() - Date.now()) / 86400000)
|
||||
return days
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">SSL Certificates</h1><p className="text-[#64748b] text-sm">{certs.length} certificates</p></div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
<button onClick={() => setShowModal(true)} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-3 py-2 rounded-lg"><Plus size={16} /> Issue Cert</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#2a2d3e]">
|
||||
{['Domain', 'Account', 'Status', 'Expires', 'Actions'].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{certs.map(c => {
|
||||
const days = daysUntil(c.expiresAt)
|
||||
return (
|
||||
<tr key={c.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130]">
|
||||
<td className="px-4 py-3 text-white flex items-center gap-2"><Lock size={12} className="text-emerald-400 flex-shrink-0" />{c.domain}</td>
|
||||
<td className="px-4 py-3 text-[#64748b]">{c.account.username}</td>
|
||||
<td className="px-4 py-3"><span className={`text-xs px-2 py-0.5 rounded-full border ${statusColor[c.status] ?? ''}`}>{c.status}</span></td>
|
||||
<td className="px-4 py-3 text-xs">
|
||||
{c.expiresAt ? (
|
||||
<span className={days !== null && days < 14 ? 'text-red-400' : days !== null && days < 30 ? 'text-amber-400' : 'text-[#64748b]'}>
|
||||
{days !== null && days > 0 ? `${days}d` : 'Expired'} · {new Date(c.expiresAt).toLocaleDateString()}
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1">
|
||||
{c.status === 'active' && <button onClick={() => renew(c.id)} className="flex items-center gap-1 px-2 py-1 text-xs text-indigo-400 hover:bg-indigo-600/10 rounded border border-indigo-500/20"><RotateCcw size={12} /> Renew</button>}
|
||||
{c.status !== 'revoked' && <button onClick={() => revoke(c.id)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><XCircle size={14} /></button>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{!loading && certs.length === 0 && <tr><td colSpan={5} className="text-center text-[#64748b] py-8">No certificates</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl w-full max-w-sm">
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#2a2d3e]">
|
||||
<h2 className="text-white font-medium">Issue SSL Certificate</h2>
|
||||
<button onClick={() => setShowModal(false)} className="text-[#64748b] hover:text-white">✕</button>
|
||||
</div>
|
||||
<form onSubmit={issue} className="p-4 space-y-3">
|
||||
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 text-xs px-3 py-2 rounded">{error}</div>}
|
||||
<div><label className="block text-[#94a3b8] text-xs mb-1">Account</label>
|
||||
<select value={form.accountId} onChange={e => setForm(f => ({ ...f, accountId: e.target.value, domain: hosting.find(h => h.id === parseInt(e.target.value, 10))?.domain ?? '' }))} required className="w-full bg-[#0f1117] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500">
|
||||
<option value="">Select account...</option>
|
||||
{hosting.map(h => <option key={h.id} value={h.id}>{h.username} ({h.domain})</option>)}
|
||||
</select></div>
|
||||
<div><label className="block text-[#94a3b8] text-xs mb-1">Domain</label>
|
||||
<input value={form.domain} onChange={e => setForm(f => ({ ...f, domain: e.target.value }))} required className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500" /></div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={form.includeWww} onChange={e => setForm(f => ({ ...f, includeWww: e.target.checked }))} className="accent-indigo-500" />
|
||||
<span className="text-[#94a3b8] text-xs">Include www. variant</span>
|
||||
</label>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowModal(false)} className="flex-1 border border-[#2a2d3e] text-[#94a3b8] text-sm py-2 rounded hover:bg-[#1e2130]">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? 'Issuing...' : 'Issue'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Shield, Plus, Trash2, CheckCircle } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface BlockedIp { id: number; ip: string; reason: string | null; createdAt: string }
|
||||
interface SecurityEvent { id: number; type: string; severity: string; message: string; sourceIp: string | null; resolved: boolean; createdAt: string }
|
||||
|
||||
const severityColor: Record<string, string> = {
|
||||
info: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
|
||||
warning: 'text-amber-400 bg-amber-500/10 border-amber-500/20',
|
||||
critical: 'text-red-400 bg-red-500/10 border-red-500/20',
|
||||
}
|
||||
|
||||
export default function Security() {
|
||||
const [blocked, setBlocked] = useState<BlockedIp[]>([])
|
||||
const [events, setEvents] = useState<SecurityEvent[]>([])
|
||||
const [newIp, setNewIp] = useState('')
|
||||
const [reason, setReason] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [b, e] = await Promise.all([
|
||||
api.get('/whm/security/blocked-ips'),
|
||||
api.get('/whm/security/events'),
|
||||
])
|
||||
setBlocked(b.data.data)
|
||||
setEvents(e.data.data.items ?? e.data.data)
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const block = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try { await api.post('/whm/security/blocked-ips', { ip: newIp, reason }); setNewIp(''); setReason(''); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
}
|
||||
|
||||
const unblock = async (id: number) => {
|
||||
try { await api.delete(`/whm/security/blocked-ips/${id}`); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
}
|
||||
|
||||
const resolve = async (id: number) => {
|
||||
try { await api.post(`/whm/security/events/${id}/resolve`); fetch() }
|
||||
catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div><h1 className="text-white text-xl font-semibold">Security</h1><p className="text-[#64748b] text-sm">IP blocking, events, and Fail2Ban</p></div>
|
||||
|
||||
{/* Block IP */}
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
|
||||
<h2 className="text-white text-sm font-medium mb-3">Block IP Address</h2>
|
||||
<form onSubmit={block} className="flex gap-3">
|
||||
<input value={newIp} onChange={e => setNewIp(e.target.value)} placeholder="192.168.1.1" required
|
||||
className="bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 w-48 focus:outline-none focus:border-indigo-500" />
|
||||
<input value={reason} onChange={e => setReason(e.target.value)} placeholder="Reason (optional)"
|
||||
className="bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 flex-1 focus:outline-none focus:border-indigo-500" />
|
||||
<button type="submit" className="flex items-center gap-2 bg-red-600/20 hover:bg-red-600/30 text-red-400 border border-red-500/30 text-sm px-3 py-2 rounded-lg">
|
||||
<Plus size={14} /> Block
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Blocked IPs */}
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[#2a2d3e]"><h2 className="text-white text-sm font-medium">Blocked IPs ({blocked.length})</h2></div>
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#2a2d3e]">
|
||||
{['IP', 'Reason', 'Blocked', ''].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-2 text-xs">{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{blocked.map(b => (
|
||||
<tr key={b.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130]">
|
||||
<td className="px-4 py-2 font-mono text-red-400 text-xs">{b.ip}</td>
|
||||
<td className="px-4 py-2 text-[#64748b] text-xs">{b.reason ?? '—'}</td>
|
||||
<td className="px-4 py-2 text-[#64748b] text-xs">{new Date(b.createdAt).toLocaleDateString()}</td>
|
||||
<td className="px-4 py-2"><button onClick={() => unblock(b.id)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{blocked.length === 0 && <tr><td colSpan={4} className="text-center text-[#64748b] py-6 text-sm">No blocked IPs</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Security Events */}
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[#2a2d3e]"><h2 className="text-white text-sm font-medium">Security Events</h2></div>
|
||||
<div className="divide-y divide-[#2a2d3e]">
|
||||
{events.slice(0, 20).map(ev => (
|
||||
<div key={ev.id} className={`flex items-start gap-3 px-4 py-3 hover:bg-[#1e2130] ${ev.resolved ? 'opacity-50' : ''}`}>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border mt-0.5 flex-shrink-0 ${severityColor[ev.severity] ?? ''}`}>{ev.severity}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[#94a3b8] text-xs">{ev.message}</p>
|
||||
<p className="text-[#64748b] text-xs mt-0.5">{ev.sourceIp && <span className="font-mono">{ev.sourceIp} · </span>}{new Date(ev.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
{!ev.resolved && (
|
||||
<button onClick={() => resolve(ev.id)} className="flex-shrink-0 p-1 text-[#64748b] hover:text-emerald-400 rounded"><CheckCircle size={14} /></button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{events.length === 0 && <div className="text-center text-[#64748b] py-6 text-sm">No security events</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Play, Square, RefreshCw, AlertCircle, CheckCircle, MinusCircle } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface Service { name: string; status: 'running' | 'stopped' | 'failed' | 'unknown'; enabled: boolean }
|
||||
|
||||
const statusIcon = {
|
||||
running: <CheckCircle size={14} className="text-emerald-400" />,
|
||||
stopped: <MinusCircle size={14} className="text-[#64748b]" />,
|
||||
failed: <AlertCircle size={14} className="text-red-400" />,
|
||||
unknown: <MinusCircle size={14} className="text-[#64748b]" />,
|
||||
}
|
||||
|
||||
export default function Services() {
|
||||
const [services, setServices] = useState<Service[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [acting, setActing] = useState<string | null>(null)
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try { const r = await api.get('/whm/services'); setServices(r.data.data) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const act = async (name: string, action: string) => {
|
||||
setActing(name + action)
|
||||
try { await api.post(`/whm/services/${name}`, { action }); fetch() }
|
||||
catch (e: unknown) { alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
finally { setActing(null) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">Services</h1><p className="text-[#64748b] text-sm">Manage system services</p></div>
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg">
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#2a2d3e]">
|
||||
{['Service', 'Status', 'Startup', 'Actions'].map(h => (
|
||||
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
|
||||
))}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{services.map(s => (
|
||||
<tr key={s.name} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130]">
|
||||
<td className="px-4 py-3 text-white font-mono text-xs">{s.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{statusIcon[s.status]}
|
||||
<span className={s.status === 'running' ? 'text-emerald-400' : s.status === 'failed' ? 'text-red-400' : 'text-[#64748b]'}>
|
||||
{s.status}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border ${s.enabled ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' : 'bg-[#1e2130] text-[#64748b] border-[#2a2d3e]'}`}>
|
||||
{s.enabled ? 'enabled' : 'disabled'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1">
|
||||
{s.status !== 'running' && (
|
||||
<button onClick={() => act(s.name, 'start')} disabled={!!acting} className="px-2 py-1 text-xs bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 rounded hover:bg-emerald-500/20">
|
||||
{acting === s.name + 'start' ? '...' : 'Start'}
|
||||
</button>
|
||||
)}
|
||||
{s.status === 'running' && (
|
||||
<button onClick={() => act(s.name, 'stop')} disabled={!!acting} className="px-2 py-1 text-xs bg-red-500/10 text-red-400 border border-red-500/20 rounded hover:bg-red-500/20">
|
||||
{acting === s.name + 'stop' ? '...' : 'Stop'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => act(s.name, 'restart')} disabled={!!acting} className="px-2 py-1 text-xs bg-[#1e2130] text-[#94a3b8] border border-[#2a2d3e] rounded hover:border-indigo-500">
|
||||
{acting === s.name + 'restart' ? '...' : 'Restart'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && services.length === 0 && (
|
||||
<tr><td colSpan={4} className="text-center text-[#64748b] py-8">No services found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Save, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
export default function Settings() {
|
||||
const [settings, setSettings] = useState<Record<string, string>>({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try { const r = await api.get('/whm/system/settings'); setSettings(r.data.data) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true)
|
||||
try { await api.put('/whm/system/settings', settings); setSaved(true); setTimeout(() => setSaved(false), 2000) }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">Settings</h1><p className="text-[#64748b] text-sm">System-wide configuration</p></div>
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={save} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-6 space-y-3">
|
||||
{Object.entries(settings).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-4">
|
||||
<label className="text-[#94a3b8] text-xs font-mono w-56 flex-shrink-0">{key}</label>
|
||||
<input
|
||||
value={value}
|
||||
onChange={e => setSettings(s => ({ ...s, [key]: e.target.value }))}
|
||||
className="flex-1 bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-4">
|
||||
<button type="submit" disabled={saving} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm px-4 py-2 rounded-lg">
|
||||
<Save size={14} /> {saving ? 'Saving...' : saved ? 'Saved!' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface SystemInfo { hostname: string; os: string; kernel: string; uptime: string }
|
||||
interface Process { user: string; pid: string; cpu: string; mem: string; command: string }
|
||||
|
||||
export default function System() {
|
||||
const [info, setInfo] = useState<SystemInfo | null>(null)
|
||||
const [processes, setProcesses] = useState<Process[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [infoR, procR] = await Promise.all([
|
||||
api.get('/whm/system/info'),
|
||||
api.get('/whm/system/processes'),
|
||||
])
|
||||
setInfo(infoR.data.data)
|
||||
setProcesses(procR.data.data)
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">System</h1><p className="text-[#64748b] text-sm">Server information and processes</p></div>
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg">
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{info && (
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[['Hostname', info.hostname], ['OS', info.os], ['Kernel', info.kernel], ['Uptime', info.uptime]].map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<p className="text-[#64748b] text-xs mb-1">{k}</p>
|
||||
<p className="text-white text-sm font-mono">{v}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[#2a2d3e]">
|
||||
<h2 className="text-white text-sm font-medium">Top Processes</h2>
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="border-b border-[#2a2d3e]">
|
||||
{['User', 'PID', 'CPU%', 'MEM%', 'Command'].map(h => (
|
||||
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-2">{h}</th>
|
||||
))}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{processes.slice(0, 25).map((p, i) => (
|
||||
<tr key={i} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130]">
|
||||
<td className="px-4 py-2 text-[#94a3b8]">{p.user}</td>
|
||||
<td className="px-4 py-2 font-mono text-[#64748b]">{p.pid}</td>
|
||||
<td className="px-4 py-2 text-amber-400">{p.cpu}</td>
|
||||
<td className="px-4 py-2 text-blue-400">{p.mem}</td>
|
||||
<td className="px-4 py-2 text-[#64748b] max-w-xs truncate font-mono">{p.command}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Server, RefreshCw, RotateCcw, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
export default function WebServer() {
|
||||
const [status, setStatus] = useState<{ nginx: { active: boolean }; apache: { active: boolean } } | null>(null)
|
||||
const [vhosts, setVhosts] = useState<{ domain: string; path: string }[]>([])
|
||||
const [logs, setLogs] = useState<{ lines: string[] } | null>(null)
|
||||
const [logType, setLogType] = useState<'access' | 'error'>('access')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [testResult, setTestResult] = useState<string | null>(null)
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [s, v, l] = await Promise.all([
|
||||
api.get('/whm/webserver/status'),
|
||||
api.get('/whm/webserver/vhosts'),
|
||||
api.get(`/whm/webserver/logs/${logType}`),
|
||||
])
|
||||
setStatus(s.data.data); setVhosts(v.data.data); setLogs(l.data.data)
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [logType])
|
||||
|
||||
const testConfig = async () => {
|
||||
const r = await api.post('/whm/webserver/nginx/test')
|
||||
setTestResult(r.data.data.output)
|
||||
}
|
||||
|
||||
const reload = async () => {
|
||||
try { await api.post('/whm/webserver/nginx/reload'); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Reload failed') }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">Web Server</h1><p className="text-[#64748b] text-sm">Nginx and Apache management</p></div>
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[['nginx', status.nginx.active], ['apache2', status.apache.active]].map(([name, active]) => (
|
||||
<div key={String(name)} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server size={16} className="text-[#64748b]" />
|
||||
<span className="text-white font-mono text-sm">{name}</span>
|
||||
</div>
|
||||
{active ? <CheckCircle size={16} className="text-emerald-400" /> : <XCircle size={16} className="text-red-400" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button onClick={testConfig} className="flex items-center gap-2 border border-[#2a2d3e] text-[#94a3b8] text-sm px-3 py-2 rounded-lg hover:bg-[#1e2130]">Test Config</button>
|
||||
<button onClick={reload} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-3 py-2 rounded-lg"><RotateCcw size={14} /> Reload nginx</button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<pre className="bg-[#0f1117] border border-[#2a2d3e] rounded-xl p-4 text-xs text-[#94a3b8] overflow-x-auto">{testResult}</pre>
|
||||
)}
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[#2a2d3e]">
|
||||
<h2 className="text-white text-sm font-medium">Logs</h2>
|
||||
<div className="flex gap-1">
|
||||
{(['access', 'error'] as const).map(t => (
|
||||
<button key={t} onClick={() => setLogType(t)} className={`px-3 py-1 text-xs rounded ${logType === t ? 'bg-indigo-600 text-white' : 'text-[#64748b] hover:text-white'}`}>{t}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<pre className="p-4 text-xs text-[#64748b] font-mono leading-relaxed">
|
||||
{logs?.lines.slice(-100).join('\n') ?? 'Loading...'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#0f1117',
|
||||
secondary: '#1a1d27',
|
||||
card: '#1e2130',
|
||||
border: '#2a2d3e',
|
||||
accent: '#6366f1',
|
||||
'accent-hover': '#4f46e5',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] }
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(__dirname, './src') },
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:2087', changeOrigin: true },
|
||||
'/ws': { target: 'ws://localhost:2087', ws: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user