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:
NixReaper
2026-04-11 05:17:49 -05:00
parent 10e31482b0
commit 8ff00c544c
65 changed files with 11223 additions and 1259 deletions
+4 -4
View File
@@ -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 })
+1 -1
View File
@@ -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}` : ''}`),
'',
]
+33 -28
View File
@@ -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' } })
+6 -6
View File
@@ -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 } })
+63 -18
View File
@@ -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' } })
})
+263 -10
View File
@@ -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({
+7 -13
View File
@@ -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 })
+52 -20
View File
@@ -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) } })
})