Initial NixPanel Build

This commit is contained in:
NixReaper
2026-04-05 10:45:21 -05:00
parent 9761e99f31
commit 16245b2d4b
94 changed files with 9228 additions and 0 deletions
+39
View File
@@ -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"
}
}
+438
View File
@@ -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")
}
+126
View File
@@ -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
}
+59
View File
@@ -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
+125
View File
@@ -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'])
}
+172
View File
@@ -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)
}
+138
View File
@@ -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'])
}
+132
View File
@@ -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',
]
+15
View File
@@ -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
+103
View File
@@ -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())
+39
View File
@@ -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()
+41
View File
@@ -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')
+250
View File
@@ -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' } })
})
}
+132
View File
@@ -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])
}
+157
View File
@@ -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' } })
})
}
+197
View File
@@ -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' } })
})
}
+125
View File
@@ -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' } })
})
}
+252
View File
@@ -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 } })
})
}
+101
View File
@@ -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' } })
})
}
+18
View File
@@ -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' })
}
+78
View File
@@ -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) },
})
})
}
+418
View File
@@ -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 })
})
}
+116
View File
@@ -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}` } })
})
}
+144
View File
@@ -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])
}
+145
View File
@@ -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' } })
})
}
+28
View File
@@ -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' })
}
+96
View File
@@ -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' } })
})
}
+116
View File
@@ -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 } })
})
}
+147
View File
@@ -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' } })
})
}
+124
View File
@@ -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 } })
})
}
+48
View File
@@ -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 } })
})
}
+145
View File
@@ -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' } })
})
}
+124
View File
@@ -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' } })
})
}
+74
View File
@@ -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) } })
})
}
+69
View File
@@ -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
}
+24
View File
@@ -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"]
}