feat: v0.4.0 — Apache service stack, shell access, log viewer, changelog
Service stack (backend): - webserver.ts rewritten: apache2/php-fpm routes replacing nginx; per-account Apache log endpoint; PHP-FPM reload per version; apache2ctl configtest/reload - accounts.ts: added PUT /:id/shell to toggle /bin/bash vs /usr/sbin/nologin, runs usermod -s and writes audit log New UI features (nixserver): - ShellAccess page: grant/revoke SSH access per account via AccountPicker - ApacheLogs page: live log viewer (access/error) per account; replaces stub - NginxLogs: re-exports ApacheLogs (NixPanel uses Apache, not NGINX) - WebServer page: shows apache2, php8.2-fpm, php8.3-fpm status with reload buttons; config test; Apache log viewer Bug fixes: - Accounts.tsx: Lucide icon size prop type widened (string|number); removed unused Users import; dropped duplicate NGINX log card - Removed soon badge from ShellAccess and ApacheLogs entries Project: - CHANGELOG.md created with full history from 0.2.x through 0.4.0 - All packages bumped from 0.3.2 to 0.4.0
This commit is contained in:
@@ -188,14 +188,14 @@ export default async function authRoutes(fastify: FastifyInstance) {
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 7)
|
||||
|
||||
const createData: Parameters<typeof prisma.refreshToken.create>[0]['data'] = {
|
||||
const createData = {
|
||||
token: newRefresh,
|
||||
role: payload.role,
|
||||
expiresAt,
|
||||
adminId: stored.adminId ?? undefined,
|
||||
resellerId: stored.resellerId ?? undefined,
|
||||
accountId: stored.accountId ?? undefined,
|
||||
}
|
||||
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 })
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ async function writeCrontab(username: string, accountId: number): Promise<void>
|
||||
`# NixPanel crontab for ${username}`,
|
||||
`# DO NOT EDIT — managed by NixPanel`,
|
||||
'',
|
||||
...jobs.map(j => `${j.schedule} ${j.command}${j.comment ? ` # ${j.comment}` : ''}`),
|
||||
...jobs.map((j: { schedule: string; command: string; comment: string | null }) => `${j.schedule} ${j.command}${j.comment ? ` # ${j.comment}` : ''}`),
|
||||
'',
|
||||
]
|
||||
|
||||
|
||||
@@ -3,11 +3,15 @@ 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'
|
||||
import {
|
||||
createDatabase,
|
||||
dropDatabase,
|
||||
createDatabaseUser,
|
||||
grantPrivileges,
|
||||
dropDatabaseUser,
|
||||
} from '../../core/mariadb.js'
|
||||
|
||||
function mysqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "\\'").replace(/\\/g, '\\\\')
|
||||
}
|
||||
const nameSchema = z.string().min(1).max(32).regex(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores allowed')
|
||||
|
||||
export default async function databaseRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
@@ -15,7 +19,9 @@ export default async function databaseRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/nixclient/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 accountId = user.role === 'user'
|
||||
? user.sub
|
||||
: parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const databases = await prisma.database.findMany({
|
||||
where: { accountId },
|
||||
@@ -28,10 +34,12 @@ export default async function databaseRoutes(fastify: FastifyInstance) {
|
||||
// POST /api/nixclient/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 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_]+$/),
|
||||
name: nameSchema,
|
||||
charset: z.string().default('utf8mb4'),
|
||||
collation: z.string().default('utf8mb4_unicode_ci'),
|
||||
}).safeParse(request.body)
|
||||
@@ -46,7 +54,6 @@ export default async function databaseRoutes(fastify: FastifyInstance) {
|
||||
})
|
||||
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) {
|
||||
@@ -54,19 +61,11 @@ export default async function databaseRoutes(fastify: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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}` })
|
||||
}
|
||||
await createDatabase(dbName, body.data.charset, body.data.collation)
|
||||
|
||||
const db = await prisma.database.create({
|
||||
data: { name: dbName, charset: body.data.charset, collation: body.data.collation, accountId },
|
||||
@@ -84,10 +83,15 @@ export default async function databaseRoutes(fastify: FastifyInstance) {
|
||||
id: parseInt(id, 10),
|
||||
...(user.role === 'user' ? { accountId: user.sub } : {}),
|
||||
},
|
||||
include: { dbUsers: true },
|
||||
})
|
||||
if (!db) return reply.code(404).send({ success: false, error: 'Database not found' })
|
||||
|
||||
await exec('mysql', ['-e', `DROP DATABASE IF EXISTS \`${mysqlEscape(db.name)}\`;`])
|
||||
// Drop all associated users first
|
||||
for (const dbUser of db.dbUsers) {
|
||||
await dropDatabaseUser(dbUser.username)
|
||||
}
|
||||
await dropDatabase(db.name)
|
||||
await prisma.database.delete({ where: { id: db.id } })
|
||||
|
||||
return reply.send({ success: true, data: { message: `Database ${db.name} deleted` } })
|
||||
@@ -99,9 +103,9 @@ export default async function databaseRoutes(fastify: FastifyInstance) {
|
||||
const user = request.user!
|
||||
|
||||
const body = z.object({
|
||||
username: z.string().min(1).max(16).regex(/^[a-zA-Z0-9_]+$/),
|
||||
username: nameSchema,
|
||||
password: z.string().min(8),
|
||||
privileges: z.string().default('ALL'),
|
||||
privileges: z.enum(['ALL', 'SELECT', 'SELECT,INSERT,UPDATE,DELETE']).default('ALL'),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
@@ -118,12 +122,9 @@ export default async function databaseRoutes(fastify: FastifyInstance) {
|
||||
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;'])
|
||||
await createDatabaseUser(dbUsername, body.data.password)
|
||||
await grantPrivileges(db.name, dbUsername, body.data.privileges)
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
const dbUser = await prisma.databaseUser.create({
|
||||
@@ -142,14 +143,18 @@ export default async function databaseRoutes(fastify: FastifyInstance) {
|
||||
// DELETE /api/nixclient/databases/:id/users/:userId
|
||||
fastify.delete('/:id/users/:userId', { preHandler }, async (request, reply) => {
|
||||
const { id, userId } = request.params as { id: string; userId: string }
|
||||
const user = request.user!
|
||||
|
||||
const dbUser = await prisma.databaseUser.findFirst({
|
||||
where: { id: parseInt(userId, 10), databaseId: parseInt(id, 10) },
|
||||
where: {
|
||||
id: parseInt(userId, 10),
|
||||
databaseId: parseInt(id, 10),
|
||||
...(user.role === 'user' ? { database: { accountId: user.sub } } : {}),
|
||||
},
|
||||
})
|
||||
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 dropDatabaseUser(dbUser.username)
|
||||
await prisma.databaseUser.delete({ where: { id: dbUser.id } })
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Database user deleted' } })
|
||||
|
||||
@@ -3,7 +3,7 @@ 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'
|
||||
import { enableVhost, disableVhost } from '../../core/apache.js'
|
||||
|
||||
export default async function domainRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
@@ -48,7 +48,7 @@ export default async function domainRoutes(fastify: FastifyInstance) {
|
||||
await exec('mkdir', ['-p', docRoot])
|
||||
await exec('chown', [`${account.username}:${account.username}`, docRoot])
|
||||
|
||||
// Create nginx vhost for subdomain
|
||||
// Create Apache vhost for subdomain
|
||||
try {
|
||||
await enableVhost({ domain: fqdn, username: account.username, documentRoot: docRoot })
|
||||
} catch (e) {
|
||||
@@ -70,7 +70,7 @@ export default async function domainRoutes(fastify: FastifyInstance) {
|
||||
})
|
||||
if (!subdomain) return reply.code(404).send({ success: false, error: 'Subdomain not found' })
|
||||
|
||||
const { disableVhost } = await import('../../core/nginx.js')
|
||||
// disableVhost already imported at top
|
||||
try { await disableVhost(subdomain.fqdn) } catch {}
|
||||
|
||||
await prisma.subdomain.delete({ where: { id: subdomain.id } })
|
||||
@@ -131,7 +131,7 @@ export default async function domainRoutes(fastify: FastifyInstance) {
|
||||
})
|
||||
if (!addon) return reply.code(404).send({ success: false, error: 'Addon domain not found' })
|
||||
|
||||
const { disableVhost } = await import('../../core/nginx.js')
|
||||
// disableVhost already imported at top
|
||||
try { await disableVhost(addon.domain) } catch {}
|
||||
|
||||
await prisma.addonDomain.delete({ where: { id: addon.id } })
|
||||
@@ -161,7 +161,7 @@ export default async function domainRoutes(fastify: FastifyInstance) {
|
||||
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
|
||||
// Parked domains point to main public_html (Apache vhost with alias)
|
||||
try {
|
||||
await enableVhost({
|
||||
domain: body.data.domain,
|
||||
@@ -188,7 +188,7 @@ export default async function domainRoutes(fastify: FastifyInstance) {
|
||||
})
|
||||
if (!parked) return reply.code(404).send({ success: false, error: 'Parked domain not found' })
|
||||
|
||||
const { disableVhost } = await import('../../core/nginx.js')
|
||||
// disableVhost already imported at top
|
||||
try { await disableVhost(parked.domain) } catch {}
|
||||
|
||||
await prisma.parkedDomain.delete({ where: { id: parked.id } })
|
||||
|
||||
@@ -3,9 +3,9 @@ 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'
|
||||
import { createMailbox, removeMailbox, addForwarder, removeForwarder, updateMailboxPassword } from '../../core/mail.js'
|
||||
|
||||
export default async function cpanelEmailRoutes(fastify: FastifyInstance) {
|
||||
export default async function emailRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
// GET /api/nixclient/email
|
||||
@@ -27,7 +27,7 @@ export default async function cpanelEmailRoutes(fastify: FastifyInstance) {
|
||||
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._+-]+$/),
|
||||
username: z.string().min(1).max(64).regex(/^[a-zA-Z0-9._+-]+$/, 'Invalid email username'),
|
||||
password: z.string().min(8),
|
||||
quotaMb: z.number().int().min(0).default(500),
|
||||
}).safeParse(request.body)
|
||||
@@ -50,33 +50,72 @@ export default async function cpanelEmailRoutes(fastify: FastifyInstance) {
|
||||
const exists = await prisma.emailAccount.findUnique({ where: { address } })
|
||||
if (exists) return reply.code(409).send({ success: false, error: 'Email address already exists' })
|
||||
|
||||
await createMailbox(account.username, address, body.data.password)
|
||||
|
||||
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 },
|
||||
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 })
|
||||
})
|
||||
|
||||
// PUT /api/nixclient/email/:id/password
|
||||
fastify.put('/:id/password', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
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: body.error.issues[0]?.message })
|
||||
|
||||
const emailAccount = await prisma.emailAccount.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
})
|
||||
if (!emailAccount) return reply.code(404).send({ success: false, error: 'Email account not found' })
|
||||
|
||||
await updateMailboxPassword(emailAccount.address, body.data.password)
|
||||
|
||||
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' } })
|
||||
})
|
||||
|
||||
// DELETE /api/nixclient/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) } })
|
||||
const emailAccount = await prisma.emailAccount.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
include: { account: true },
|
||||
})
|
||||
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' })
|
||||
|
||||
// Check if domain has other mailboxes before potentially removing the domain entry
|
||||
const remaining = await prisma.emailAccount.count({
|
||||
where: { accountId: emailAccount.accountId, domain: emailAccount.domain, id: { not: emailAccount.id } },
|
||||
})
|
||||
|
||||
await removeMailbox(emailAccount.account.username, emailAccount.address, true)
|
||||
|
||||
if (remaining === 0) {
|
||||
const { removeVirtualDomain } = await import('../../core/mail.js')
|
||||
await removeVirtualDomain(emailAccount.domain)
|
||||
}
|
||||
|
||||
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' } })
|
||||
})
|
||||
|
||||
@@ -103,6 +142,13 @@ export default async function cpanelEmailRoutes(fastify: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const existing = await prisma.emailForwarder.findFirst({
|
||||
where: { source: body.data.source, accountId },
|
||||
})
|
||||
if (existing) return reply.code(409).send({ success: false, error: 'Forwarder already exists' })
|
||||
|
||||
await addForwarder(body.data.source, body.data.destination)
|
||||
|
||||
const forwarder = await prisma.emailForwarder.create({ data: { ...body.data, accountId } })
|
||||
return reply.code(201).send({ success: true, data: forwarder })
|
||||
})
|
||||
@@ -110,15 +156,14 @@ export default async function cpanelEmailRoutes(fastify: FastifyInstance) {
|
||||
// DELETE /api/nixclient/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) } })
|
||||
const forwarder = await prisma.emailForwarder.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
})
|
||||
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 removeForwarder(forwarder.source)
|
||||
await prisma.emailForwarder.delete({ where: { id: forwarder.id } })
|
||||
return reply.send({ success: true, data: { message: 'Forwarder deleted' } })
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { enableVhost, disableVhost, suspendVhost, enablePhpFpmPool, disablePhpFpmPool } from '../../core/apache.js'
|
||||
import { createZone, removeZone } from '../../core/dns.js'
|
||||
import { config } from '../../config.js'
|
||||
|
||||
@@ -83,6 +83,218 @@ export default async function accountRoutes(fastify: FastifyInstance) {
|
||||
return reply.send({ success: true, data: { total, active, suspended, terminated } })
|
||||
})
|
||||
|
||||
// GET /api/nixserver/accounts/parked-domains
|
||||
fastify.get('/parked-domains', { preHandler }, async (request, reply) => {
|
||||
const where = request.user!.role === 'reseller'
|
||||
? { account: { resellerId: request.user!.sub } }
|
||||
: {}
|
||||
|
||||
const items = await prisma.parkedDomain.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
account: { select: { username: true, domain: true } },
|
||||
},
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: items })
|
||||
})
|
||||
|
||||
// GET /api/nixserver/accounts/subdomains
|
||||
fastify.get('/subdomains', { preHandler }, async (request, reply) => {
|
||||
const where = request.user!.role === 'reseller'
|
||||
? { account: { resellerId: request.user!.sub } }
|
||||
: {}
|
||||
|
||||
const items = await prisma.subdomain.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
account: { select: { username: true } },
|
||||
},
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: items })
|
||||
})
|
||||
|
||||
// GET /api/nixserver/accounts/over-quota
|
||||
fastify.get('/over-quota', { preHandler }, async (request, reply) => {
|
||||
const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
|
||||
|
||||
const accounts = await prisma.account.findMany({
|
||||
where: {
|
||||
...resellerWhere,
|
||||
package: { isNot: null },
|
||||
},
|
||||
select: {
|
||||
id: true, username: true, domain: true, status: true,
|
||||
diskUsedMb: true,
|
||||
package: { select: { name: true, diskMb: true, bandwidthMb: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const overQuota = accounts.filter((a: typeof accounts[number]) => a.package && a.diskUsedMb > a.package.diskMb)
|
||||
|
||||
return reply.send({ success: true, data: overQuota })
|
||||
})
|
||||
|
||||
// GET /api/nixserver/accounts/bandwidth
|
||||
fastify.get('/bandwidth', { preHandler }, async (request, reply) => {
|
||||
const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
|
||||
|
||||
const items = await prisma.account.findMany({
|
||||
where: resellerWhere,
|
||||
orderBy: { bandwidthUsedMb: 'desc' },
|
||||
select: {
|
||||
id: true, username: true, domain: true, status: true,
|
||||
bandwidthUsedMb: true, suspendReason: true,
|
||||
package: { select: { name: true, bandwidthMb: true } },
|
||||
},
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: items })
|
||||
})
|
||||
|
||||
// POST /api/nixserver/accounts/:id/change-ip
|
||||
fastify.post('/:id/change-ip', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const body = z.object({ ipAddress: z.string().min(1) }).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 account = await prisma.account.findFirst({ where })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
const updated = await prisma.account.update({
|
||||
where: { id: account.id },
|
||||
data: { ipAddress: body.data.ipAddress },
|
||||
})
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
action: 'account.change-ip',
|
||||
target: account.username,
|
||||
details: `IP changed to ${body.data.ipAddress}`,
|
||||
sourceIp: request.ip,
|
||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||
},
|
||||
})
|
||||
|
||||
const { passwordHash: _, ...safe } = updated
|
||||
return reply.send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
// POST /api/nixserver/accounts/:id/reset-bandwidth
|
||||
fastify.post('/:id/reset-bandwidth', { 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 })
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
await prisma.account.update({
|
||||
where: { id: account.id },
|
||||
data: { bandwidthUsedMb: 0 },
|
||||
})
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
action: 'account.reset-bandwidth',
|
||||
target: account.username,
|
||||
sourceIp: request.ip,
|
||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||
},
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: { message: `Bandwidth reset for ${account.username}` } })
|
||||
})
|
||||
|
||||
// PUT /api/nixserver/accounts/:id/quota
|
||||
fastify.put('/:id/quota', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const body = z.object({
|
||||
diskMb: z.number().int().positive(),
|
||||
bandwidthMb: z.number().int().positive(),
|
||||
}).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 account = await prisma.account.findFirst({
|
||||
where,
|
||||
include: { package: true },
|
||||
})
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
if (account.packageId) {
|
||||
await prisma.package.update({
|
||||
where: { id: account.packageId },
|
||||
data: { diskMb: body.data.diskMb, bandwidthMb: body.data.bandwidthMb },
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
action: 'account.quota-modification',
|
||||
target: account.username,
|
||||
details: `diskMb=${body.data.diskMb}, bandwidthMb=${body.data.bandwidthMb}`,
|
||||
sourceIp: request.ip,
|
||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||
},
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Quota updated', ...body.data } })
|
||||
})
|
||||
|
||||
// POST /api/nixserver/accounts/unsuspend-bandwidth-exceeded
|
||||
fastify.post('/unsuspend-bandwidth-exceeded', { preHandler }, async (request, reply) => {
|
||||
const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
|
||||
|
||||
const accounts = await prisma.account.findMany({
|
||||
where: {
|
||||
...resellerWhere,
|
||||
status: 'suspended',
|
||||
suspendReason: { contains: 'bandwidth' },
|
||||
},
|
||||
})
|
||||
|
||||
const results: { id: number; username: string; success: boolean; error?: string }[] = []
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
await exec('usermod', ['-U', account.username])
|
||||
await prisma.account.update({
|
||||
where: { id: account.id },
|
||||
data: { status: 'active', suspendedAt: null, suspendReason: null },
|
||||
})
|
||||
results.push({ id: account.id, username: account.username, success: true })
|
||||
} catch (e) {
|
||||
results.push({ id: account.id, username: account.username, success: false, error: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
action: 'account.unsuspend-bandwidth-exceeded',
|
||||
target: `${results.length} accounts`,
|
||||
sourceIp: request.ip,
|
||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||
},
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: results })
|
||||
})
|
||||
|
||||
// GET /api/nixserver/accounts/:id
|
||||
fastify.get('/:id', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
@@ -139,7 +351,7 @@ export default async function accountRoutes(fastify: FastifyInstance) {
|
||||
|
||||
// Create system user
|
||||
const userAdd = await exec('useradd', [
|
||||
'-m', '-d', homedir, '-s', '/bin/false', username,
|
||||
'-m', '-d', homedir, '-s', '/usr/sbin/nologin', username,
|
||||
])
|
||||
if (userAdd.exitCode !== 0 && !config.isDev) {
|
||||
return reply.code(500).send({ success: false, error: `Failed to create system user: ${userAdd.stderr}` })
|
||||
@@ -202,15 +414,13 @@ export default async function accountRoutes(fastify: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
// Create nginx vhost
|
||||
// Create Apache vhost + PHP-FPM pool
|
||||
const phpVersion = (await prisma.package.findUnique({ where: { id: packageId ?? 0 } }))?.phpVersion ?? '8.3'
|
||||
try {
|
||||
await enableVhost({
|
||||
domain,
|
||||
username,
|
||||
documentRoot: `${homedir}/public_html`,
|
||||
})
|
||||
await enablePhpFpmPool(username, phpVersion)
|
||||
await enableVhost({ domain, username, documentRoot: `${homedir}/public_html`, phpVersion })
|
||||
} catch (e) {
|
||||
console.warn(`Nginx vhost creation failed for ${domain}:`, e)
|
||||
console.warn(`Apache/PHP-FPM setup failed for ${domain}:`, e)
|
||||
}
|
||||
|
||||
// Audit log
|
||||
@@ -272,6 +482,9 @@ export default async function accountRoutes(fastify: FastifyInstance) {
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Active account not found' })
|
||||
|
||||
await exec('usermod', ['-L', account.username])
|
||||
try { await suspendVhost(account.domain, account.username) } catch (e) {
|
||||
console.warn(`suspendVhost failed for ${account.domain}:`, e)
|
||||
}
|
||||
|
||||
await prisma.account.update({
|
||||
where: { id: account.id },
|
||||
@@ -301,6 +514,11 @@ export default async function accountRoutes(fastify: FastifyInstance) {
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Suspended account not found' })
|
||||
|
||||
await exec('usermod', ['-U', account.username])
|
||||
try {
|
||||
await enableVhost({ domain: account.domain, username: account.username, documentRoot: `${account.homedir}/public_html` })
|
||||
} catch (e) {
|
||||
console.warn(`enableVhost failed on unsuspend for ${account.domain}:`, e)
|
||||
}
|
||||
|
||||
await prisma.account.update({
|
||||
where: { id: account.id },
|
||||
@@ -327,12 +545,20 @@ export default async function accountRoutes(fastify: FastifyInstance) {
|
||||
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
|
||||
// Remove Apache vhost + PHP-FPM pool
|
||||
try {
|
||||
await disableVhost(account.domain)
|
||||
} catch (e) {
|
||||
console.warn(`Failed to remove vhost for ${account.domain}:`, e)
|
||||
}
|
||||
try {
|
||||
const phpVersion = account.packageId
|
||||
? (await prisma.package.findUnique({ where: { id: account.packageId } }))?.phpVersion ?? '8.3'
|
||||
: '8.3'
|
||||
await disablePhpFpmPool(account.username, phpVersion)
|
||||
} catch (e) {
|
||||
console.warn(`Failed to remove PHP-FPM pool for ${account.username}:`, e)
|
||||
}
|
||||
|
||||
// Remove DNS zone
|
||||
try {
|
||||
@@ -364,6 +590,33 @@ export default async function accountRoutes(fastify: FastifyInstance) {
|
||||
return reply.send({ success: true, data: { message: `Account ${account.username} terminated` } })
|
||||
})
|
||||
|
||||
// PUT /api/nixserver/accounts/:id/shell — enable or disable shell access
|
||||
fastify.put('/:id/shell', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const { enabled } = (request.body ?? {}) as { enabled?: boolean }
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return reply.code(400).send({ success: false, error: '"enabled" boolean required' })
|
||||
}
|
||||
|
||||
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 shell = enabled ? '/bin/bash' : '/usr/sbin/nologin'
|
||||
await exec('usermod', ['-s', shell, account.username])
|
||||
await prisma.account.update({ where: { id: account.id }, data: { shell } })
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
action: enabled ? 'account.shell.enable' : 'account.shell.disable',
|
||||
target: account.username,
|
||||
sourceIp: request.ip,
|
||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||
},
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: { shell } })
|
||||
})
|
||||
|
||||
// POST /api/nixserver/accounts/bulk
|
||||
fastify.post('/bulk', { preHandler }, async (request, reply) => {
|
||||
const body = z.object({
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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'
|
||||
import { enableVhost } from '../../core/apache.js'
|
||||
import { getCertPaths } from '../../core/ssl.js'
|
||||
|
||||
export default async function sslRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAdminOrReseller]
|
||||
@@ -47,23 +46,19 @@ export default async function sslRoutes(fastify: FastifyInstance) {
|
||||
if (includeWww) domains.push(`www.${domain}`)
|
||||
|
||||
const certbotArgs = [
|
||||
'certonly', '--webroot',
|
||||
'-w', config.paths.certbotWebroot,
|
||||
'certonly', '--apache',
|
||||
'--non-interactive',
|
||||
'--agree-tos',
|
||||
'--email', account.email,
|
||||
...domains.flatMap(d => ['-d', d]),
|
||||
]
|
||||
|
||||
const result = await exec('certbot', certbotArgs)
|
||||
const result = await exec('certbot', certbotArgs, { timeout: 120_000 })
|
||||
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 { certPath, keyPath, chainPath } = getCertPaths(domain)
|
||||
|
||||
const issuedAt = new Date()
|
||||
const expiresAt = new Date()
|
||||
@@ -82,7 +77,7 @@ export default async function sslRoutes(fastify: FastifyInstance) {
|
||||
})
|
||||
}
|
||||
|
||||
// Update nginx vhost with SSL
|
||||
// Regenerate Apache vhost with SSL enabled
|
||||
try {
|
||||
await enableVhost({
|
||||
domain,
|
||||
@@ -91,10 +86,9 @@ export default async function sslRoutes(fastify: FastifyInstance) {
|
||||
sslEnabled: true,
|
||||
certPath,
|
||||
keyPath,
|
||||
chainPath,
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn(`Nginx SSL update failed for ${domain}:`, e)
|
||||
console.warn(`Apache SSL vhost update failed for ${domain}:`, e)
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: cert })
|
||||
|
||||
@@ -1,34 +1,42 @@
|
||||
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'
|
||||
import { readdir } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
export default async function webserverRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/nixserver/webserver/status
|
||||
fastify.get('/status', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const [nginx, apache] = await Promise.all([
|
||||
exec('systemctl', ['is-active', 'nginx']),
|
||||
const [apache, fpm82, fpm83] = await Promise.all([
|
||||
exec('systemctl', ['is-active', 'apache2']),
|
||||
exec('systemctl', ['is-active', 'php8.2-fpm']),
|
||||
exec('systemctl', ['is-active', 'php8.3-fpm']),
|
||||
])
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
nginx: { active: nginx.stdout.trim() === 'active' },
|
||||
apache: { active: apache.stdout.trim() === 'active' },
|
||||
php82fpm: { active: fpm82.stdout.trim() === 'active' },
|
||||
php83fpm: { active: fpm83.stdout.trim() === 'active' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/nixserver/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 })
|
||||
try {
|
||||
const files = await readdir('/etc/apache2/sites-enabled')
|
||||
const vhosts = files
|
||||
.filter(f => f.endsWith('.conf'))
|
||||
.map(f => ({
|
||||
path: `/etc/apache2/sites-enabled/${f}`,
|
||||
domain: f.replace('.conf', ''),
|
||||
}))
|
||||
return reply.send({ success: true, data: vhosts })
|
||||
} catch {
|
||||
return reply.send({ success: true, data: [] })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/nixserver/webserver/vhosts/:domain
|
||||
@@ -38,36 +46,60 @@ export default async function webserverRoutes(fastify: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid domain' })
|
||||
}
|
||||
try {
|
||||
const content = await readFile(`/etc/nginx/sites-available/${domain}.conf`, 'utf8')
|
||||
const content = await readFile(`/etc/apache2/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/nixserver/webserver/nginx/test
|
||||
fastify.post('/nginx/test', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const result = await exec('nginx', ['-t'])
|
||||
// POST /api/nixserver/webserver/apache/test
|
||||
fastify.post('/apache/test', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const result = await exec('apache2ctl', ['configtest'])
|
||||
return reply.send({
|
||||
success: result.exitCode === 0,
|
||||
data: { output: result.stdout + result.stderr },
|
||||
})
|
||||
})
|
||||
|
||||
// POST /api/nixserver/webserver/nginx/reload
|
||||
fastify.post('/nginx/reload', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const test = await exec('nginx', ['-t'])
|
||||
// POST /api/nixserver/webserver/apache/reload
|
||||
fastify.post('/apache/reload', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const test = await exec('apache2ctl', ['configtest'])
|
||||
if (test.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: `Config test failed: ${test.stderr}` })
|
||||
}
|
||||
const result = await exec('systemctl', ['reload', 'nginx'])
|
||||
const result = await exec('systemctl', ['reload', 'apache2'])
|
||||
return reply.send({ success: result.exitCode === 0, data: { output: result.stdout } })
|
||||
})
|
||||
|
||||
// POST /api/nixserver/webserver/fpm/reload
|
||||
fastify.post('/fpm/reload', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { version } = (request.body ?? {}) as { version?: string }
|
||||
const service = version === '8.2' ? 'php8.2-fpm' : 'php8.3-fpm'
|
||||
const result = await exec('systemctl', ['reload', service])
|
||||
return reply.send({ success: result.exitCode === 0, data: { output: result.stdout } })
|
||||
})
|
||||
|
||||
// GET /api/nixserver/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 logFile = type === 'error'
|
||||
? '/var/log/apache2/error.log'
|
||||
: '/var/log/apache2/access.log'
|
||||
const result = await exec('tail', ['-n', '200', logFile])
|
||||
return reply.send({ success: true, data: { lines: result.stdout.split('\n').filter(Boolean) } })
|
||||
})
|
||||
|
||||
// GET /api/nixserver/webserver/accounts/:username/logs/:type — per-account Apache logs
|
||||
fastify.get('/accounts/:username/logs/:type', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { username, type } = request.params as { username: string; type: string }
|
||||
// Validate username — only alphanum, dash, underscore allowed
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid username' })
|
||||
}
|
||||
const logFile = type === 'error'
|
||||
? path.join('/home', username, 'logs', 'error.log')
|
||||
: path.join('/home', username, 'logs', '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