Initial NixPanel Build
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { config } from '../../config.js'
|
||||
import type { JwtPayload, Role } from '../../types/index.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
|
||||
const LoginSchema = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
password: z.string().min(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* Generate access + refresh token pair.
|
||||
*/
|
||||
function signTokens(
|
||||
fastify: FastifyInstance,
|
||||
sub: number,
|
||||
role: Role,
|
||||
username: string
|
||||
) {
|
||||
const payload: JwtPayload = { sub, role, username }
|
||||
|
||||
const accessToken = fastify.jwt.sign(payload, {
|
||||
expiresIn: config.jwt.expiresIn,
|
||||
})
|
||||
|
||||
const refreshToken = fastify.jwt.sign(
|
||||
{ ...payload, type: 'refresh' },
|
||||
{ secret: config.jwt.refreshSecret, expiresIn: config.jwt.refreshExpiresIn }
|
||||
)
|
||||
|
||||
return { accessToken, refreshToken }
|
||||
}
|
||||
|
||||
export default async function authRoutes(fastify: FastifyInstance) {
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Login for admin, reseller, or cPanel user.
|
||||
* Body: { username, password }
|
||||
* Returns: { accessToken, refreshToken, role, user }
|
||||
*/
|
||||
fastify.post('/login', async (request, reply) => {
|
||||
const body = LoginSchema.safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid request body' })
|
||||
}
|
||||
|
||||
const { username, password } = body.data
|
||||
|
||||
// Try admin first
|
||||
const admin = await prisma.adminUser.findUnique({ where: { username } })
|
||||
if (admin) {
|
||||
const valid = await bcrypt.compare(password, admin.passwordHash)
|
||||
if (!valid) {
|
||||
return reply.code(401).send({ success: false, error: 'Invalid credentials' })
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = signTokens(fastify, admin.id, 'admin', admin.username)
|
||||
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 7)
|
||||
await prisma.refreshToken.create({
|
||||
data: { token: refreshToken, role: 'admin', adminId: admin.id, expiresAt },
|
||||
})
|
||||
|
||||
await prisma.adminUser.update({
|
||||
where: { id: admin.id },
|
||||
data: { lastLogin: new Date(), lastLoginIp: request.ip },
|
||||
})
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
role: 'admin',
|
||||
user: { id: admin.id, username: admin.username, email: admin.email },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Try reseller
|
||||
const reseller = await prisma.reseller.findUnique({ where: { username } })
|
||||
if (reseller) {
|
||||
if (reseller.status !== 'active') {
|
||||
return reply.code(403).send({ success: false, error: 'Account suspended' })
|
||||
}
|
||||
const valid = await bcrypt.compare(password, reseller.passwordHash)
|
||||
if (!valid) {
|
||||
return reply.code(401).send({ success: false, error: 'Invalid credentials' })
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = signTokens(fastify, reseller.id, 'reseller', reseller.username)
|
||||
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 7)
|
||||
await prisma.refreshToken.create({
|
||||
data: { token: refreshToken, role: 'reseller', resellerId: reseller.id, expiresAt },
|
||||
})
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
role: 'reseller',
|
||||
user: { id: reseller.id, username: reseller.username, email: reseller.email },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Try hosting account (cPanel user)
|
||||
const account = await prisma.account.findUnique({ where: { username } })
|
||||
if (account) {
|
||||
if (account.status !== 'active') {
|
||||
return reply.code(403).send({ success: false, error: 'Account suspended' })
|
||||
}
|
||||
const valid = await bcrypt.compare(password, account.passwordHash)
|
||||
if (!valid) {
|
||||
return reply.code(401).send({ success: false, error: 'Invalid credentials' })
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = signTokens(fastify, account.id, 'user', account.username)
|
||||
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 7)
|
||||
await prisma.refreshToken.create({
|
||||
data: { token: refreshToken, role: 'user', accountId: account.id, expiresAt },
|
||||
})
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
role: 'user',
|
||||
user: { id: account.id, username: account.username, email: account.email, domain: account.domain },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return reply.code(401).send({ success: false, error: 'Invalid credentials' })
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/auth/refresh
|
||||
* Exchange a refresh token for a new access token.
|
||||
*/
|
||||
fastify.post('/refresh', async (request, reply) => {
|
||||
const body = z.object({ refreshToken: z.string() }).safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: 'refreshToken required' })
|
||||
}
|
||||
|
||||
let payload: JwtPayload & { type?: string }
|
||||
try {
|
||||
payload = fastify.jwt.verify<JwtPayload & { type?: string }>(
|
||||
body.data.refreshToken,
|
||||
{ secret: config.jwt.refreshSecret }
|
||||
)
|
||||
} catch {
|
||||
return reply.code(401).send({ success: false, error: 'Invalid or expired refresh token' })
|
||||
}
|
||||
|
||||
if (payload.type !== 'refresh') {
|
||||
return reply.code(401).send({ success: false, error: 'Not a refresh token' })
|
||||
}
|
||||
|
||||
const stored = await prisma.refreshToken.findUnique({ where: { token: body.data.refreshToken } })
|
||||
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
|
||||
return reply.code(401).send({ success: false, error: 'Refresh token revoked or expired' })
|
||||
}
|
||||
|
||||
// Rotate: revoke old, issue new
|
||||
await prisma.refreshToken.update({
|
||||
where: { id: stored.id },
|
||||
data: { revokedAt: new Date() },
|
||||
})
|
||||
|
||||
const newPayload: JwtPayload = { sub: payload.sub, role: payload.role as Role, username: payload.username }
|
||||
const accessToken = fastify.jwt.sign(newPayload, { expiresIn: config.jwt.expiresIn })
|
||||
const newRefresh = fastify.jwt.sign(
|
||||
{ ...newPayload, type: 'refresh' },
|
||||
{ secret: config.jwt.refreshSecret, expiresIn: config.jwt.refreshExpiresIn }
|
||||
)
|
||||
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 7)
|
||||
|
||||
const createData: Parameters<typeof prisma.refreshToken.create>[0]['data'] = {
|
||||
token: newRefresh,
|
||||
role: payload.role,
|
||||
expiresAt,
|
||||
}
|
||||
if (stored.adminId) createData.adminId = stored.adminId
|
||||
if (stored.resellerId) createData.resellerId = stored.resellerId
|
||||
if (stored.accountId) createData.accountId = stored.accountId
|
||||
|
||||
await prisma.refreshToken.create({ data: createData })
|
||||
|
||||
return reply.send({ success: true, data: { accessToken, refreshToken: newRefresh } })
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Revoke the refresh token.
|
||||
*/
|
||||
fastify.post('/logout', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const body = z.object({ refreshToken: z.string() }).safeParse(request.body)
|
||||
if (body.success) {
|
||||
await prisma.refreshToken.updateMany({
|
||||
where: { token: body.data.refreshToken, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
})
|
||||
}
|
||||
return reply.send({ success: true, data: null })
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Return the current authenticated user's profile.
|
||||
*/
|
||||
fastify.get('/me', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
|
||||
if (user.role === 'admin') {
|
||||
const admin = await prisma.adminUser.findUnique({
|
||||
where: { id: user.sub },
|
||||
select: { id: true, username: true, email: true, lastLogin: true, twoFactorEnabled: true },
|
||||
})
|
||||
return reply.send({ success: true, data: { ...admin, role: 'admin' } })
|
||||
}
|
||||
|
||||
if (user.role === 'reseller') {
|
||||
const reseller = await prisma.reseller.findUnique({
|
||||
where: { id: user.sub },
|
||||
select: { id: true, username: true, email: true, company: true, status: true },
|
||||
})
|
||||
return reply.send({ success: true, data: { ...reseller, role: 'reseller' } })
|
||||
}
|
||||
|
||||
const account = await prisma.account.findUnique({
|
||||
where: { id: user.sub },
|
||||
select: { id: true, username: true, email: true, domain: true, status: true },
|
||||
})
|
||||
return reply.send({ success: true, data: { ...account, role: 'user' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec, writeFile } from '../../core/exec.js'
|
||||
|
||||
// Basic cron schedule validation
|
||||
function isValidCronSchedule(schedule: string): boolean {
|
||||
const parts = schedule.trim().split(/\s+/)
|
||||
if (parts.length !== 5) return false
|
||||
const fieldPattern = /^(\*|(\d+(-\d+)?(,\d+(-\d+)?)*)(\/\d+)?|\*\/\d+)$/
|
||||
return parts.every(p => fieldPattern.test(p))
|
||||
}
|
||||
|
||||
export default async function cronRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const jobs = await prisma.cronJob.findMany({ where: { accountId }, orderBy: { createdAt: 'desc' } })
|
||||
return reply.send({ success: true, data: jobs })
|
||||
})
|
||||
|
||||
fastify.post('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, number>).accountId
|
||||
|
||||
const body = z.object({
|
||||
schedule: z.string().min(9),
|
||||
command: z.string().min(1).max(1000),
|
||||
comment: z.string().max(255).optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
if (!isValidCronSchedule(body.data.schedule)) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid cron schedule' })
|
||||
}
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId }, include: { package: true } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Check quota
|
||||
if (account.package && account.package.maxCronJobs > 0) {
|
||||
const count = await prisma.cronJob.count({ where: { accountId } })
|
||||
if (count >= account.package.maxCronJobs) {
|
||||
return reply.code(429).send({ success: false, error: 'Cron job limit reached' })
|
||||
}
|
||||
}
|
||||
|
||||
const job = await prisma.cronJob.create({
|
||||
data: { ...body.data, accountId },
|
||||
})
|
||||
|
||||
await writeCrontab(account.username, accountId)
|
||||
|
||||
return reply.code(201).send({ success: true, data: job })
|
||||
})
|
||||
|
||||
fastify.put('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const body = z.object({
|
||||
schedule: z.string().optional(),
|
||||
command: z.string().min(1).max(1000).optional(),
|
||||
comment: z.string().max(255).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
if (body.data.schedule && !isValidCronSchedule(body.data.schedule)) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid cron schedule' })
|
||||
}
|
||||
|
||||
const job = await prisma.cronJob.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
include: { account: true },
|
||||
})
|
||||
if (!job) return reply.code(404).send({ success: false, error: 'Cron job not found' })
|
||||
|
||||
const updated = await prisma.cronJob.update({
|
||||
where: { id: job.id },
|
||||
data: body.data,
|
||||
})
|
||||
|
||||
await writeCrontab(job.account.username, job.accountId)
|
||||
|
||||
return reply.send({ success: true, data: updated })
|
||||
})
|
||||
|
||||
fastify.delete('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const job = await prisma.cronJob.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
include: { account: true },
|
||||
})
|
||||
if (!job) return reply.code(404).send({ success: false, error: 'Cron job not found' })
|
||||
|
||||
await prisma.cronJob.delete({ where: { id: job.id } })
|
||||
await writeCrontab(job.account.username, job.accountId)
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Cron job deleted' } })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate and install the crontab for a user.
|
||||
*/
|
||||
async function writeCrontab(username: string, accountId: number): Promise<void> {
|
||||
const jobs = await prisma.cronJob.findMany({
|
||||
where: { accountId, enabled: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
const lines = [
|
||||
`# NixPanel crontab for ${username}`,
|
||||
`# DO NOT EDIT — managed by NixPanel`,
|
||||
'',
|
||||
...jobs.map(j => `${j.schedule} ${j.command}${j.comment ? ` # ${j.comment}` : ''}`),
|
||||
'',
|
||||
]
|
||||
|
||||
const crontabPath = `/var/spool/cron/crontabs/${username}`
|
||||
await writeFile(crontabPath, lines.join('\n'))
|
||||
await exec('chown', [`${username}:crontab`, crontabPath])
|
||||
await exec('chmod', ['600', crontabPath])
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
|
||||
function mysqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "\\'").replace(/\\/g, '\\\\')
|
||||
}
|
||||
|
||||
export default async function databaseRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
// GET /api/cpanel/databases
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const databases = await prisma.database.findMany({
|
||||
where: { accountId },
|
||||
include: { dbUsers: { select: { id: true, username: true, host: true, privileges: true } } },
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
return reply.send({ success: true, data: databases })
|
||||
})
|
||||
|
||||
// POST /api/cpanel/databases
|
||||
fastify.post('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.body as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const body = z.object({
|
||||
name: z.string().min(1).max(32).regex(/^[a-zA-Z0-9_]+$/),
|
||||
charset: z.string().default('utf8mb4'),
|
||||
collation: z.string().default('utf8mb4_unicode_ci'),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const account = await prisma.account.findUnique({
|
||||
where: { id: accountId },
|
||||
include: { package: true },
|
||||
})
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Check quota
|
||||
if (account.package && account.package.maxDatabases > 0) {
|
||||
const count = await prisma.database.count({ where: { accountId } })
|
||||
if (count >= account.package.maxDatabases) {
|
||||
return reply.code(429).send({ success: false, error: 'Database limit reached' })
|
||||
}
|
||||
}
|
||||
|
||||
// Prefix database name with username to avoid conflicts
|
||||
const dbName = `${account.username}_${body.data.name}`
|
||||
const exists = await prisma.database.findUnique({ where: { name: dbName } })
|
||||
if (exists) return reply.code(409).send({ success: false, error: 'Database already exists' })
|
||||
|
||||
// Create the actual MariaDB database
|
||||
const result = await exec('mysql', [
|
||||
'-e',
|
||||
`CREATE DATABASE \`${mysqlEscape(dbName)}\` CHARACTER SET ${mysqlEscape(body.data.charset)} COLLATE ${mysqlEscape(body.data.collation)};`,
|
||||
])
|
||||
if (result.exitCode !== 0 && !process.env.NODE_ENV?.startsWith('dev')) {
|
||||
return reply.code(500).send({ success: false, error: `Failed to create database: ${result.stderr}` })
|
||||
}
|
||||
|
||||
const db = await prisma.database.create({
|
||||
data: { name: dbName, charset: body.data.charset, collation: body.data.collation, accountId },
|
||||
})
|
||||
return reply.code(201).send({ success: true, data: db })
|
||||
})
|
||||
|
||||
// DELETE /api/cpanel/databases/:id
|
||||
fastify.delete('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const db = await prisma.database.findFirst({
|
||||
where: {
|
||||
id: parseInt(id, 10),
|
||||
...(user.role === 'user' ? { accountId: user.sub } : {}),
|
||||
},
|
||||
})
|
||||
if (!db) return reply.code(404).send({ success: false, error: 'Database not found' })
|
||||
|
||||
await exec('mysql', ['-e', `DROP DATABASE IF EXISTS \`${mysqlEscape(db.name)}\`;`])
|
||||
await prisma.database.delete({ where: { id: db.id } })
|
||||
|
||||
return reply.send({ success: true, data: { message: `Database ${db.name} deleted` } })
|
||||
})
|
||||
|
||||
// POST /api/cpanel/databases/:id/users
|
||||
fastify.post('/:id/users', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const body = z.object({
|
||||
username: z.string().min(1).max(16).regex(/^[a-zA-Z0-9_]+$/),
|
||||
password: z.string().min(8),
|
||||
privileges: z.string().default('ALL'),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const db = await prisma.database.findFirst({
|
||||
where: {
|
||||
id: parseInt(id, 10),
|
||||
...(user.role === 'user' ? { accountId: user.sub } : {}),
|
||||
},
|
||||
include: { account: true },
|
||||
})
|
||||
if (!db) return reply.code(404).send({ success: false, error: 'Database not found' })
|
||||
|
||||
const dbUsername = `${db.account.username}_${body.data.username}`
|
||||
const escapedPass = mysqlEscape(body.data.password)
|
||||
|
||||
// Create MariaDB user and grant privileges
|
||||
await exec('mysql', ['-e', `CREATE USER '${mysqlEscape(dbUsername)}'@'localhost' IDENTIFIED BY '${escapedPass}';`])
|
||||
await exec('mysql', ['-e', `GRANT ${mysqlEscape(body.data.privileges)} ON \`${mysqlEscape(db.name)}\`.* TO '${mysqlEscape(dbUsername)}'@'localhost';`])
|
||||
await exec('mysql', ['-e', 'FLUSH PRIVILEGES;'])
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
const dbUser = await prisma.databaseUser.create({
|
||||
data: {
|
||||
username: dbUsername,
|
||||
passwordHash,
|
||||
privileges: body.data.privileges,
|
||||
databaseId: db.id,
|
||||
},
|
||||
})
|
||||
|
||||
const { passwordHash: _, ...safe } = dbUser
|
||||
return reply.code(201).send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
// DELETE /api/cpanel/databases/:id/users/:userId
|
||||
fastify.delete('/:id/users/:userId', { preHandler }, async (request, reply) => {
|
||||
const { id, userId } = request.params as { id: string; userId: string }
|
||||
|
||||
const dbUser = await prisma.databaseUser.findFirst({
|
||||
where: { id: parseInt(userId, 10), databaseId: parseInt(id, 10) },
|
||||
})
|
||||
if (!dbUser) return reply.code(404).send({ success: false, error: 'Database user not found' })
|
||||
|
||||
await exec('mysql', ['-e', `DROP USER IF EXISTS '${mysqlEscape(dbUser.username)}'@'localhost';`])
|
||||
await exec('mysql', ['-e', 'FLUSH PRIVILEGES;'])
|
||||
await prisma.databaseUser.delete({ where: { id: dbUser.id } })
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Database user deleted' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { validateDomain, exec } from '../../core/exec.js'
|
||||
import { enableVhost } from '../../core/nginx.js'
|
||||
|
||||
export default async function domainRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
// ─── Subdomains ──────────────────────────────────────────────────────────────
|
||||
|
||||
fastify.get('/subdomains', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
const subdomains = await prisma.subdomain.findMany({ where: { accountId }, orderBy: { fqdn: 'asc' } })
|
||||
return reply.send({ success: true, data: subdomains })
|
||||
})
|
||||
|
||||
fastify.post('/subdomains', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, number>).accountId
|
||||
|
||||
const body = z.object({
|
||||
subdomain: z.string().min(1).max(63).regex(/^[a-zA-Z0-9-]+$/),
|
||||
documentRoot: z.string().optional(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Check quota
|
||||
const pkg = account.packageId ? await prisma.package.findUnique({ where: { id: account.packageId } }) : null
|
||||
if (pkg && pkg.maxSubdomains > 0) {
|
||||
const count = await prisma.subdomain.count({ where: { accountId } })
|
||||
if (count >= pkg.maxSubdomains) return reply.code(429).send({ success: false, error: 'Subdomain limit reached' })
|
||||
}
|
||||
|
||||
const fqdn = `${body.data.subdomain}.${account.domain}`
|
||||
const existing = await prisma.subdomain.findUnique({ where: { fqdn } })
|
||||
if (existing) return reply.code(409).send({ success: false, error: 'Subdomain already exists' })
|
||||
|
||||
const docRoot = body.data.documentRoot ?? `${account.homedir}/public_html/${body.data.subdomain}`
|
||||
|
||||
// Create doc root
|
||||
await exec('mkdir', ['-p', docRoot])
|
||||
await exec('chown', [`${account.username}:${account.username}`, docRoot])
|
||||
|
||||
// Create nginx vhost for subdomain
|
||||
try {
|
||||
await enableVhost({ domain: fqdn, username: account.username, documentRoot: docRoot })
|
||||
} catch (e) {
|
||||
console.warn(`Nginx vhost for subdomain ${fqdn} failed:`, e)
|
||||
}
|
||||
|
||||
const subdomain = await prisma.subdomain.create({
|
||||
data: { subdomain: body.data.subdomain, domain: account.domain, fqdn, documentRoot: docRoot, accountId },
|
||||
})
|
||||
return reply.code(201).send({ success: true, data: subdomain })
|
||||
})
|
||||
|
||||
fastify.delete('/subdomains/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const subdomain = await prisma.subdomain.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
})
|
||||
if (!subdomain) return reply.code(404).send({ success: false, error: 'Subdomain not found' })
|
||||
|
||||
const { disableVhost } = await import('../../core/nginx.js')
|
||||
try { await disableVhost(subdomain.fqdn) } catch {}
|
||||
|
||||
await prisma.subdomain.delete({ where: { id: subdomain.id } })
|
||||
return reply.send({ success: true, data: { message: 'Subdomain deleted' } })
|
||||
})
|
||||
|
||||
// ─── Addon Domains ───────────────────────────────────────────────────────────
|
||||
|
||||
fastify.get('/addon', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
const domains = await prisma.addonDomain.findMany({ where: { accountId }, orderBy: { domain: 'asc' } })
|
||||
return reply.send({ success: true, data: domains })
|
||||
})
|
||||
|
||||
fastify.post('/addon', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, number>).accountId
|
||||
|
||||
const body = z.object({
|
||||
domain: z.string(),
|
||||
subdomain: z.string().optional(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
if (!validateDomain(body.data.domain)) return reply.code(400).send({ success: false, error: 'Invalid domain' })
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const existing = await prisma.addonDomain.findUnique({ where: { domain: body.data.domain } })
|
||||
if (existing) return reply.code(409).send({ success: false, error: 'Addon domain already exists' })
|
||||
|
||||
const subdomain = body.data.subdomain ?? body.data.domain.replace(/\./g, '_')
|
||||
const docRoot = `${account.homedir}/public_html/${subdomain}`
|
||||
|
||||
await exec('mkdir', ['-p', docRoot])
|
||||
await exec('chown', [`${account.username}:${account.username}`, docRoot])
|
||||
|
||||
try {
|
||||
await enableVhost({ domain: body.data.domain, username: account.username, documentRoot: docRoot })
|
||||
} catch (e) {
|
||||
console.warn(`Nginx vhost for addon domain ${body.data.domain} failed:`, e)
|
||||
}
|
||||
|
||||
const addon = await prisma.addonDomain.create({
|
||||
data: { domain: body.data.domain, subdomain, documentRoot: docRoot, accountId },
|
||||
})
|
||||
return reply.code(201).send({ success: true, data: addon })
|
||||
})
|
||||
|
||||
fastify.delete('/addon/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const addon = await prisma.addonDomain.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
})
|
||||
if (!addon) return reply.code(404).send({ success: false, error: 'Addon domain not found' })
|
||||
|
||||
const { disableVhost } = await import('../../core/nginx.js')
|
||||
try { await disableVhost(addon.domain) } catch {}
|
||||
|
||||
await prisma.addonDomain.delete({ where: { id: addon.id } })
|
||||
return reply.send({ success: true, data: { message: 'Addon domain deleted' } })
|
||||
})
|
||||
|
||||
// ─── Parked Domains ──────────────────────────────────────────────────────────
|
||||
|
||||
fastify.get('/parked', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
const domains = await prisma.parkedDomain.findMany({ where: { accountId }, orderBy: { domain: 'asc' } })
|
||||
return reply.send({ success: true, data: domains })
|
||||
})
|
||||
|
||||
fastify.post('/parked', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, number>).accountId
|
||||
|
||||
const body = z.object({ domain: z.string() }).safeParse(request.body)
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
if (!validateDomain(body.data.domain)) return reply.code(400).send({ success: false, error: 'Invalid domain' })
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const existing = await prisma.parkedDomain.findUnique({ where: { domain: body.data.domain } })
|
||||
if (existing) return reply.code(409).send({ success: false, error: 'Parked domain already exists' })
|
||||
|
||||
// Parked domains point to main public_html
|
||||
try {
|
||||
await enableVhost({
|
||||
domain: body.data.domain,
|
||||
username: account.username,
|
||||
documentRoot: `${account.homedir}/public_html`,
|
||||
aliases: [account.domain],
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn(`Nginx vhost for parked domain ${body.data.domain} failed:`, e)
|
||||
}
|
||||
|
||||
const parked = await prisma.parkedDomain.create({
|
||||
data: { domain: body.data.domain, accountId },
|
||||
})
|
||||
return reply.code(201).send({ success: true, data: parked })
|
||||
})
|
||||
|
||||
fastify.delete('/parked/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const parked = await prisma.parkedDomain.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
})
|
||||
if (!parked) return reply.code(404).send({ success: false, error: 'Parked domain not found' })
|
||||
|
||||
const { disableVhost } = await import('../../core/nginx.js')
|
||||
try { await disableVhost(parked.domain) } catch {}
|
||||
|
||||
await prisma.parkedDomain.delete({ where: { id: parked.id } })
|
||||
return reply.send({ success: true, data: { message: 'Parked domain deleted' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
|
||||
export default async function cpanelEmailRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
// GET /api/cpanel/email
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const accounts = await prisma.emailAccount.findMany({
|
||||
where: { accountId },
|
||||
orderBy: { address: 'asc' },
|
||||
select: { id: true, address: true, username: true, domain: true, quotaMb: true, usedMb: true, status: true },
|
||||
})
|
||||
return reply.send({ success: true, data: accounts })
|
||||
})
|
||||
|
||||
// POST /api/cpanel/email
|
||||
fastify.post('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, unknown>).accountId as number
|
||||
|
||||
const body = z.object({
|
||||
username: z.string().min(1).max(64).regex(/^[a-zA-Z0-9._+-]+$/),
|
||||
password: z.string().min(8),
|
||||
quotaMb: z.number().int().min(0).default(500),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId }, include: { package: true } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
if (account.package && account.package.maxEmailAccounts > 0) {
|
||||
const count = await prisma.emailAccount.count({ where: { accountId: account.id } })
|
||||
if (count >= account.package.maxEmailAccounts) {
|
||||
return reply.code(429).send({ success: false, error: 'Email account limit reached' })
|
||||
}
|
||||
}
|
||||
|
||||
const address = `${body.data.username}@${account.domain}`
|
||||
const exists = await prisma.emailAccount.findUnique({ where: { address } })
|
||||
if (exists) return reply.code(409).send({ success: false, error: 'Email address already exists' })
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
|
||||
await exec('doveadm', ['user', 'import', '-u', address, '-p', body.data.password])
|
||||
|
||||
const emailAccount = await prisma.emailAccount.create({
|
||||
data: { username: body.data.username, domain: account.domain, address, passwordHash, quotaMb: body.data.quotaMb, accountId: account.id },
|
||||
})
|
||||
|
||||
const { passwordHash: _, ...safe } = emailAccount
|
||||
return reply.code(201).send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
// DELETE /api/cpanel/email/:id
|
||||
fastify.delete('/:id', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : 0
|
||||
const { id } = request.params as { id: string }
|
||||
|
||||
const emailAccount = await prisma.emailAccount.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!emailAccount) return reply.code(404).send({ success: false, error: 'Email account not found' })
|
||||
if (user.role === 'user' && emailAccount.accountId !== accountId) {
|
||||
return reply.code(403).send({ success: false, error: 'Forbidden' })
|
||||
}
|
||||
|
||||
await exec('doveadm', ['user', 'delete', '-u', emailAccount.address])
|
||||
await prisma.emailAccount.delete({ where: { id: emailAccount.id } })
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Email account deleted' } })
|
||||
})
|
||||
|
||||
// GET /api/cpanel/email/forwarders
|
||||
fastify.get('/forwarders', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const forwarders = await prisma.emailForwarder.findMany({ where: { accountId } })
|
||||
return reply.send({ success: true, data: forwarders })
|
||||
})
|
||||
|
||||
// POST /api/cpanel/email/forwarders
|
||||
fastify.post('/forwarders', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, unknown>).accountId as number
|
||||
|
||||
const body = z.object({
|
||||
source: z.string().email(),
|
||||
destination: z.string().email(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const forwarder = await prisma.emailForwarder.create({ data: { ...body.data, accountId } })
|
||||
return reply.code(201).send({ success: true, data: forwarder })
|
||||
})
|
||||
|
||||
// DELETE /api/cpanel/email/forwarders/:id
|
||||
fastify.delete('/forwarders/:id', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : 0
|
||||
const { id } = request.params as { id: string }
|
||||
|
||||
const forwarder = await prisma.emailForwarder.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!forwarder) return reply.code(404).send({ success: false, error: 'Forwarder not found' })
|
||||
if (user.role === 'user' && forwarder.accountId !== accountId) {
|
||||
return reply.code(403).send({ success: false, error: 'Forbidden' })
|
||||
}
|
||||
|
||||
await prisma.emailForwarder.delete({ where: { id: forwarder.id } })
|
||||
return reply.send({ success: true, data: { message: 'Forwarder deleted' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import path from 'path'
|
||||
import { stat, readdir, unlink, rename, mkdir } from 'fs/promises'
|
||||
import { createReadStream, createWriteStream } from 'fs'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
import { config } from '../../config.js'
|
||||
import prisma from '../../db/client.js'
|
||||
|
||||
/**
|
||||
* Resolve a user path, enforcing it stays inside their home directory.
|
||||
*/
|
||||
function resolveUserPath(homedir: string, userPath: string): string {
|
||||
const resolved = path.resolve(homedir, userPath.replace(/^\//, ''))
|
||||
if (!resolved.startsWith(homedir + path.sep) && resolved !== homedir) {
|
||||
throw new Error('Path traversal detected')
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
async function getAccount(fastify: FastifyInstance, userId: number) {
|
||||
return prisma.account.findUnique({ where: { id: userId } })
|
||||
}
|
||||
|
||||
export default async function fileRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
// GET /api/cpanel/files?path=
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'cPanel users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const q = request.query as Record<string, string>
|
||||
const userPath = q.path ?? '/'
|
||||
|
||||
let targetPath: string
|
||||
try {
|
||||
targetPath = resolveUserPath(account.homedir, userPath)
|
||||
} catch {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid path' })
|
||||
}
|
||||
|
||||
const entries = await readdir(targetPath, { withFileTypes: true })
|
||||
const files = await Promise.all(
|
||||
entries.map(async entry => {
|
||||
const fullPath = path.join(targetPath, entry.name)
|
||||
let size = 0
|
||||
let mtime = new Date()
|
||||
try {
|
||||
const s = await stat(fullPath)
|
||||
size = s.size
|
||||
mtime = s.mtime
|
||||
} catch {}
|
||||
return {
|
||||
name: entry.name,
|
||||
type: entry.isDirectory() ? 'directory' : 'file',
|
||||
size,
|
||||
mtime,
|
||||
path: path.join(userPath, entry.name),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
files.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: { path: userPath, files } })
|
||||
})
|
||||
|
||||
// GET /api/cpanel/files/content?path=
|
||||
fastify.get('/content', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'cPanel users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const q = request.query as Record<string, string>
|
||||
let targetPath: string
|
||||
try {
|
||||
targetPath = resolveUserPath(account.homedir, q.path ?? '')
|
||||
} catch {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid path' })
|
||||
}
|
||||
|
||||
const fileStat = await stat(targetPath)
|
||||
if (fileStat.isDirectory()) {
|
||||
return reply.code(400).send({ success: false, error: 'Path is a directory' })
|
||||
}
|
||||
if (fileStat.size > 5 * 1024 * 1024) {
|
||||
return reply.code(413).send({ success: false, error: 'File too large to edit (max 5MB)' })
|
||||
}
|
||||
|
||||
const { readFile } = await import('fs/promises')
|
||||
const content = await readFile(targetPath, 'utf8')
|
||||
return reply.send({ success: true, data: { path: q.path, content } })
|
||||
})
|
||||
|
||||
// PUT /api/cpanel/files/content — save file content
|
||||
fastify.put('/content', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'cPanel users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const body = z.object({
|
||||
path: z.string(),
|
||||
content: z.string(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
let targetPath: string
|
||||
try {
|
||||
targetPath = resolveUserPath(account.homedir, body.data.path)
|
||||
} catch {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid path' })
|
||||
}
|
||||
|
||||
const { writeFile } = await import('fs/promises')
|
||||
await writeFile(targetPath, body.data.content, 'utf8')
|
||||
await exec('chown', [`${account.username}:${account.username}`, targetPath])
|
||||
|
||||
return reply.send({ success: true, data: { message: 'File saved' } })
|
||||
})
|
||||
|
||||
// POST /api/cpanel/files/mkdir
|
||||
fastify.post('/mkdir', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'cPanel users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const body = z.object({ path: z.string() }).safeParse(request.body)
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: 'path required' })
|
||||
|
||||
let targetPath: string
|
||||
try {
|
||||
targetPath = resolveUserPath(account.homedir, body.data.path)
|
||||
} catch {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid path' })
|
||||
}
|
||||
|
||||
await mkdir(targetPath, { recursive: true })
|
||||
await exec('chown', [`${account.username}:${account.username}`, targetPath])
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Directory created' } })
|
||||
})
|
||||
|
||||
// DELETE /api/cpanel/files
|
||||
fastify.delete('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'cPanel users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const body = z.object({ paths: z.array(z.string()).min(1).max(50) }).safeParse(request.body)
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: 'paths[] required' })
|
||||
|
||||
const deleted: string[] = []
|
||||
const errors: { path: string; error: string }[] = []
|
||||
|
||||
for (const p of body.data.paths) {
|
||||
try {
|
||||
const targetPath = resolveUserPath(account.homedir, p)
|
||||
const s = await stat(targetPath)
|
||||
if (s.isDirectory()) {
|
||||
await exec('rm', ['-rf', targetPath])
|
||||
} else {
|
||||
await unlink(targetPath)
|
||||
}
|
||||
deleted.push(p)
|
||||
} catch (e) {
|
||||
errors.push({ path: p, error: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: { deleted, errors } })
|
||||
})
|
||||
|
||||
// POST /api/cpanel/files/rename
|
||||
fastify.post('/rename', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'cPanel users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const body = z.object({ from: z.string(), to: z.string() }).safeParse(request.body)
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
|
||||
const fromPath = resolveUserPath(account.homedir, body.data.from)
|
||||
const toPath = resolveUserPath(account.homedir, body.data.to)
|
||||
|
||||
await rename(fromPath, toPath)
|
||||
return reply.send({ success: true, data: { message: 'Renamed' } })
|
||||
})
|
||||
|
||||
// POST /api/cpanel/files/chmod
|
||||
fastify.post('/chmod', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'cPanel users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const body = z.object({
|
||||
path: z.string(),
|
||||
mode: z.string().regex(/^[0-7]{3,4}$/),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
|
||||
const targetPath = resolveUserPath(account.homedir, body.data.path)
|
||||
await exec('chmod', [body.data.mode, targetPath])
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Permissions updated' } })
|
||||
})
|
||||
|
||||
// POST /api/cpanel/files/compress — zip files
|
||||
fastify.post('/compress', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
if (user.role !== 'user') return reply.code(403).send({ success: false, error: 'cPanel users only' })
|
||||
|
||||
const account = await getAccount(fastify, user.sub)
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const body = z.object({
|
||||
paths: z.array(z.string()).min(1),
|
||||
dest: z.string(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
|
||||
const destPath = resolveUserPath(account.homedir, body.data.dest)
|
||||
const srcPaths = body.data.paths.map(p => resolveUserPath(account.homedir, p))
|
||||
|
||||
await exec('tar', ['-czf', destPath, ...srcPaths])
|
||||
return reply.send({ success: true, data: { message: 'Compressed', dest: body.data.dest } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec, writeFile } from '../../core/exec.js'
|
||||
|
||||
export default async function ftpRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const accounts = await prisma.ftpAccount.findMany({
|
||||
where: { accountId },
|
||||
select: { id: true, username: true, homedir: true, quotaMb: true, status: true, createdAt: true },
|
||||
orderBy: { username: 'asc' },
|
||||
})
|
||||
return reply.send({ success: true, data: accounts })
|
||||
})
|
||||
|
||||
fastify.post('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, number>).accountId
|
||||
|
||||
const body = z.object({
|
||||
username: z.string().min(1).max(32).regex(/^[a-zA-Z0-9_]+$/),
|
||||
password: z.string().min(8),
|
||||
homedir: z.string().default('/'),
|
||||
quotaMb: z.number().int().min(0).default(0),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId }, include: { package: true } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Check quota
|
||||
if (account.package && account.package.maxFtpAccounts > 0) {
|
||||
const count = await prisma.ftpAccount.count({ where: { accountId } })
|
||||
if (count >= account.package.maxFtpAccounts) {
|
||||
return reply.code(429).send({ success: false, error: 'FTP account limit reached' })
|
||||
}
|
||||
}
|
||||
|
||||
const ftpUsername = `${account.username}_${body.data.username}`
|
||||
const homedir = `${account.homedir}${body.data.homedir}`
|
||||
|
||||
// Create system user for FTP (locked shell, home in account dir)
|
||||
await exec('useradd', ['-m', '-d', homedir, '-s', '/sbin/nologin', ftpUsername])
|
||||
await exec('mkdir', ['-p', homedir])
|
||||
await exec('chown', [`${ftpUsername}:${ftpUsername}`, homedir])
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
|
||||
// Add to vsftpd virtual users list
|
||||
const vsftpdUser = `/etc/vsftpd/users/${ftpUsername}`
|
||||
await writeFile(vsftpdUser, `${ftpUsername}\n${body.data.password}\n`)
|
||||
|
||||
const ftpAccount = await prisma.ftpAccount.create({
|
||||
data: { username: ftpUsername, passwordHash, homedir, quotaMb: body.data.quotaMb, accountId },
|
||||
})
|
||||
|
||||
const { passwordHash: _, ...safe } = ftpAccount
|
||||
return reply.code(201).send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
fastify.put('/:id/password', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const body = z.object({ password: z.string().min(8) }).safeParse(request.body)
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: 'Password must be at least 8 characters' })
|
||||
|
||||
const ftpAccount = await prisma.ftpAccount.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
})
|
||||
if (!ftpAccount) return reply.code(404).send({ success: false, error: 'FTP account not found' })
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
await prisma.ftpAccount.update({ where: { id: ftpAccount.id }, data: { passwordHash } })
|
||||
|
||||
return reply.send({ success: true, data: { message: 'FTP password updated' } })
|
||||
})
|
||||
|
||||
fastify.delete('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const ftpAccount = await prisma.ftpAccount.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
})
|
||||
if (!ftpAccount) return reply.code(404).send({ success: false, error: 'FTP account not found' })
|
||||
|
||||
await exec('userdel', [ftpAccount.username])
|
||||
await prisma.ftpAccount.delete({ where: { id: ftpAccount.id } })
|
||||
|
||||
return reply.send({ success: true, data: { message: 'FTP account deleted' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import fileRoutes from './files.js'
|
||||
import databaseRoutes from './databases.js'
|
||||
import emailRoutes from './email.js'
|
||||
import domainRoutes from './domains.js'
|
||||
import ftpRoutes from './ftp.js'
|
||||
import cronRoutes from './cron.js'
|
||||
import statsRoutes from './stats.js'
|
||||
|
||||
export default async function cpanelRoutes(fastify: FastifyInstance) {
|
||||
fastify.register(fileRoutes, { prefix: '/files' })
|
||||
fastify.register(databaseRoutes, { prefix: '/databases' })
|
||||
fastify.register(emailRoutes, { prefix: '/email' })
|
||||
fastify.register(domainRoutes, { prefix: '/domains' })
|
||||
fastify.register(ftpRoutes, { prefix: '/ftp' })
|
||||
fastify.register(cronRoutes, { prefix: '/cron' })
|
||||
fastify.register(statsRoutes, { prefix: '/stats' })
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
|
||||
export default async function statsRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
// GET /api/cpanel/stats — account resource usage summary
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const account = await prisma.account.findUnique({
|
||||
where: { id: accountId },
|
||||
include: {
|
||||
package: true,
|
||||
_count: {
|
||||
select: {
|
||||
emailAccounts: true, databases: true, ftpAccounts: true,
|
||||
subdomains: true, addonDomains: true, parkedDomains: true,
|
||||
sslCertificates: true, cronJobs: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Get actual disk usage
|
||||
const duResult = await exec('du', ['-sb', account.homedir])
|
||||
const diskUsedBytes = parseInt(duResult.stdout.split('\t')[0] ?? '0', 10)
|
||||
const diskUsedMb = Math.floor(diskUsedBytes / 1024 / 1024)
|
||||
|
||||
// Update DB with current usage
|
||||
await prisma.account.update({ where: { id: accountId }, data: { diskUsedMb: BigInt(diskUsedMb) } })
|
||||
|
||||
const pkg = account.package
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
diskUsedMb,
|
||||
diskLimitMb: pkg ? Number(pkg.diskMb) : 0,
|
||||
bandwidthUsedMb: Number(account.bandwidthUsedMb),
|
||||
bandwidthLimitMb: pkg ? Number(pkg.bandwidthMb) : 0,
|
||||
counts: account._count,
|
||||
limits: pkg ? {
|
||||
maxEmailAccounts: pkg.maxEmailAccounts,
|
||||
maxDatabases: pkg.maxDatabases,
|
||||
maxFtpAccounts: pkg.maxFtpAccounts,
|
||||
maxSubdomains: pkg.maxSubdomains,
|
||||
maxAddonDomains: pkg.maxAddonDomains,
|
||||
maxParkedDomains: pkg.maxParkedDomains,
|
||||
maxCronJobs: pkg.maxCronJobs,
|
||||
} : null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/cpanel/stats/bandwidth — bandwidth usage over time (placeholder for log parsing)
|
||||
fastify.get('/bandwidth', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Read nginx access log and count bytes for this account's domain
|
||||
const logFile = `/var/log/nginx/${account.username}.access.log`
|
||||
const result = await exec('awk', ['{sum += $10} END {print sum}', logFile])
|
||||
const bytes = parseInt(result.stdout || '0', 10)
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: { domain: account.domain, bandwidthBytes: bytes, bandwidthMb: Math.floor(bytes / 1024 / 1024) },
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdminOrReseller } from '../../middleware/auth.js'
|
||||
import { exec, validateUsername, validateDomain } from '../../core/exec.js'
|
||||
import { enableVhost, disableVhost } from '../../core/nginx.js'
|
||||
import { createZone, removeZone } from '../../core/dns.js'
|
||||
import { config } from '../../config.js'
|
||||
|
||||
const CreateAccountSchema = z.object({
|
||||
username: z.string().min(1).max(32),
|
||||
password: z.string().min(8),
|
||||
email: z.string().email(),
|
||||
domain: z.string().min(4),
|
||||
packageId: z.number().int().positive().optional(),
|
||||
resellerId: z.number().int().positive().optional(),
|
||||
ipId: z.number().int().positive().optional(),
|
||||
})
|
||||
|
||||
const UpdateAccountSchema = z.object({
|
||||
email: z.string().email().optional(),
|
||||
packageId: z.number().int().positive().optional(),
|
||||
password: z.string().min(8).optional(),
|
||||
notes: z.string().max(500).optional(),
|
||||
})
|
||||
|
||||
export default async function accountRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAdminOrReseller]
|
||||
|
||||
// GET /api/whm/accounts
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const q = request.query as Record<string, string>
|
||||
const page = Math.max(1, parseInt(q.page ?? '1', 10))
|
||||
const limit = Math.min(100, Math.max(1, parseInt(q.limit ?? '25', 10)))
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const where: Record<string, unknown> = {}
|
||||
if (q.search) {
|
||||
where.OR = [
|
||||
{ username: { contains: q.search } },
|
||||
{ domain: { contains: q.search } },
|
||||
{ email: { contains: q.search } },
|
||||
]
|
||||
}
|
||||
if (q.status) where.status = q.status
|
||||
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.account.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true, username: true, domain: true, email: true, status: true,
|
||||
diskUsedMb: true, bandwidthUsedMb: true, ipAddress: true,
|
||||
createdAt: true, suspendedAt: true, suspendReason: true,
|
||||
package: { select: { name: true, diskMb: true, bandwidthMb: true } },
|
||||
reseller: { select: { username: true } },
|
||||
},
|
||||
}),
|
||||
prisma.account.count({ where }),
|
||||
])
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: { items, total, page, limit, totalPages: Math.ceil(total / limit) },
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/whm/accounts/stats
|
||||
fastify.get('/stats', { preHandler }, async (request, reply) => {
|
||||
const where = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
|
||||
|
||||
const [total, active, suspended, terminated] = await Promise.all([
|
||||
prisma.account.count({ where }),
|
||||
prisma.account.count({ where: { ...where, status: 'active' } }),
|
||||
prisma.account.count({ where: { ...where, status: 'suspended' } }),
|
||||
prisma.account.count({ where: { ...where, status: 'terminated' } }),
|
||||
])
|
||||
|
||||
return reply.send({ success: true, data: { total, active, suspended, terminated } })
|
||||
})
|
||||
|
||||
// GET /api/whm/accounts/:id
|
||||
fastify.get('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const where: Record<string, unknown> = { id: parseInt(id, 10) }
|
||||
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
|
||||
|
||||
const account = await prisma.account.findFirst({
|
||||
where,
|
||||
include: {
|
||||
package: true,
|
||||
reseller: { select: { username: true, company: true } },
|
||||
ip: true,
|
||||
dnsZones: { select: { id: true, domain: true, status: true } },
|
||||
_count: {
|
||||
select: {
|
||||
emailAccounts: true, databases: true, ftpAccounts: true,
|
||||
subdomains: true, addonDomains: true, sslCertificates: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const { passwordHash, ...safe } = account
|
||||
return reply.send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
// POST /api/whm/accounts
|
||||
fastify.post('/', { preHandler }, async (request, reply) => {
|
||||
const body = CreateAccountSchema.safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const { username, password, email, domain, packageId, resellerId, ipId } = body.data
|
||||
|
||||
if (!validateUsername(username)) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid username format' })
|
||||
}
|
||||
if (!validateDomain(domain)) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid domain format' })
|
||||
}
|
||||
|
||||
const exists = await prisma.account.findFirst({
|
||||
where: { OR: [{ username }, { domain }] },
|
||||
})
|
||||
if (exists) {
|
||||
return reply.code(409).send({ success: false, error: 'Username or domain already in use' })
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12)
|
||||
const homedir = `${config.paths.homeDir}/${username}`
|
||||
|
||||
// Create system user
|
||||
const userAdd = await exec('useradd', [
|
||||
'-m', '-d', homedir, '-s', '/bin/false', username,
|
||||
])
|
||||
if (userAdd.exitCode !== 0 && !config.isDev) {
|
||||
return reply.code(500).send({ success: false, error: `Failed to create system user: ${userAdd.stderr}` })
|
||||
}
|
||||
|
||||
// Create directory structure
|
||||
const dirs = [
|
||||
`${homedir}/public_html`,
|
||||
`${homedir}/logs`,
|
||||
`${homedir}/tmp`,
|
||||
`${homedir}/mail`,
|
||||
`${homedir}/.ssh`,
|
||||
]
|
||||
for (const dir of dirs) {
|
||||
await exec('mkdir', ['-p', dir])
|
||||
}
|
||||
await exec('chown', ['-R', `${username}:${username}`, homedir])
|
||||
await exec('chmod', ['750', homedir])
|
||||
await exec('chmod', ['755', `${homedir}/public_html`])
|
||||
|
||||
// Resolve IP for vhost
|
||||
let ipAddress: string | undefined
|
||||
if (ipId) {
|
||||
const ip = await prisma.ipAddress.findUnique({ where: { id: ipId } })
|
||||
ipAddress = ip?.address
|
||||
}
|
||||
|
||||
// Create account in DB
|
||||
const account = await prisma.account.create({
|
||||
data: {
|
||||
username,
|
||||
passwordHash,
|
||||
email,
|
||||
domain,
|
||||
homedir,
|
||||
status: 'active',
|
||||
packageId: packageId ?? null,
|
||||
resellerId: resellerId ?? (request.user!.role === 'reseller' ? request.user!.sub : null),
|
||||
ipId: ipId ?? null,
|
||||
ipAddress: ipAddress ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
// Create DNS zone
|
||||
if (ipAddress) {
|
||||
const serverHostname = (await exec('hostname', ['-f'])).stdout || 'ns1.server.com'
|
||||
try {
|
||||
const zoneFile = await createZone({
|
||||
domain,
|
||||
ip: ipAddress,
|
||||
ns1: `ns1.${domain}`,
|
||||
ns2: `ns2.${domain}`,
|
||||
})
|
||||
await prisma.dnsZone.create({
|
||||
data: { domain, zoneFile, accountId: account.id },
|
||||
})
|
||||
} catch (e) {
|
||||
// Non-fatal — log and continue
|
||||
console.warn(`DNS zone creation failed for ${domain}:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
// Create nginx vhost
|
||||
try {
|
||||
await enableVhost({
|
||||
domain,
|
||||
username,
|
||||
documentRoot: `${homedir}/public_html`,
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn(`Nginx vhost creation failed for ${domain}:`, e)
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
action: 'account.create',
|
||||
target: username,
|
||||
details: `Created hosting account for domain ${domain}`,
|
||||
sourceIp: request.ip,
|
||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||
},
|
||||
})
|
||||
|
||||
const { passwordHash: _, ...safe } = account
|
||||
return reply.code(201).send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
// PUT /api/whm/accounts/:id
|
||||
fastify.put('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const body = UpdateAccountSchema.safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = { id: parseInt(id, 10) }
|
||||
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
|
||||
|
||||
const existing = await prisma.account.findFirst({ where })
|
||||
if (!existing) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const updateData: Record<string, unknown> = {}
|
||||
if (body.data.email) updateData.email = body.data.email
|
||||
if (body.data.packageId) updateData.packageId = body.data.packageId
|
||||
if (body.data.notes !== undefined) updateData.notes = body.data.notes
|
||||
if (body.data.password) {
|
||||
updateData.passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
// Update system user password
|
||||
await exec('passwd', ['--stdin', existing.username])
|
||||
}
|
||||
|
||||
const updated = await prisma.account.update({
|
||||
where: { id: parseInt(id, 10) },
|
||||
data: updateData,
|
||||
})
|
||||
|
||||
const { passwordHash: _, ...safe } = updated
|
||||
return reply.send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
// POST /api/whm/accounts/:id/suspend
|
||||
fastify.post('/:id/suspend', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const { reason } = (request.body as Record<string, string>) ?? {}
|
||||
|
||||
const account = await prisma.account.findFirst({
|
||||
where: { id: parseInt(id, 10), status: 'active' },
|
||||
})
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Active account not found' })
|
||||
|
||||
await exec('usermod', ['-L', account.username])
|
||||
|
||||
await prisma.account.update({
|
||||
where: { id: account.id },
|
||||
data: { status: 'suspended', suspendedAt: new Date(), suspendReason: reason ?? null },
|
||||
})
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
action: 'account.suspend',
|
||||
target: account.username,
|
||||
details: reason ?? 'No reason given',
|
||||
sourceIp: request.ip,
|
||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||
},
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: { message: `Account ${account.username} suspended` } })
|
||||
})
|
||||
|
||||
// POST /api/whm/accounts/:id/unsuspend
|
||||
fastify.post('/:id/unsuspend', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
|
||||
const account = await prisma.account.findFirst({
|
||||
where: { id: parseInt(id, 10), status: 'suspended' },
|
||||
})
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Suspended account not found' })
|
||||
|
||||
await exec('usermod', ['-U', account.username])
|
||||
|
||||
await prisma.account.update({
|
||||
where: { id: account.id },
|
||||
data: { status: 'active', suspendedAt: null, suspendReason: null },
|
||||
})
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
action: 'account.unsuspend',
|
||||
target: account.username,
|
||||
sourceIp: request.ip,
|
||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||
},
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: { message: `Account ${account.username} unsuspended` } })
|
||||
})
|
||||
|
||||
// DELETE /api/whm/accounts/:id
|
||||
fastify.delete('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const { removeFiles } = (request.body as Record<string, boolean>) ?? {}
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Remove nginx vhost
|
||||
try {
|
||||
await disableVhost(account.domain)
|
||||
} catch (e) {
|
||||
console.warn(`Failed to remove vhost for ${account.domain}:`, e)
|
||||
}
|
||||
|
||||
// Remove DNS zone
|
||||
try {
|
||||
await removeZone(account.domain)
|
||||
} catch (e) {
|
||||
console.warn(`Failed to remove DNS zone for ${account.domain}:`, e)
|
||||
}
|
||||
|
||||
// Remove system user
|
||||
const delArgs = removeFiles ? ['-r', account.username] : [account.username]
|
||||
await exec('userdel', delArgs)
|
||||
|
||||
// Mark as terminated in DB (cascade deletes related records)
|
||||
await prisma.account.update({
|
||||
where: { id: account.id },
|
||||
data: { status: 'terminated', terminatedAt: new Date() },
|
||||
})
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
action: 'account.terminate',
|
||||
target: account.username,
|
||||
details: removeFiles ? 'Files removed' : 'Files retained',
|
||||
sourceIp: request.ip,
|
||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||
},
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: { message: `Account ${account.username} terminated` } })
|
||||
})
|
||||
|
||||
// POST /api/whm/accounts/bulk
|
||||
fastify.post('/bulk', { preHandler }, async (request, reply) => {
|
||||
const body = z.object({
|
||||
action: z.enum(['suspend', 'unsuspend', 'terminate']),
|
||||
ids: z.array(z.number().int().positive()).min(1).max(100),
|
||||
reason: z.string().optional(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const { action, ids, reason } = body.data
|
||||
const results: { id: number; success: boolean; error?: string }[] = []
|
||||
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const account = await prisma.account.findUnique({ where: { id } })
|
||||
if (!account) {
|
||||
results.push({ id, success: false, error: 'Not found' })
|
||||
continue
|
||||
}
|
||||
|
||||
if (action === 'suspend') {
|
||||
await exec('usermod', ['-L', account.username])
|
||||
await prisma.account.update({
|
||||
where: { id },
|
||||
data: { status: 'suspended', suspendedAt: new Date(), suspendReason: reason ?? null },
|
||||
})
|
||||
} else if (action === 'unsuspend') {
|
||||
await exec('usermod', ['-U', account.username])
|
||||
await prisma.account.update({
|
||||
where: { id },
|
||||
data: { status: 'active', suspendedAt: null, suspendReason: null },
|
||||
})
|
||||
} else if (action === 'terminate') {
|
||||
await exec('userdel', [account.username])
|
||||
await prisma.account.update({
|
||||
where: { id },
|
||||
data: { status: 'terminated', terminatedAt: new Date() },
|
||||
})
|
||||
}
|
||||
|
||||
results.push({ id, success: true })
|
||||
} catch (e) {
|
||||
results.push({ id, success: false, error: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: results })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import path from 'path'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdmin } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
import { config } from '../../config.js'
|
||||
|
||||
const BACKUP_DIR = '/var/nixpanel/backups'
|
||||
|
||||
export default async function backupRoutes(fastify: FastifyInstance) {
|
||||
// POST /api/whm/backup/account/:id — backup a single account
|
||||
fastify.post('/account/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const account = await prisma.account.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `${account.username}-${timestamp}.tar.gz`
|
||||
const dest = path.join(BACKUP_DIR, 'accounts', filename)
|
||||
|
||||
// Ensure backup dir exists
|
||||
await exec('mkdir', ['-p', path.join(BACKUP_DIR, 'accounts')])
|
||||
|
||||
const result = await exec('tar', [
|
||||
'-czf', dest,
|
||||
'-C', config.paths.homeDir,
|
||||
account.username,
|
||||
])
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: `Backup failed: ${result.stderr}` })
|
||||
}
|
||||
|
||||
// Get size
|
||||
const stat = await exec('du', ['-sb', dest])
|
||||
const size = parseInt(stat.stdout.split('\t')[0] ?? '0', 10)
|
||||
|
||||
return reply.send({ success: true, data: { filename, path: dest, size, createdAt: new Date() } })
|
||||
})
|
||||
|
||||
// POST /api/whm/backup/server — full server backup
|
||||
fastify.post('/server', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `server-backup-${timestamp}.tar.gz`
|
||||
const dest = path.join(BACKUP_DIR, 'server', filename)
|
||||
|
||||
await exec('mkdir', ['-p', path.join(BACKUP_DIR, 'server')])
|
||||
|
||||
const result = await exec('tar', [
|
||||
'-czf', dest,
|
||||
'--exclude=/proc', '--exclude=/sys', '--exclude=/dev', '--exclude=/run',
|
||||
'--exclude=/tmp', '--exclude=/var/nixpanel/backups',
|
||||
'/etc', '/home', '/var/www',
|
||||
])
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: `Backup failed: ${result.stderr}` })
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: { filename, path: dest, createdAt: new Date() } })
|
||||
})
|
||||
|
||||
// GET /api/whm/backup/list — list available backups
|
||||
fastify.get('/list', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const result = await exec('find', [BACKUP_DIR, '-name', '*.tar.gz', '-printf', '%p %s %T@\n'])
|
||||
if (result.exitCode !== 0 && !result.stdout) {
|
||||
return reply.send({ success: true, data: [] })
|
||||
}
|
||||
|
||||
const backups = result.stdout.split('\n').filter(Boolean).map(line => {
|
||||
const parts = line.trim().split(' ')
|
||||
return {
|
||||
path: parts[0],
|
||||
filename: path.basename(parts[0] ?? ''),
|
||||
size: parseInt(parts[1] ?? '0', 10),
|
||||
createdAt: new Date(parseFloat(parts[2] ?? '0') * 1000),
|
||||
}
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: backups })
|
||||
})
|
||||
|
||||
// DELETE /api/whm/backup — delete a backup file
|
||||
fastify.delete('/', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const body = z.object({ path: z.string() }).safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: 'path required' })
|
||||
}
|
||||
|
||||
if (!body.data.path.startsWith(BACKUP_DIR)) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid backup path' })
|
||||
}
|
||||
|
||||
await exec('rm', ['-f', body.data.path])
|
||||
return reply.send({ success: true, data: { message: 'Backup deleted' } })
|
||||
})
|
||||
|
||||
// POST /api/whm/backup/restore/:filename
|
||||
fastify.post('/restore/:filename', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { filename } = request.params as { filename: string }
|
||||
// Sanitize — no path traversal
|
||||
if (filename.includes('/') || filename.includes('..')) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid filename' })
|
||||
}
|
||||
|
||||
const backupPath = path.join(BACKUP_DIR, 'accounts', filename)
|
||||
const result = await exec('tar', ['-xzf', backupPath, '-C', config.paths.homeDir])
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: `Restore failed: ${result.stderr}` })
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: { message: `Restored from ${filename}` } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdminOrReseller } from '../../middleware/auth.js'
|
||||
import { createZone, removeZone, generateZoneFile } from '../../core/dns.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
|
||||
const DnsRecordSchema = z.object({
|
||||
type: z.enum(['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'PTR', 'CAA']),
|
||||
name: z.string().min(1).max(255),
|
||||
value: z.string().min(1).max(500),
|
||||
ttl: z.number().int().min(60).max(86400).default(3600),
|
||||
priority: z.number().int().min(0).max(65535).optional(),
|
||||
})
|
||||
|
||||
export default async function dnsRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAdminOrReseller]
|
||||
|
||||
// GET /api/whm/dns
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const q = request.query as Record<string, string>
|
||||
const where: Record<string, unknown> = {}
|
||||
if (q.search) where.domain = { contains: q.search }
|
||||
if (q.accountId) where.accountId = parseInt(q.accountId, 10)
|
||||
|
||||
const zones = await prisma.dnsZone.findMany({
|
||||
where,
|
||||
orderBy: { domain: 'asc' },
|
||||
include: {
|
||||
account: { select: { username: true } },
|
||||
_count: { select: { records: true } },
|
||||
},
|
||||
})
|
||||
return reply.send({ success: true, data: zones })
|
||||
})
|
||||
|
||||
// GET /api/whm/dns/:id
|
||||
fastify.get('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const zone = await prisma.dnsZone.findUnique({
|
||||
where: { id: parseInt(id, 10) },
|
||||
include: {
|
||||
records: { orderBy: [{ type: 'asc' }, { name: 'asc' }] },
|
||||
account: { select: { username: true, domain: true } },
|
||||
},
|
||||
})
|
||||
if (!zone) return reply.code(404).send({ success: false, error: 'Zone not found' })
|
||||
return reply.send({ success: true, data: zone })
|
||||
})
|
||||
|
||||
// POST /api/whm/dns/:id/records
|
||||
fastify.post('/:id/records', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const body = DnsRecordSchema.safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const zone = await prisma.dnsZone.findUnique({
|
||||
where: { id: parseInt(id, 10) },
|
||||
include: { account: true },
|
||||
})
|
||||
if (!zone) return reply.code(404).send({ success: false, error: 'Zone not found' })
|
||||
|
||||
const record = await prisma.dnsRecord.create({
|
||||
data: { ...body.data, zoneId: zone.id },
|
||||
})
|
||||
|
||||
// Regenerate zone file and reload
|
||||
await rebuildZone(zone.id)
|
||||
|
||||
return reply.code(201).send({ success: true, data: record })
|
||||
})
|
||||
|
||||
// PUT /api/whm/dns/:zoneId/records/:recordId
|
||||
fastify.put('/:zoneId/records/:recordId', { preHandler }, async (request, reply) => {
|
||||
const { zoneId, recordId } = request.params as { zoneId: string; recordId: string }
|
||||
const body = DnsRecordSchema.partial().safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const record = await prisma.dnsRecord.findFirst({
|
||||
where: { id: parseInt(recordId, 10), zoneId: parseInt(zoneId, 10) },
|
||||
})
|
||||
if (!record) return reply.code(404).send({ success: false, error: 'Record not found' })
|
||||
|
||||
const updated = await prisma.dnsRecord.update({
|
||||
where: { id: parseInt(recordId, 10) },
|
||||
data: body.data,
|
||||
})
|
||||
|
||||
await rebuildZone(parseInt(zoneId, 10))
|
||||
|
||||
return reply.send({ success: true, data: updated })
|
||||
})
|
||||
|
||||
// DELETE /api/whm/dns/:zoneId/records/:recordId
|
||||
fastify.delete('/:zoneId/records/:recordId', { preHandler }, async (request, reply) => {
|
||||
const { zoneId, recordId } = request.params as { zoneId: string; recordId: string }
|
||||
|
||||
const record = await prisma.dnsRecord.findFirst({
|
||||
where: { id: parseInt(recordId, 10), zoneId: parseInt(zoneId, 10) },
|
||||
})
|
||||
if (!record) return reply.code(404).send({ success: false, error: 'Record not found' })
|
||||
|
||||
await prisma.dnsRecord.delete({ where: { id: parseInt(recordId, 10) } })
|
||||
await rebuildZone(parseInt(zoneId, 10))
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Record deleted' } })
|
||||
})
|
||||
|
||||
// POST /api/whm/dns/:id/reload
|
||||
fastify.post('/:id/reload', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
await rebuildZone(parseInt(id, 10))
|
||||
return reply.send({ success: true, data: { message: 'Zone reloaded' } })
|
||||
})
|
||||
}
|
||||
|
||||
async function rebuildZone(zoneId: number): Promise<void> {
|
||||
const zone = await prisma.dnsZone.findUnique({
|
||||
where: { id: zoneId },
|
||||
include: { records: true, account: true },
|
||||
})
|
||||
if (!zone || !zone.zoneFile) return
|
||||
|
||||
// Bump serial
|
||||
const serial = Math.floor(Date.now() / 1000)
|
||||
|
||||
const zoneContent = generateZoneFile({
|
||||
domain: zone.domain,
|
||||
ip: zone.account.ipAddress ?? '127.0.0.1',
|
||||
ns1: `ns1.${zone.domain}`,
|
||||
ns2: `ns2.${zone.domain}`,
|
||||
serial,
|
||||
})
|
||||
|
||||
const { writeFile } = await import('fs/promises')
|
||||
await writeFile(zone.zoneFile, zoneContent, 'utf8')
|
||||
|
||||
await prisma.dnsZone.update({ where: { id: zoneId }, data: { serial: BigInt(serial) } })
|
||||
await exec('rndc', ['reload', zone.domain])
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdminOrReseller } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
|
||||
export default async function emailRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAdminOrReseller]
|
||||
|
||||
// GET /api/whm/email?accountId=
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const q = request.query as Record<string, string>
|
||||
const where: Record<string, unknown> = {}
|
||||
if (q.accountId) where.accountId = parseInt(q.accountId, 10)
|
||||
if (q.domain) where.domain = q.domain
|
||||
|
||||
const accounts = await prisma.emailAccount.findMany({
|
||||
where,
|
||||
orderBy: { address: 'asc' },
|
||||
select: {
|
||||
id: true, address: true, username: true, domain: true,
|
||||
quotaMb: true, usedMb: true, status: true, createdAt: true,
|
||||
account: { select: { username: true } },
|
||||
},
|
||||
})
|
||||
return reply.send({ success: true, data: accounts })
|
||||
})
|
||||
|
||||
// POST /api/whm/email
|
||||
fastify.post('/', { preHandler }, async (request, reply) => {
|
||||
const body = z.object({
|
||||
accountId: z.number().int().positive(),
|
||||
username: z.string().min(1).max(64).regex(/^[a-zA-Z0-9._+-]+$/),
|
||||
password: z.string().min(8),
|
||||
quotaMb: z.number().int().min(0).default(500),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const account = await prisma.account.findUnique({
|
||||
where: { id: body.data.accountId },
|
||||
include: { package: true },
|
||||
})
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Check quota
|
||||
if (account.package && account.package.maxEmailAccounts > 0) {
|
||||
const count = await prisma.emailAccount.count({ where: { accountId: account.id } })
|
||||
if (count >= account.package.maxEmailAccounts) {
|
||||
return reply.code(429).send({ success: false, error: 'Email account limit reached for this package' })
|
||||
}
|
||||
}
|
||||
|
||||
const address = `${body.data.username}@${account.domain}`
|
||||
const exists = await prisma.emailAccount.findUnique({ where: { address } })
|
||||
if (exists) return reply.code(409).send({ success: false, error: 'Email address already exists' })
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
|
||||
// Create Postfix/Dovecot mailbox via doveadm
|
||||
const result = await exec('doveadm', [
|
||||
'user', 'import', '-u', address, '-p', body.data.password,
|
||||
])
|
||||
// Non-fatal in dev
|
||||
|
||||
const emailAccount = await prisma.emailAccount.create({
|
||||
data: {
|
||||
username: body.data.username,
|
||||
domain: account.domain,
|
||||
address,
|
||||
passwordHash,
|
||||
quotaMb: body.data.quotaMb,
|
||||
accountId: account.id,
|
||||
},
|
||||
})
|
||||
|
||||
const { passwordHash: _, ...safe } = emailAccount
|
||||
return reply.code(201).send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
// DELETE /api/whm/email/:id
|
||||
fastify.delete('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const emailAccount = await prisma.emailAccount.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!emailAccount) return reply.code(404).send({ success: false, error: 'Email account not found' })
|
||||
|
||||
await exec('doveadm', ['user', 'delete', '-u', emailAccount.address])
|
||||
await prisma.emailAccount.delete({ where: { id: emailAccount.id } })
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Email account deleted' } })
|
||||
})
|
||||
|
||||
// POST /api/whm/email/:id/password
|
||||
fastify.post('/:id/password', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const body = z.object({ password: z.string().min(8) }).safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: 'Password must be at least 8 characters' })
|
||||
}
|
||||
|
||||
const emailAccount = await prisma.emailAccount.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!emailAccount) return reply.code(404).send({ success: false, error: 'Email account not found' })
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
await prisma.emailAccount.update({ where: { id: emailAccount.id }, data: { passwordHash } })
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Password updated' } })
|
||||
})
|
||||
|
||||
// GET /api/whm/email/forwarders?accountId=
|
||||
fastify.get('/forwarders', { preHandler }, async (request, reply) => {
|
||||
const q = request.query as Record<string, string>
|
||||
const where: Record<string, unknown> = {}
|
||||
if (q.accountId) where.accountId = parseInt(q.accountId, 10)
|
||||
|
||||
const forwarders = await prisma.emailForwarder.findMany({ where })
|
||||
return reply.send({ success: true, data: forwarders })
|
||||
})
|
||||
|
||||
// POST /api/whm/email/forwarders
|
||||
fastify.post('/forwarders', { preHandler }, async (request, reply) => {
|
||||
const body = z.object({
|
||||
accountId: z.number().int().positive(),
|
||||
source: z.string().email(),
|
||||
destination: z.string().email(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const forwarder = await prisma.emailForwarder.create({ data: body.data })
|
||||
return reply.code(201).send({ success: true, data: forwarder })
|
||||
})
|
||||
|
||||
// DELETE /api/whm/email/forwarders/:id
|
||||
fastify.delete('/forwarders/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
await prisma.emailForwarder.delete({ where: { id: parseInt(id, 10) } })
|
||||
return reply.send({ success: true, data: { message: 'Forwarder deleted' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import accountRoutes from './accounts.js'
|
||||
import packageRoutes from './packages.js'
|
||||
import resellerRoutes from './resellers.js'
|
||||
import dnsRoutes from './dns.js'
|
||||
import emailRoutes from './email.js'
|
||||
import sslRoutes from './ssl.js'
|
||||
import phpRoutes from './php.js'
|
||||
import serviceRoutes from './services.js'
|
||||
import systemRoutes from './system.js'
|
||||
import securityRoutes from './security.js'
|
||||
import backupRoutes from './backup.js'
|
||||
import webserverRoutes from './webserver.js'
|
||||
|
||||
export default async function whmRoutes(fastify: FastifyInstance) {
|
||||
fastify.register(accountRoutes, { prefix: '/accounts' })
|
||||
fastify.register(packageRoutes, { prefix: '/packages' })
|
||||
fastify.register(resellerRoutes, { prefix: '/resellers' })
|
||||
fastify.register(dnsRoutes, { prefix: '/dns' })
|
||||
fastify.register(emailRoutes, { prefix: '/email' })
|
||||
fastify.register(sslRoutes, { prefix: '/ssl' })
|
||||
fastify.register(phpRoutes, { prefix: '/php' })
|
||||
fastify.register(serviceRoutes, { prefix: '/services' })
|
||||
fastify.register(systemRoutes, { prefix: '/system' })
|
||||
fastify.register(securityRoutes, { prefix: '/security' })
|
||||
fastify.register(backupRoutes, { prefix: '/backup' })
|
||||
fastify.register(webserverRoutes, { prefix: '/webserver' })
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdmin } from '../../middleware/auth.js'
|
||||
|
||||
const PackageSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
diskMb: z.number().int().min(0),
|
||||
bandwidthMb: z.number().int().min(0),
|
||||
maxDatabases: z.number().int().min(0),
|
||||
maxEmailAccounts: z.number().int().min(0),
|
||||
maxSubdomains: z.number().int().min(0),
|
||||
maxAddonDomains: z.number().int().min(0),
|
||||
maxParkedDomains: z.number().int().min(0),
|
||||
maxFtpAccounts: z.number().int().min(0),
|
||||
maxCronJobs: z.number().int().min(0),
|
||||
phpVersion: z.string().default('8.3'),
|
||||
sslEnabled: z.boolean().default(true),
|
||||
backupEnabled: z.boolean().default(true),
|
||||
dedicatedIp: z.boolean().default(false),
|
||||
notes: z.string().max(500).optional(),
|
||||
})
|
||||
|
||||
export default async function packageRoutes(fastify: FastifyInstance) {
|
||||
fastify.get('/', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const packages = await prisma.package.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: { _count: { select: { accounts: true } } },
|
||||
})
|
||||
return reply.send({ success: true, data: packages })
|
||||
})
|
||||
|
||||
fastify.get('/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const pkg = await prisma.package.findUnique({
|
||||
where: { id: parseInt(id, 10) },
|
||||
include: { _count: { select: { accounts: true } } },
|
||||
})
|
||||
if (!pkg) return reply.code(404).send({ success: false, error: 'Package not found' })
|
||||
return reply.send({ success: true, data: pkg })
|
||||
})
|
||||
|
||||
fastify.post('/', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const body = PackageSchema.safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const existing = await prisma.package.findUnique({ where: { name: body.data.name } })
|
||||
if (existing) return reply.code(409).send({ success: false, error: 'Package name already in use' })
|
||||
|
||||
const pkg = await prisma.package.create({
|
||||
data: {
|
||||
...body.data,
|
||||
diskMb: BigInt(body.data.diskMb),
|
||||
bandwidthMb: BigInt(body.data.bandwidthMb),
|
||||
},
|
||||
})
|
||||
return reply.code(201).send({ success: true, data: pkg })
|
||||
})
|
||||
|
||||
fastify.put('/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const body = PackageSchema.partial().safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const existing = await prisma.package.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!existing) return reply.code(404).send({ success: false, error: 'Package not found' })
|
||||
|
||||
const updateData: Record<string, unknown> = { ...body.data }
|
||||
if (body.data.diskMb !== undefined) updateData.diskMb = BigInt(body.data.diskMb)
|
||||
if (body.data.bandwidthMb !== undefined) updateData.bandwidthMb = BigInt(body.data.bandwidthMb)
|
||||
|
||||
const pkg = await prisma.package.update({
|
||||
where: { id: parseInt(id, 10) },
|
||||
data: updateData,
|
||||
})
|
||||
return reply.send({ success: true, data: pkg })
|
||||
})
|
||||
|
||||
fastify.delete('/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const pkg = await prisma.package.findUnique({
|
||||
where: { id: parseInt(id, 10) },
|
||||
include: { _count: { select: { accounts: true } } },
|
||||
})
|
||||
if (!pkg) return reply.code(404).send({ success: false, error: 'Package not found' })
|
||||
if (pkg._count.accounts > 0) {
|
||||
return reply.code(409).send({ success: false, error: `Cannot delete — ${pkg._count.accounts} accounts use this package` })
|
||||
}
|
||||
await prisma.package.delete({ where: { id: parseInt(id, 10) } })
|
||||
return reply.send({ success: true, data: { message: 'Package deleted' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdmin } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
import { writeFile } from '../../core/exec.js'
|
||||
|
||||
const SUPPORTED_PHP_VERSIONS = ['7.4', '8.0', '8.1', '8.2', '8.3']
|
||||
|
||||
export default async function phpRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/whm/php/versions — list installed PHP versions
|
||||
fastify.get('/versions', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const versions: { version: string; installed: boolean; active: boolean }[] = []
|
||||
|
||||
for (const v of SUPPORTED_PHP_VERSIONS) {
|
||||
const result = await exec('php' + v, ['--version'])
|
||||
const installed = result.exitCode === 0
|
||||
versions.push({ version: v, installed, active: installed })
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: versions })
|
||||
})
|
||||
|
||||
// GET /api/whm/php/accounts — list which PHP version each account uses
|
||||
fastify.get('/accounts', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const accounts = await prisma.account.findMany({
|
||||
where: { status: 'active' },
|
||||
select: {
|
||||
id: true, username: true, domain: true,
|
||||
package: { select: { phpVersion: true } },
|
||||
},
|
||||
})
|
||||
return reply.send({ success: true, data: accounts })
|
||||
})
|
||||
|
||||
// PUT /api/whm/php/accounts/:id — change PHP version for account
|
||||
fastify.put('/accounts/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const body = z.object({
|
||||
phpVersion: z.enum(['7.4', '8.0', '8.1', '8.2', '8.3']),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid PHP version' })
|
||||
}
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Create PHP-FPM pool config for this account
|
||||
const phpFpmConf = `/etc/php/${body.data.phpVersion}/fpm/pool.d/${account.username}.conf`
|
||||
const poolConfig = `[${account.username}]
|
||||
user = ${account.username}
|
||||
group = ${account.username}
|
||||
listen = /run/php/php${body.data.phpVersion}-fpm-${account.username}.sock
|
||||
listen.owner = www-data
|
||||
listen.group = www-data
|
||||
pm = dynamic
|
||||
pm.max_children = 5
|
||||
pm.start_servers = 2
|
||||
pm.min_spare_servers = 1
|
||||
pm.max_spare_servers = 3
|
||||
chdir = /
|
||||
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen
|
||||
php_admin_value[open_basedir] = ${account.homedir}:/tmp
|
||||
php_admin_value[error_log] = ${account.homedir}/logs/php_errors.log
|
||||
`
|
||||
|
||||
await writeFile(phpFpmConf, poolConfig)
|
||||
|
||||
const phpVersion = body.data.phpVersion
|
||||
await exec('systemctl', [`php${phpVersion}-fpm`, 'reload'])
|
||||
|
||||
// Update package if account has one
|
||||
if (account.packageId) {
|
||||
await prisma.package.update({
|
||||
where: { id: account.packageId },
|
||||
data: { phpVersion: body.data.phpVersion },
|
||||
})
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: { message: `PHP version set to ${body.data.phpVersion}` } })
|
||||
})
|
||||
|
||||
// GET /api/whm/php/ini/:version — get php.ini settings
|
||||
fastify.get('/ini/:version', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { version } = request.params as { version: string }
|
||||
if (!SUPPORTED_PHP_VERSIONS.includes(version)) {
|
||||
return reply.code(400).send({ success: false, error: 'Unsupported PHP version' })
|
||||
}
|
||||
|
||||
const result = await exec(`php${version}`, ['-i'])
|
||||
if (result.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: 'Failed to get PHP info' })
|
||||
}
|
||||
|
||||
// Parse key settings
|
||||
const lines = result.stdout.split('\n')
|
||||
const settings: Record<string, string> = {}
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^([^=>]+)=>\s*(.+?)\s*$/)
|
||||
if (match) settings[match[1].trim()] = match[2].trim()
|
||||
}
|
||||
|
||||
const relevant = [
|
||||
'memory_limit', 'max_execution_time', 'max_input_time', 'upload_max_filesize',
|
||||
'post_max_size', 'display_errors', 'error_reporting', 'date.timezone',
|
||||
]
|
||||
const filtered: Record<string, string> = {}
|
||||
for (const key of relevant) {
|
||||
if (settings[key]) filtered[key] = settings[key]
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: { version, settings: filtered } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdmin } from '../../middleware/auth.js'
|
||||
|
||||
const CreateResellerSchema = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
password: z.string().min(8),
|
||||
email: z.string().email(),
|
||||
contactName: z.string().max(255).optional(),
|
||||
company: z.string().max(255).optional(),
|
||||
maxAccounts: z.number().int().min(0).default(10),
|
||||
diskLimitMb: z.number().int().min(0).default(51200),
|
||||
bandwidthLimitMb: z.number().int().min(0).default(512000),
|
||||
brandName: z.string().max(255).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
})
|
||||
|
||||
export default async function resellerRoutes(fastify: FastifyInstance) {
|
||||
fastify.get('/', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const q = request.query as Record<string, string>
|
||||
const page = Math.max(1, parseInt(q.page ?? '1', 10))
|
||||
const limit = Math.min(100, Math.max(1, parseInt(q.limit ?? '25', 10)))
|
||||
|
||||
const where: Record<string, unknown> = {}
|
||||
if (q.search) {
|
||||
where.OR = [
|
||||
{ username: { contains: q.search } },
|
||||
{ email: { contains: q.search } },
|
||||
{ company: { contains: q.search } },
|
||||
]
|
||||
}
|
||||
if (q.status) where.status = q.status
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.reseller.findMany({
|
||||
where,
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true, username: true, email: true, company: true, contactName: true,
|
||||
status: true, maxAccounts: true, diskLimitMb: true, bandwidthLimitMb: true,
|
||||
diskUsedMb: true, bandwidthUsedMb: true, createdAt: true,
|
||||
_count: { select: { accounts: true } },
|
||||
},
|
||||
}),
|
||||
prisma.reseller.count({ where }),
|
||||
])
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: { items, total, page, limit, totalPages: Math.ceil(total / limit) },
|
||||
})
|
||||
})
|
||||
|
||||
fastify.get('/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const reseller = await prisma.reseller.findUnique({
|
||||
where: { id: parseInt(id, 10) },
|
||||
include: { _count: { select: { accounts: true } } },
|
||||
})
|
||||
if (!reseller) return reply.code(404).send({ success: false, error: 'Reseller not found' })
|
||||
const { passwordHash: _, ...safe } = reseller
|
||||
return reply.send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
fastify.post('/', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const body = CreateResellerSchema.safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const exists = await prisma.reseller.findFirst({
|
||||
where: { OR: [{ username: body.data.username }, { email: body.data.email }] },
|
||||
})
|
||||
if (exists) return reply.code(409).send({ success: false, error: 'Username or email already in use' })
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
const reseller = await prisma.reseller.create({
|
||||
data: {
|
||||
...body.data,
|
||||
passwordHash,
|
||||
diskLimitMb: BigInt(body.data.diskLimitMb),
|
||||
bandwidthLimitMb: BigInt(body.data.bandwidthLimitMb),
|
||||
},
|
||||
})
|
||||
|
||||
const { passwordHash: _, ...safe } = reseller
|
||||
return reply.code(201).send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
fastify.put('/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const body = CreateResellerSchema.omit({ password: true, username: true }).partial().safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const existing = await prisma.reseller.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!existing) return reply.code(404).send({ success: false, error: 'Reseller not found' })
|
||||
|
||||
const updateData: Record<string, unknown> = { ...body.data }
|
||||
if (body.data.diskLimitMb !== undefined) updateData.diskLimitMb = BigInt(body.data.diskLimitMb)
|
||||
if (body.data.bandwidthLimitMb !== undefined) updateData.bandwidthLimitMb = BigInt(body.data.bandwidthLimitMb)
|
||||
|
||||
const reseller = await prisma.reseller.update({
|
||||
where: { id: parseInt(id, 10) },
|
||||
data: updateData,
|
||||
})
|
||||
const { passwordHash: _, ...safe } = reseller
|
||||
return reply.send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
fastify.post('/:id/suspend', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
await prisma.reseller.update({
|
||||
where: { id: parseInt(id, 10) },
|
||||
data: { status: 'suspended' },
|
||||
})
|
||||
return reply.send({ success: true, data: { message: 'Reseller suspended' } })
|
||||
})
|
||||
|
||||
fastify.post('/:id/unsuspend', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
await prisma.reseller.update({
|
||||
where: { id: parseInt(id, 10) },
|
||||
data: { status: 'active' },
|
||||
})
|
||||
return reply.send({ success: true, data: { message: 'Reseller unsuspended' } })
|
||||
})
|
||||
|
||||
fastify.delete('/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const reseller = await prisma.reseller.findUnique({
|
||||
where: { id: parseInt(id, 10) },
|
||||
include: { _count: { select: { accounts: true } } },
|
||||
})
|
||||
if (!reseller) return reply.code(404).send({ success: false, error: 'Reseller not found' })
|
||||
if (reseller._count.accounts > 0) {
|
||||
return reply.code(409).send({ success: false, error: `Cannot delete — reseller has ${reseller._count.accounts} accounts` })
|
||||
}
|
||||
await prisma.reseller.delete({ where: { id: parseInt(id, 10) } })
|
||||
return reply.send({ success: true, data: { message: 'Reseller deleted' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdmin } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
|
||||
export default async function securityRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/whm/security/events
|
||||
fastify.get('/events', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const q = request.query as Record<string, string>
|
||||
const page = Math.max(1, parseInt(q.page ?? '1', 10))
|
||||
const limit = Math.min(100, parseInt(q.limit ?? '50', 10))
|
||||
const where: Record<string, unknown> = {}
|
||||
if (q.severity) where.severity = q.severity
|
||||
if (q.resolved !== undefined) where.resolved = q.resolved === 'true'
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.securityEvent.findMany({
|
||||
where, skip: (page - 1) * limit, take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.securityEvent.count({ where }),
|
||||
])
|
||||
|
||||
return reply.send({ success: true, data: { items, total, page, limit, totalPages: Math.ceil(total / limit) } })
|
||||
})
|
||||
|
||||
// POST /api/whm/security/events/:id/resolve
|
||||
fastify.post('/events/:id/resolve', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
await prisma.securityEvent.update({
|
||||
where: { id: parseInt(id, 10) },
|
||||
data: { resolved: true, resolvedAt: new Date() },
|
||||
})
|
||||
return reply.send({ success: true, data: { message: 'Event resolved' } })
|
||||
})
|
||||
|
||||
// GET /api/whm/security/blocked-ips
|
||||
fastify.get('/blocked-ips', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const blocked = await prisma.blockedIp.findMany({ orderBy: { createdAt: 'desc' } })
|
||||
return reply.send({ success: true, data: blocked })
|
||||
})
|
||||
|
||||
// POST /api/whm/security/blocked-ips
|
||||
fastify.post('/blocked-ips', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const body = z.object({
|
||||
ip: z.string().ip(),
|
||||
reason: z.string().max(500).optional(),
|
||||
expiresAt: z.string().datetime().optional(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const exists = await prisma.blockedIp.findUnique({ where: { ip: body.data.ip } })
|
||||
if (exists) return reply.code(409).send({ success: false, error: 'IP already blocked' })
|
||||
|
||||
await exec('ufw', ['insert', '1', 'deny', 'from', body.data.ip, 'to', 'any'])
|
||||
|
||||
const blocked = await prisma.blockedIp.create({
|
||||
data: {
|
||||
ip: body.data.ip,
|
||||
reason: body.data.reason,
|
||||
expiresAt: body.data.expiresAt ? new Date(body.data.expiresAt) : null,
|
||||
},
|
||||
})
|
||||
|
||||
return reply.code(201).send({ success: true, data: blocked })
|
||||
})
|
||||
|
||||
// DELETE /api/whm/security/blocked-ips/:id
|
||||
fastify.delete('/blocked-ips/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const blocked = await prisma.blockedIp.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!blocked) return reply.code(404).send({ success: false, error: 'IP not found' })
|
||||
|
||||
await exec('ufw', ['delete', 'deny', 'from', blocked.ip, 'to', 'any'])
|
||||
await prisma.blockedIp.delete({ where: { id: blocked.id } })
|
||||
|
||||
return reply.send({ success: true, data: { message: `${blocked.ip} unblocked` } })
|
||||
})
|
||||
|
||||
// GET /api/whm/security/whitelisted-ips
|
||||
fastify.get('/whitelisted-ips', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const list = await prisma.whitelistedIp.findMany({ orderBy: { createdAt: 'desc' } })
|
||||
return reply.send({ success: true, data: list })
|
||||
})
|
||||
|
||||
// POST /api/whm/security/whitelisted-ips
|
||||
fastify.post('/whitelisted-ips', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const body = z.object({
|
||||
ip: z.string().ip(),
|
||||
note: z.string().max(255).optional(),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const entry = await prisma.whitelistedIp.create({ data: body.data })
|
||||
return reply.code(201).send({ success: true, data: entry })
|
||||
})
|
||||
|
||||
// DELETE /api/whm/security/whitelisted-ips/:id
|
||||
fastify.delete('/whitelisted-ips/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
await prisma.whitelistedIp.delete({ where: { id: parseInt(id, 10) } })
|
||||
return reply.send({ success: true, data: { message: 'Removed from whitelist' } })
|
||||
})
|
||||
|
||||
// GET /api/whm/security/fail2ban
|
||||
fastify.get('/fail2ban', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const result = await exec('fail2ban-client', ['status'])
|
||||
return reply.send({ success: true, data: { output: result.stdout } })
|
||||
})
|
||||
|
||||
// GET /api/whm/security/fail2ban/:jail
|
||||
fastify.get('/fail2ban/:jail', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { jail } = request.params as { jail: string }
|
||||
const result = await exec('fail2ban-client', ['status', jail])
|
||||
return reply.send({ success: true, data: { output: result.stdout } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import { requireAdmin } from '../../middleware/auth.js'
|
||||
import { exec, MANAGED_SERVICES as _ } from '../../core/exec.js'
|
||||
import { getServiceStatus, MANAGED_SERVICES } from '../../core/system.js'
|
||||
|
||||
const ServiceActionSchema = z.object({
|
||||
action: z.enum(['start', 'stop', 'restart', 'reload', 'enable', 'disable']),
|
||||
})
|
||||
|
||||
export default async function serviceRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/whm/services — list all managed services and their status
|
||||
fastify.get('/', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const statuses = await Promise.all(MANAGED_SERVICES.map(getServiceStatus))
|
||||
return reply.send({ success: true, data: statuses })
|
||||
})
|
||||
|
||||
// GET /api/whm/services/:name
|
||||
fastify.get('/:name', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { name } = request.params as { name: string }
|
||||
if (!MANAGED_SERVICES.includes(name)) {
|
||||
return reply.code(400).send({ success: false, error: 'Service not in managed list' })
|
||||
}
|
||||
const status = await getServiceStatus(name)
|
||||
return reply.send({ success: true, data: status })
|
||||
})
|
||||
|
||||
// POST /api/whm/services/:name — start/stop/restart/reload/enable/disable
|
||||
fastify.post('/:name', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { name } = request.params as { name: string }
|
||||
const body = ServiceActionSchema.safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
if (!MANAGED_SERVICES.includes(name)) {
|
||||
return reply.code(400).send({ success: false, error: 'Service not in managed list' })
|
||||
}
|
||||
|
||||
const result = await exec('systemctl', [body.data.action, name])
|
||||
if (result.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: result.stderr || 'Service action failed' })
|
||||
}
|
||||
|
||||
const status = await getServiceStatus(name)
|
||||
return reply.send({ success: true, data: { message: `${body.data.action} ${name} succeeded`, status } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import path from 'path'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdminOrReseller } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
import { enableVhost } from '../../core/nginx.js'
|
||||
import { config } from '../../config.js'
|
||||
|
||||
export default async function sslRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAdminOrReseller]
|
||||
|
||||
// GET /api/whm/ssl
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const q = request.query as Record<string, string>
|
||||
const where: Record<string, unknown> = {}
|
||||
if (q.accountId) where.accountId = parseInt(q.accountId, 10)
|
||||
if (q.status) where.status = q.status
|
||||
|
||||
const certs = await prisma.sslCertificate.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { account: { select: { username: true, domain: true } } },
|
||||
})
|
||||
return reply.send({ success: true, data: certs })
|
||||
})
|
||||
|
||||
// POST /api/whm/ssl/issue — issue Let's Encrypt cert via certbot
|
||||
fastify.post('/issue', { preHandler }, async (request, reply) => {
|
||||
const body = z.object({
|
||||
accountId: z.number().int().positive(),
|
||||
domain: z.string(),
|
||||
includeWww: z.boolean().default(true),
|
||||
autoRenew: z.boolean().default(true),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const { accountId, domain, includeWww, autoRenew } = body.data
|
||||
|
||||
const account = await prisma.account.findUnique({ where: { id: accountId } })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const domains = [domain]
|
||||
if (includeWww) domains.push(`www.${domain}`)
|
||||
|
||||
const certbotArgs = [
|
||||
'certonly', '--webroot',
|
||||
'-w', config.paths.certbotWebroot,
|
||||
'--non-interactive',
|
||||
'--agree-tos',
|
||||
'--email', account.email,
|
||||
...domains.flatMap(d => ['-d', d]),
|
||||
]
|
||||
|
||||
const result = await exec('certbot', certbotArgs)
|
||||
if (result.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: `certbot failed: ${result.stderr}` })
|
||||
}
|
||||
|
||||
const certDir = `/etc/letsencrypt/live/${domain}`
|
||||
const certPath = `${certDir}/fullchain.pem`
|
||||
const keyPath = `${certDir}/privkey.pem`
|
||||
const chainPath = `${certDir}/chain.pem`
|
||||
|
||||
const issuedAt = new Date()
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 90)
|
||||
|
||||
const existing = await prisma.sslCertificate.findFirst({ where: { accountId, domain } })
|
||||
let cert
|
||||
if (existing) {
|
||||
cert = await prisma.sslCertificate.update({
|
||||
where: { id: existing.id },
|
||||
data: { certPath, keyPath, chainPath, issuedAt, expiresAt, status: 'active', autoRenew },
|
||||
})
|
||||
} else {
|
||||
cert = await prisma.sslCertificate.create({
|
||||
data: { accountId, domain, certPath, keyPath, chainPath, issuedAt, expiresAt, status: 'active', autoRenew },
|
||||
})
|
||||
}
|
||||
|
||||
// Update nginx vhost with SSL
|
||||
try {
|
||||
await enableVhost({
|
||||
domain,
|
||||
username: account.username,
|
||||
documentRoot: `${account.homedir}/public_html`,
|
||||
sslEnabled: true,
|
||||
certPath,
|
||||
keyPath,
|
||||
chainPath,
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn(`Nginx SSL update failed for ${domain}:`, e)
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: cert })
|
||||
})
|
||||
|
||||
// POST /api/whm/ssl/revoke
|
||||
fastify.post('/:id/revoke', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const cert = await prisma.sslCertificate.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!cert) return reply.code(404).send({ success: false, error: 'Certificate not found' })
|
||||
|
||||
if (cert.certPath) {
|
||||
const result = await exec('certbot', ['revoke', '--cert-path', cert.certPath, '--non-interactive'])
|
||||
if (result.exitCode !== 0) {
|
||||
console.warn(`certbot revoke warning: ${result.stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.sslCertificate.update({
|
||||
where: { id: cert.id },
|
||||
data: { status: 'revoked' },
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Certificate revoked' } })
|
||||
})
|
||||
|
||||
// POST /api/whm/ssl/:id/renew
|
||||
fastify.post('/:id/renew', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const cert = await prisma.sslCertificate.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
if (!cert) return reply.code(404).send({ success: false, error: 'Certificate not found' })
|
||||
|
||||
const result = await exec('certbot', ['renew', '--cert-name', cert.domain, '--non-interactive'])
|
||||
if (result.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: `Renewal failed: ${result.stderr}` })
|
||||
}
|
||||
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 90)
|
||||
|
||||
await prisma.sslCertificate.update({
|
||||
where: { id: cert.id },
|
||||
data: { status: 'active', issuedAt: new Date(), expiresAt },
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Certificate renewed' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdmin } from '../../middleware/auth.js'
|
||||
import { getSystemStats } from '../../core/system.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
import { readFile } from 'fs/promises'
|
||||
|
||||
export default async function systemRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/whm/system/stats
|
||||
fastify.get('/stats', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const stats = await getSystemStats()
|
||||
return reply.send({ success: true, data: stats })
|
||||
})
|
||||
|
||||
// GET /api/whm/system/info
|
||||
fastify.get('/info', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const [hostname, osRelease, kernel, uptime] = await Promise.all([
|
||||
exec('hostname', ['-f']),
|
||||
readFile('/etc/os-release', 'utf8').catch(() => ''),
|
||||
exec('uname', ['-r']),
|
||||
exec('uptime', ['-p']),
|
||||
])
|
||||
|
||||
const osInfo: Record<string, string> = {}
|
||||
for (const line of osRelease.split('\n')) {
|
||||
const [k, v] = line.split('=')
|
||||
if (k && v) osInfo[k] = v.replace(/"/g, '')
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
hostname: hostname.stdout,
|
||||
os: osInfo['PRETTY_NAME'] ?? 'Unknown',
|
||||
osId: osInfo['ID'] ?? 'unknown',
|
||||
kernel: kernel.stdout,
|
||||
uptime: uptime.stdout,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/whm/system/processes
|
||||
fastify.get('/processes', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const result = await exec('ps', ['aux', '--sort=-%cpu'])
|
||||
if (result.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: 'Failed to get process list' })
|
||||
}
|
||||
|
||||
const lines = result.stdout.split('\n').slice(1, 51) // top 50 processes
|
||||
const processes = lines.map(line => {
|
||||
const parts = line.trim().split(/\s+/)
|
||||
return {
|
||||
user: parts[0],
|
||||
pid: parts[1],
|
||||
cpu: parts[2],
|
||||
mem: parts[3],
|
||||
command: parts.slice(10).join(' '),
|
||||
}
|
||||
}).filter(p => p.pid)
|
||||
|
||||
return reply.send({ success: true, data: processes })
|
||||
})
|
||||
|
||||
// POST /api/whm/system/processes/:pid/kill
|
||||
fastify.post('/processes/:pid/kill', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { pid } = request.params as { pid: string }
|
||||
if (!/^\d+$/.test(pid) || parseInt(pid, 10) <= 1) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid PID' })
|
||||
}
|
||||
const result = await exec('kill', ['-9', pid])
|
||||
if (result.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: result.stderr })
|
||||
}
|
||||
return reply.send({ success: true, data: { message: `Process ${pid} killed` } })
|
||||
})
|
||||
|
||||
// GET /api/whm/system/audit-log
|
||||
fastify.get('/audit-log', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const q = request.query as Record<string, string>
|
||||
const page = Math.max(1, parseInt(q.page ?? '1', 10))
|
||||
const limit = Math.min(100, Math.max(1, parseInt(q.limit ?? '50', 10)))
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.auditLog.findMany({
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { admin: { select: { username: true } } },
|
||||
}),
|
||||
prisma.auditLog.count(),
|
||||
])
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: { items, total, page, limit, totalPages: Math.ceil(total / limit) },
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/whm/system/settings
|
||||
fastify.get('/settings', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const settings = await prisma.setting.findMany({ orderBy: { key: 'asc' } })
|
||||
const map: Record<string, string> = {}
|
||||
for (const s of settings) map[s.key] = s.value
|
||||
return reply.send({ success: true, data: map })
|
||||
})
|
||||
|
||||
// PUT /api/whm/system/settings
|
||||
fastify.put('/settings', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const body = request.body as Record<string, string>
|
||||
if (typeof body !== 'object' || Array.isArray(body)) {
|
||||
return reply.code(400).send({ success: false, error: 'Body must be a key-value object' })
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
await prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: { value: String(value) },
|
||||
create: { key, value: String(value) },
|
||||
})
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Settings updated' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import { requireAdmin } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
import { readFile } from 'fs/promises'
|
||||
|
||||
export default async function webserverRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/whm/webserver/status
|
||||
fastify.get('/status', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const [nginx, apache] = await Promise.all([
|
||||
exec('systemctl', ['is-active', 'nginx']),
|
||||
exec('systemctl', ['is-active', 'apache2']),
|
||||
])
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
nginx: { active: nginx.stdout.trim() === 'active' },
|
||||
apache: { active: apache.stdout.trim() === 'active' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/whm/webserver/vhosts
|
||||
fastify.get('/vhosts', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const result = await exec('find', ['/etc/nginx/sites-enabled', '-name', '*.conf', '-type', 'f'])
|
||||
const files = result.stdout.split('\n').filter(Boolean)
|
||||
const vhosts = files.map(f => ({
|
||||
path: f,
|
||||
domain: f.split('/').pop()?.replace('.conf', '') ?? '',
|
||||
}))
|
||||
return reply.send({ success: true, data: vhosts })
|
||||
})
|
||||
|
||||
// GET /api/whm/webserver/vhosts/:domain
|
||||
fastify.get('/vhosts/:domain', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { domain } = request.params as { domain: string }
|
||||
if (domain.includes('/') || domain.includes('..')) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid domain' })
|
||||
}
|
||||
try {
|
||||
const content = await readFile(`/etc/nginx/sites-available/${domain}.conf`, 'utf8')
|
||||
return reply.send({ success: true, data: { domain, config: content } })
|
||||
} catch {
|
||||
return reply.code(404).send({ success: false, error: 'Vhost not found' })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/whm/webserver/nginx/test
|
||||
fastify.post('/nginx/test', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const result = await exec('nginx', ['-t'])
|
||||
return reply.send({
|
||||
success: result.exitCode === 0,
|
||||
data: { output: result.stdout + result.stderr },
|
||||
})
|
||||
})
|
||||
|
||||
// POST /api/whm/webserver/nginx/reload
|
||||
fastify.post('/nginx/reload', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const test = await exec('nginx', ['-t'])
|
||||
if (test.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: `Config test failed: ${test.stderr}` })
|
||||
}
|
||||
const result = await exec('systemctl', ['reload', 'nginx'])
|
||||
return reply.send({ success: result.exitCode === 0, data: { output: result.stdout } })
|
||||
})
|
||||
|
||||
// GET /api/whm/webserver/logs/:type — access or error log (last 200 lines)
|
||||
fastify.get('/logs/:type', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { type } = request.params as { type: string }
|
||||
const logFile = type === 'error' ? '/var/log/nginx/error.log' : '/var/log/nginx/access.log'
|
||||
const result = await exec('tail', ['-n', '200', logFile])
|
||||
return reply.send({ success: true, data: { lines: result.stdout.split('\n').filter(Boolean) } })
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user