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
+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' } })
})