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
+17
View File
@@ -0,0 +1,17 @@
# Database (SQLite)
# Production: DATABASE_URL="file:/opt/nixpanel/data/nixpanel.db"
# Development: DATABASE_URL="file:./dev.db"
DATABASE_URL="file:./dev.db"
# JWT Secrets (generate with: openssl rand -hex 64)
JWT_SECRET=""
JWT_REFRESH_SECRET=""
# Server
PORT=4000
HOST=127.0.0.1
NODE_ENV=production
# Licensing
LICENSING_SERVER_URL="https://license.nixpanel.io"
LICENSE_KEY=""
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nixpanel/server",
"version": "0.3.1",
"version": "0.4.0",
"private": true,
"type": "module",
"main": "dist/index.js",
@@ -24,6 +24,7 @@
"@fastify/websocket": "^10.0.1",
"@prisma/client": "^5.22.0",
"bcryptjs": "^2.4.3",
"mysql2": "^3.11.5",
"dotenv": "^16.4.5",
"fastify": "^4.28.1",
"node-cron": "^3.0.3",
@@ -32,6 +33,7 @@
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.9.0",
"@types/node-cron": "^3.0.11",
"prisma": "^5.22.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
+193 -202
View File
@@ -3,26 +3,26 @@ generator client {
}
datasource db {
provider = "mysql"
provider = "sqlite"
url = env("DATABASE_URL")
}
// ─── Admin / WHM Users ───────────────────────────────────────────────────────
model AdminUser {
id Int @id @default(autoincrement())
username String @unique @db.VarChar(64)
passwordHash String @db.VarChar(255)
email String @unique @db.VarChar(255)
twoFactorSecret String? @db.VarChar(64)
twoFactorEnabled Boolean @default(false)
lastLogin DateTime?
lastLoginIp String? @db.VarChar(45)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id Int @id @default(autoincrement())
username String @unique
passwordHash String
email String @unique
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
lastLogin DateTime?
lastLoginIp String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
refreshTokens RefreshToken[]
auditLogs AuditLog[]
refreshTokens RefreshToken[]
auditLogs AuditLog[]
@@map("admin_users")
}
@@ -31,25 +31,26 @@ model AdminUser {
model Reseller {
id Int @id @default(autoincrement())
username String @unique @db.VarChar(64)
passwordHash String @db.VarChar(255)
email String @unique @db.VarChar(255)
contactName String? @db.VarChar(255)
company String? @db.VarChar(255)
username String @unique
passwordHash String
email String @unique
contactName String?
company String?
maxAccounts Int @default(0)
diskLimitMb BigInt @default(0)
bandwidthLimitMb BigInt @default(0)
diskUsedMb BigInt @default(0)
bandwidthUsedMb BigInt @default(0)
status String @default("active") @db.VarChar(20)
brandName String? @db.VarChar(255)
brandLogo String? @db.VarChar(500)
notes String? @db.Text
status String @default("active")
brandName String?
brandLogo String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
refreshTokens RefreshToken[]
auditLogs AuditLog[]
@@map("resellers")
}
@@ -57,70 +58,70 @@ model Reseller {
// ─── Hosting Packages ────────────────────────────────────────────────────────
model Package {
id Int @id @default(autoincrement())
name String @unique @db.VarChar(100)
diskMb BigInt @default(1024)
bandwidthMb BigInt @default(10240)
maxDatabases Int @default(5)
maxEmailAccounts Int @default(10)
maxSubdomains Int @default(10)
maxAddonDomains Int @default(5)
maxParkedDomains Int @default(5)
maxFtpAccounts Int @default(3)
maxCronJobs Int @default(5)
phpVersion String @default("8.3") @db.VarChar(10)
sslEnabled Boolean @default(true)
backupEnabled Boolean @default(true)
dedicatedIp Boolean @default(false)
notes String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id Int @id @default(autoincrement())
name String @unique
diskMb BigInt @default(1024)
bandwidthMb BigInt @default(10240)
maxDatabases Int @default(5)
maxEmailAccounts Int @default(10)
maxSubdomains Int @default(10)
maxAddonDomains Int @default(5)
maxParkedDomains Int @default(5)
maxFtpAccounts Int @default(3)
maxCronJobs Int @default(5)
phpVersion String @default("8.3")
sslEnabled Boolean @default(true)
backupEnabled Boolean @default(true)
dedicatedIp Boolean @default(false)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
accounts Account[]
@@map("packages")
}
// ─── Hosting Accounts (cPanel users) ─────────────────────────────────────────
// ─── Hosting Accounts ────────────────────────────────────────────────────────
model Account {
id Int @id @default(autoincrement())
username String @unique @db.VarChar(64)
passwordHash String @db.VarChar(255)
email String @db.VarChar(255)
domain String @unique @db.VarChar(255)
homedir String @db.VarChar(500)
shell String @default("/bin/false") @db.VarChar(100)
status String @default("active") @db.VarChar(20)
diskUsedMb BigInt @default(0)
bandwidthUsedMb BigInt @default(0)
ipAddress String? @db.VarChar(45)
nameservers String? @db.VarChar(500)
notes String? @db.Text
suspendedAt DateTime?
suspendReason String? @db.VarChar(500)
terminatedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id Int @id @default(autoincrement())
username String @unique
passwordHash String
email String
domain String @unique
homedir String
shell String @default("/usr/sbin/nologin")
status String @default("active")
diskUsedMb BigInt @default(0)
bandwidthUsedMb BigInt @default(0)
ipAddress String?
nameservers String?
notes String?
suspendedAt DateTime?
suspendReason String?
terminatedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
resellerId Int?
reseller Reseller? @relation(fields: [resellerId], references: [id])
packageId Int?
package Package? @relation(fields: [packageId], references: [id])
ipId Int?
ip IpAddress? @relation(fields: [ipId], references: [id])
resellerId Int?
reseller Reseller? @relation(fields: [resellerId], references: [id])
packageId Int?
package Package? @relation(fields: [packageId], references: [id])
ipId Int?
ip IpAddress? @relation(fields: [ipId], references: [id])
dnsZones DnsZone[]
emailAccounts EmailAccount[]
emailForwarders EmailForwarder[]
ftpAccounts FtpAccount[]
databases Database[]
subdomains Subdomain[]
addonDomains AddonDomain[]
parkedDomains ParkedDomain[]
sslCertificates SslCertificate[]
cronJobs CronJob[]
refreshTokens RefreshToken[]
dnsZones DnsZone[]
emailAccounts EmailAccount[]
emailForwarders EmailForwarder[]
ftpAccounts FtpAccount[]
databases Database[]
subdomains Subdomain[]
addonDomains AddonDomain[]
parkedDomains ParkedDomain[]
sslCertificates SslCertificate[]
cronJobs CronJob[]
refreshTokens RefreshToken[]
@@map("accounts")
}
@@ -128,17 +129,17 @@ model Account {
// ─── IP Addresses ─────────────────────────────────────────────────────────────
model IpAddress {
id Int @id @default(autoincrement())
address String @unique @db.VarChar(45)
netmask String? @db.VarChar(45)
gateway String? @db.VarChar(45)
type String @default("shared") @db.VarChar(20)
status String @default("available") @db.VarChar(20)
notes String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id Int @id @default(autoincrement())
address String @unique
netmask String?
gateway String?
type String @default("shared")
status String @default("available")
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
accounts Account[]
@@map("ip_addresses")
}
@@ -146,27 +147,27 @@ model IpAddress {
// ─── DNS ──────────────────────────────────────────────────────────────────────
model DnsZone {
id Int @id @default(autoincrement())
domain String @unique @db.VarChar(255)
zoneFile String? @db.VarChar(500)
serial BigInt @default(1)
status String @default("active") @db.VarChar(20)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id Int @id @default(autoincrement())
domain String @unique
zoneFile String?
serial BigInt @default(1)
status String @default("active")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accountId Int
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
accountId Int
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
records DnsRecord[]
records DnsRecord[]
@@map("dns_zones")
}
model DnsRecord {
id Int @id @default(autoincrement())
type String @db.VarChar(10)
name String @db.VarChar(255)
value String @db.VarChar(500)
type String
name String
value String
ttl Int @default(3600)
priority Int?
createdAt DateTime @default(now())
@@ -182,13 +183,13 @@ model DnsRecord {
model EmailAccount {
id Int @id @default(autoincrement())
username String @db.VarChar(64)
domain String @db.VarChar(255)
address String @unique @db.VarChar(320)
passwordHash String @db.VarChar(255)
username String
domain String
address String @unique
passwordHash String
quotaMb Int @default(500)
usedMb Int @default(0)
status String @default("active") @db.VarChar(20)
status String @default("active")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -200,8 +201,8 @@ model EmailAccount {
model EmailForwarder {
id Int @id @default(autoincrement())
source String @db.VarChar(320)
destination String @db.VarChar(320)
source String
destination String
createdAt DateTime @default(now())
accountId Int
@@ -214,11 +215,11 @@ model EmailForwarder {
model FtpAccount {
id Int @id @default(autoincrement())
username String @db.VarChar(64)
passwordHash String @db.VarChar(255)
homedir String @db.VarChar(500)
username String
passwordHash String
homedir String
quotaMb Int @default(0)
status String @default("active") @db.VarChar(20)
status String @default("active")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -232,9 +233,9 @@ model FtpAccount {
model Database {
id Int @id @default(autoincrement())
name String @unique @db.VarChar(64)
charset String @default("utf8mb4") @db.VarChar(32)
collation String @default("utf8mb4_unicode_ci") @db.VarChar(64)
name String @unique
charset String @default("utf8mb4")
collation String @default("utf8mb4_unicode_ci")
sizeMb Float @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -249,10 +250,10 @@ model Database {
model DatabaseUser {
id Int @id @default(autoincrement())
username String @db.VarChar(64)
passwordHash String @db.VarChar(255)
host String @default("localhost") @db.VarChar(255)
privileges String @default("ALL") @db.VarChar(255)
username String
passwordHash String
host String @default("localhost")
privileges String @default("ALL")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -265,25 +266,25 @@ model DatabaseUser {
// ─── Subdomains / Addon / Parked Domains ─────────────────────────────────────
model Subdomain {
id Int @id @default(autoincrement())
subdomain String @db.VarChar(255)
domain String @db.VarChar(255)
fqdn String @unique @db.VarChar(510)
documentRoot String @db.VarChar(500)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id Int @id @default(autoincrement())
subdomain String
domain String
fqdn String @unique
documentRoot String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accountId Int
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
accountId Int
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@map("subdomains")
}
model AddonDomain {
id Int @id @default(autoincrement())
domain String @unique @db.VarChar(255)
subdomain String @db.VarChar(255)
documentRoot String @db.VarChar(500)
domain String @unique
subdomain String
documentRoot String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -295,7 +296,7 @@ model AddonDomain {
model ParkedDomain {
id Int @id @default(autoincrement())
domain String @unique @db.VarChar(255)
domain String @unique
createdAt DateTime @default(now())
accountId Int
@@ -307,21 +308,21 @@ model ParkedDomain {
// ─── SSL ──────────────────────────────────────────────────────────────────────
model SslCertificate {
id Int @id @default(autoincrement())
domain String @db.VarChar(255)
type String @default("letsencrypt") @db.VarChar(30)
certPath String? @db.VarChar(500)
keyPath String? @db.VarChar(500)
chainPath String? @db.VarChar(500)
issuedAt DateTime?
expiresAt DateTime?
autoRenew Boolean @default(true)
status String @default("pending") @db.VarChar(20)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id Int @id @default(autoincrement())
domain String
type String @default("letsencrypt")
certPath String?
keyPath String?
chainPath String?
issuedAt DateTime?
expiresAt DateTime?
autoRenew Boolean @default(true)
status String @default("pending")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accountId Int
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
accountId Int
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@map("ssl_certificates")
}
@@ -329,18 +330,18 @@ model SslCertificate {
// ─── Cron Jobs ────────────────────────────────────────────────────────────────
model CronJob {
id Int @id @default(autoincrement())
schedule String @db.VarChar(100)
command String @db.VarChar(1000)
comment String? @db.VarChar(255)
enabled Boolean @default(true)
id Int @id @default(autoincrement())
schedule String
command String
comment String?
enabled Boolean @default(true)
lastRun DateTime?
nextRun DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accountId Int
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@map("cron_jobs")
}
@@ -348,32 +349,32 @@ model CronJob {
// ─── Security ─────────────────────────────────────────────────────────────────
model SecurityEvent {
id Int @id @default(autoincrement())
type String @db.VarChar(50)
severity String @default("info") @db.VarChar(20)
message String @db.Text
sourceIp String? @db.VarChar(45)
resolved Boolean @default(false)
resolvedAt DateTime?
createdAt DateTime @default(now())
id Int @id @default(autoincrement())
type String
severity String @default("info")
message String
sourceIp String?
resolved Boolean @default(false)
resolvedAt DateTime?
createdAt DateTime @default(now())
@@map("security_events")
}
model BlockedIp {
id Int @id @default(autoincrement())
ip String @unique @db.VarChar(45)
reason String? @db.VarChar(500)
expiresAt DateTime?
createdAt DateTime @default(now())
id Int @id @default(autoincrement())
ip String @unique
reason String?
expiresAt DateTime?
createdAt DateTime @default(now())
@@map("blocked_ips")
}
model WhitelistedIp {
id Int @id @default(autoincrement())
ip String @unique @db.VarChar(45)
note String? @db.VarChar(255)
ip String @unique
note String?
createdAt DateTime @default(now())
@@map("whitelisted_ips")
@@ -382,15 +383,17 @@ model WhitelistedIp {
// ─── Audit Log ────────────────────────────────────────────────────────────────
model AuditLog {
id Int @id @default(autoincrement())
action String @db.VarChar(100)
target String? @db.VarChar(255)
details String? @db.Text
sourceIp String? @db.VarChar(45)
createdAt DateTime @default(now())
id Int @id @default(autoincrement())
action String
target String?
details String?
sourceIp String?
createdAt DateTime @default(now())
adminId Int?
admin AdminUser? @relation(fields: [adminId], references: [id])
adminId Int?
admin AdminUser? @relation(fields: [adminId], references: [id])
resellerId Int?
reseller Reseller? @relation(fields: [resellerId], references: [id])
@@map("audit_log")
}
@@ -398,19 +401,19 @@ model AuditLog {
// ─── Auth ─────────────────────────────────────────────────────────────────────
model RefreshToken {
id Int @id @default(autoincrement())
token String @unique @db.VarChar(500)
role String @db.VarChar(20)
expiresAt DateTime
revokedAt DateTime?
createdAt DateTime @default(now())
id Int @id @default(autoincrement())
token String @unique
role String
expiresAt DateTime
revokedAt DateTime?
createdAt DateTime @default(now())
adminId Int?
admin AdminUser? @relation(fields: [adminId], references: [id])
resellerId Int?
reseller Reseller? @relation(fields: [resellerId], references: [id])
accountId Int?
account Account? @relation(fields: [accountId], references: [id])
adminId Int?
admin AdminUser? @relation(fields: [adminId], references: [id])
resellerId Int?
reseller Reseller? @relation(fields: [resellerId], references: [id])
accountId Int?
account Account? @relation(fields: [accountId], references: [id])
@@map("refresh_tokens")
}
@@ -418,21 +421,9 @@ model RefreshToken {
// ─── Settings ─────────────────────────────────────────────────────────────────
model Setting {
key String @id @db.VarChar(100)
value String @db.Text
key String @id
value String
updatedAt DateTime @updatedAt
@@map("settings")
}
// ─── Terminal Sessions ────────────────────────────────────────────────────────
model TerminalSession {
id Int @id @default(autoincrement())
adminId Int
sourceIp String? @db.VarChar(45)
startedAt DateTime @default(now())
endedAt DateTime?
@@map("terminal_sessions")
}
+1 -1
View File
@@ -73,7 +73,7 @@ export async function buildApp() {
fastify.get('/api/health', async () => ({
status: 'ok',
version: '0.3.1',
version: '0.3.2',
timestamp: new Date().toISOString(),
}))
+21 -4
View File
@@ -39,12 +39,18 @@ export const config = {
paths: {
homeDir: optional('HOME_DIR', '/home'),
nginxSites: optional('NGINX_SITES_DIR', '/etc/nginx/sites-available'),
nginxEnabled: optional('NGINX_ENABLED_DIR', '/etc/nginx/sites-enabled'),
apacheSites: optional('APACHE_SITES_DIR', '/etc/apache2/sites-available'),
apacheEnabled: optional('APACHE_ENABLED_DIR', '/etc/apache2/sites-enabled'),
bindZones: optional('BIND_ZONES_DIR', '/etc/bind/zones'),
phpFpmPool82: optional('PHP_FPM_POOL_82', '/etc/php/8.2/fpm/pool.d'),
phpFpmPool83: optional('PHP_FPM_POOL_83', '/etc/php/8.3/fpm/pool.d'),
pdnsZones: optional('PDNS_ZONES_DIR', '/etc/pdns/zones'),
pdnsBindBackend: optional('PDNS_BIND_BACKEND', '/etc/pdns/bindbackend.conf'),
eximVirtualDomains: optional('EXIM_VIRTUAL_DOMAINS', '/etc/exim4/virtual_domains'),
eximVirtualMailboxes: optional('EXIM_VIRTUAL_MAILBOXES', '/etc/exim4/virtual_mailboxes'),
eximVirtualAliases: optional('EXIM_VIRTUAL_ALIASES', '/etc/exim4/virtual_aliases'),
dovecotPasswd: optional('DOVECOT_PASSWD', '/etc/dovecot/passwd'),
certbotWebroot: optional('CERTBOT_WEBROOT', '/var/www/letsencrypt'),
letsencryptLive: optional('LETSENCRYPT_LIVE', '/etc/letsencrypt/live'),
},
rateLimit: {
@@ -52,8 +58,19 @@ export const config = {
windowMs: parseInt(optional('RATE_LIMIT_WINDOW_MS', '60000'), 10),
},
mariadb: {
host: optional('MARIADB_HOST', '127.0.0.1'),
port: parseInt(optional('MARIADB_PORT', '3306'), 10),
user: optional('MARIADB_USER', 'root'),
password: optional('MARIADB_PASSWORD', ''),
},
licensing: {
serverUrl: optional('LICENSING_SERVER_URL', 'https://license.nixpanel.io'),
licenseKey: optional('LICENSE_KEY', ''),
},
features: {
terminalEnabled: optional('ENABLE_TERMINAL', 'true') === 'true',
wsStatsInterval: parseInt(optional('WEBSOCKET_STATS_INTERVAL', '5000'), 10),
},
} as const
+174
View File
@@ -0,0 +1,174 @@
import path from 'path'
import fs from 'fs/promises'
import { config } from '../config.js'
import { exec, writeFile } from './exec.js'
export interface VhostOptions {
domain: string
username: string
documentRoot: string
phpVersion?: string
sslEnabled?: boolean
certPath?: string
keyPath?: string
aliases?: string[]
}
export function generateVhost(opts: VhostOptions): string {
const {
domain,
username,
documentRoot,
phpVersion = '8.3',
sslEnabled = false,
certPath,
keyPath,
aliases = [],
} = opts
const phpSocket = `/run/php/php${phpVersion}-fpm-${username}.sock`
const serverAlias = aliases.length > 0 ? ` ServerAlias ${aliases.join(' ')}\n` : ''
const httpVhost = `<VirtualHost *:80>
ServerName ${domain}
${serverAlias} DocumentRoot ${documentRoot}
<FilesMatch \\.php$>
SetHandler "proxy:unix:${phpSocket}|fcgi://localhost"
</FilesMatch>
<Directory ${documentRoot}>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
CustomLog /var/log/apache2/${username}-access.log combined
ErrorLog /var/log/apache2/${username}-error.log
Alias /.well-known/acme-challenge/ /var/www/letsencrypt/.well-known/acme-challenge/
<Directory /var/www/letsencrypt/.well-known/acme-challenge/>
Require all granted
</Directory>
${sslEnabled ? `\n RewriteEngine On\n RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=301,L]\n` : ''}
</VirtualHost>`
if (!sslEnabled || !certPath || !keyPath) {
return httpVhost
}
const sslVhost = `
<VirtualHost *:443>
ServerName ${domain}
${serverAlias} DocumentRoot ${documentRoot}
SSLEngine on
SSLCertificateFile ${certPath}
SSLCertificateKeyFile ${keyPath}
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
SSLHonorCipherOrder off
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
<FilesMatch \\.php$>
SetHandler "proxy:unix:${phpSocket}|fcgi://localhost"
</FilesMatch>
<Directory ${documentRoot}>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
CustomLog /var/log/apache2/${username}-access.log combined
ErrorLog /var/log/apache2/${username}-error.log
</VirtualHost>`
return `${httpVhost}\n${sslVhost}`
}
export function generateSuspendedVhost(domain: string, username: string, aliases: string[] = []): string {
const serverAlias = aliases.length > 0 ? ` ServerAlias ${aliases.join(' ')}\n` : ''
return `<VirtualHost *:80>
ServerName ${domain}
${serverAlias}
DocumentRoot /var/www/html
RedirectMatch 503 ^/(?!\.well-known)
ErrorDocument 503 "<html><body><h1>Account Suspended</h1><p>This account has been suspended. Please contact support.</p></body></html>"
CustomLog /var/log/apache2/${username}-access.log combined
ErrorLog /var/log/apache2/${username}-error.log
</VirtualHost>`
}
export function generatePhpFpmPool(username: string, phpVersion: string): string {
return `[${username}]
user = ${username}
group = ${username}
listen = /run/php/php${phpVersion}-fpm-${username}.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = ondemand
pm.max_children = 10
pm.process_idle_timeout = 10s
pm.max_requests = 500
chdir = /
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec
php_admin_value[open_basedir] = /home/${username}/:/tmp/
php_admin_value[error_log] = /home/${username}/logs/php_errors.log
php_admin_flag[log_errors] = on
php_admin_flag[allow_url_fopen] = off
`
}
export async function enableVhost(opts: VhostOptions): Promise<void> {
const confFile = `${opts.domain}.conf`
const confPath = path.join(config.paths.apacheSites, confFile)
const enabledPath = path.join(config.paths.apacheEnabled, confFile)
await writeFile(confPath, generateVhost(opts))
await exec('ln', ['-sf', confPath, enabledPath])
const test = await exec('apache2ctl', ['configtest'])
if (test.exitCode !== 0) {
// Roll back on bad config
await fs.unlink(enabledPath).catch(() => {})
await fs.unlink(confPath).catch(() => {})
throw new Error(`Apache config test failed: ${test.stderr}`)
}
await exec('systemctl', ['reload', 'apache2'])
}
export async function disableVhost(domain: string): Promise<void> {
const confFile = `${domain}.conf`
await exec('a2dissite', [confFile])
await fs.unlink(path.join(config.paths.apacheSites, confFile)).catch(() => {})
await exec('systemctl', ['reload', 'apache2'])
}
export async function suspendVhost(domain: string, username: string, aliases: string[] = []): Promise<void> {
const confFile = `${domain}.conf`
const confPath = path.join(config.paths.apacheSites, confFile)
await writeFile(confPath, generateSuspendedVhost(domain, username, aliases))
await exec('systemctl', ['reload', 'apache2'])
}
export async function enablePhpFpmPool(username: string, phpVersion: string): Promise<void> {
const poolDir = phpVersion === '8.2' ? config.paths.phpFpmPool82 : config.paths.phpFpmPool83
const poolPath = path.join(poolDir, `${username}.conf`)
await writeFile(poolPath, generatePhpFpmPool(username, phpVersion))
await exec('systemctl', ['reload', `php${phpVersion}-fpm`])
}
export async function disablePhpFpmPool(username: string, phpVersion: string): Promise<void> {
const poolDir = phpVersion === '8.2' ? config.paths.phpFpmPool82 : config.paths.phpFpmPool83
const poolPath = path.join(poolDir, `${username}.conf`)
await fs.unlink(poolPath).catch(() => {})
await exec('systemctl', ['reload', `php${phpVersion}-fpm`])
}
+85 -57
View File
@@ -1,4 +1,5 @@
import path from 'path'
import fs from 'fs/promises'
import { config } from '../config.js'
import { exec, writeFile } from './exec.js'
@@ -12,9 +13,6 @@ export interface DnsZoneOptions {
ttl?: number
}
/**
* Generate a BIND9 zone file for a domain.
*/
export function generateZoneFile(opts: DnsZoneOptions): string {
const {
domain,
@@ -22,19 +20,19 @@ export function generateZoneFile(opts: DnsZoneOptions): string {
ns1,
ns2,
mail = `mail.${opts.domain}`,
serial = Math.floor(Date.now() / 1000),
serial = generateSerial(),
ttl = 3600,
} = opts
return `; Zone file for ${domain}
; Generated by NixPanel
; Managed by NixPanel — do not edit manually
$TTL ${ttl}
@ IN SOA ${ns1}. hostmaster.${domain}. (
${serial} ; Serial
3600 ; Refresh
1800 ; Retry
604800 ; Expire
300 ) ; Minimum TTL
${serial} ; Serial
3600 ; Refresh
1800 ; Retry
604800 ; Expire
300 ) ; Minimum TTL
; Name servers
@ IN NS ${ns1}.
@@ -44,12 +42,11 @@ $TTL ${ttl}
@ IN A ${ip}
www IN A ${ip}
mail IN A ${ip}
ftp IN A ${ip}
nixclient IN A ${ip}
webmail IN A ${ip}
cpanel IN A ${ip}
; MX record
@ IN MX 10 ${mail}.
@ IN MX 10 ${mail}.
; SPF
@ IN TXT "v=spf1 a mx ip4:${ip} ~all"
@@ -60,66 +57,97 @@ _dmarc IN TXT "v=DMARC1; p=none; rua=mailto:admin@${domain}"
}
/**
* Write zone file and update named.conf.local, then reload BIND.
* Create a DNS zone: write the zone file and register it with PowerDNS
* bind backend, then reload.
*/
export async function createZone(opts: DnsZoneOptions): Promise<string> {
const zoneFile = path.join(config.paths.bindZones, `db.${opts.domain}`)
const zoneFile = path.join(config.paths.pdnsZones, `db.${opts.domain}`)
await writeFile(zoneFile, generateZoneFile(opts))
await registerZoneInBindBackend(opts.domain, zoneFile)
// Add zone declaration to named.conf.local (idempotent)
const namedConf = '/etc/bind/named.conf.local'
const zoneDecl = `
zone "${opts.domain}" {
type master;
file "${zoneFile}";
allow-transfer { any; };
};
`
// Read existing, only add if not already there
const { readFile } = await import('fs/promises')
let existing = ''
try {
existing = await readFile(namedConf, 'utf8')
} catch {
// file may not exist yet in test env
// Tell PowerDNS about the new zone without a full restart
const reload = await exec('pdns_control', ['bind-add-zone', opts.domain, zoneFile])
if (reload.exitCode !== 0) {
// Fall back to full reload if bind-add-zone not supported
await exec('systemctl', ['reload', 'pdns'])
}
if (!existing.includes(`zone "${opts.domain}"`)) {
const { appendFile } = await import('fs/promises')
await appendFile(namedConf, zoneDecl)
}
// Validate zone
const check = await exec('named-checkzone', [opts.domain, zoneFile])
if (check.exitCode !== 0) {
throw new Error(`Zone validation failed: ${check.stderr}`)
}
await exec('rndc', ['reload'])
return zoneFile
}
/**
* Remove a zone and reload BIND.
* Remove a DNS zone and reload PowerDNS.
*/
export async function removeZone(domain: string): Promise<void> {
const zoneFile = path.join(config.paths.bindZones, `db.${domain}`)
await exec('rm', ['-f', zoneFile])
const zoneFile = path.join(config.paths.pdnsZones, `db.${domain}`)
await fs.unlink(zoneFile).catch(() => {})
await unregisterZoneFromBindBackend(domain)
await exec('systemctl', ['reload', 'pdns'])
}
// Remove zone block from named.conf.local
const namedConf = '/etc/bind/named.conf.local'
const { readFile, writeFile: fsWrite } = await import('fs/promises')
/**
* Update just the serial in an existing zone file to force secondary
* nameservers to re-transfer the zone.
*/
export async function bumpSerial(domain: string): Promise<void> {
const zoneFile = path.join(config.paths.pdnsZones, `db.${domain}`)
let content: string
try {
const content = await readFile(namedConf, 'utf8')
const cleaned = content.replace(
new RegExp(`zone\\s+"${domain.replace('.', '\\.')}\"[^}]+\\};\\s*`, 'g'),
''
)
await fsWrite(namedConf, cleaned, 'utf8')
content = await fs.readFile(zoneFile, 'utf8')
} catch {
throw new Error(`Zone file not found for ${domain}`)
}
const newSerial = generateSerial()
const updated = content.replace(
/(\d{10})\s*;\s*Serial/,
`${newSerial} ; Serial`
)
await fs.writeFile(zoneFile, updated, 'utf8')
await exec('pdns_control', ['bind-reload-now', domain])
}
/**
* Manage /etc/pdns/bindbackend.conf — the file that lists all zones
* PowerDNS should serve. Each zone gets one line:
* zone=domain.com:/etc/pdns/zones/db.domain.com
*/
async function registerZoneInBindBackend(domain: string, zoneFile: string): Promise<void> {
const backendConf = config.paths.pdnsBindBackend
let existing = ''
try {
existing = await fs.readFile(backendConf, 'utf8')
} catch {
// File may not exist yet on first zone creation
}
const entry = `zone=${domain}:${zoneFile}`
if (!existing.includes(`zone=${domain}`)) {
await fs.appendFile(backendConf, `${entry}\n`)
}
}
async function unregisterZoneFromBindBackend(domain: string): Promise<void> {
const backendConf = config.paths.pdnsBindBackend
try {
const content = await fs.readFile(backendConf, 'utf8')
const cleaned = content
.split('\n')
.filter(line => !line.startsWith(`zone=${domain}:`))
.join('\n')
await fs.writeFile(backendConf, cleaned, 'utf8')
} catch {
// best effort
}
}
await exec('rndc', ['reload'])
/**
* Generate a YYYYMMDDNN-style serial number based on today's date.
* Uses seconds since epoch truncated to 10 digits as a simpler alternative.
*/
function generateSerial(): number {
const now = new Date()
const date = now.toISOString().slice(0, 10).replace(/-/g, '')
// Append 01 as the revision — sufficient for daily zone changes
return parseInt(`${date}01`, 10)
}
+73 -65
View File
@@ -1,4 +1,4 @@
import { execFile } from 'child_process'
import { execFile, spawn } from 'child_process'
import { promisify } from 'util'
import { existsSync } from 'fs'
@@ -7,62 +7,49 @@ const execFileAsync = promisify(execFile)
// Commands allowed to be executed — nothing outside this list runs
const ALLOWED_COMMANDS = new Set([
// User management
'useradd', 'userdel', 'usermod', 'passwd', 'chage', 'id', 'groups',
'useradd', 'userdel', 'usermod', 'chage', 'id',
// File operations
'mkdir', 'rmdir', 'rm', 'cp', 'mv', 'chmod', 'chown', 'ln', 'tar', 'gzip', 'gunzip',
'find', 'du', 'df', 'ls', 'stat',
// Web servers
'nginx', 'apache2', 'apache2ctl', 'a2ensite', 'a2dissite', 'a2enmod', 'a2dismod',
'mkdir', 'rmdir', 'rm', 'cp', 'mv', 'chmod', 'chown', 'ln', 'tar',
'find', 'du', 'df', 'stat',
// Web server
'apache2ctl', 'a2ensite', 'a2dissite', 'a2enmod', 'a2dismod',
// DNS
'named', 'named-checkconf', 'named-checkzone', 'rndc',
'pdns_control', 'pdnsutil',
// Mail
'postfix', 'postmap', 'postconf', 'dovecot', 'doveadm',
'dovecot', 'openssl',
// SSL
'certbot', 'openssl',
// FTP
'vsftpd',
'certbot',
// Services
'systemctl', 'service',
'systemctl',
// Network
'ss', 'ip', 'hostname', 'ping', 'traceroute', 'dig', 'nslookup',
'ss', 'ip', 'hostname',
// System
'uname', 'uptime', 'free', 'vmstat', 'iostat', 'top', 'ps', 'kill', 'pkill',
'cat', 'grep', 'awk', 'sed', 'head', 'tail', 'wc', 'sort', 'uniq', 'cut',
// Package managers
'apt', 'apt-get', 'dpkg',
// MySQL/MariaDB
'mysql', 'mysqladmin', 'mysqlcheck', 'mysqldump',
'uname', 'free', 'ps',
// PHP
'php', 'php7.4', 'php8.0', 'php8.1', 'php8.2', 'php8.3', 'update-alternatives',
// Misc
'touch', 'echo', 'which', 'whoami', 'env',
'php8.2', 'php8.3',
// Fail2ban
'fail2ban-client',
// UFW
'ufw',
// iptables
'iptables', 'netfilter-persistent',
])
// Characters never allowed in arguments (shell injection prevention)
// Shell metacharacters never allowed in arguments
const DANGEROUS_CHARS = /[;&|`$<>()\n\r\0]/
// Paths outside these prefixes are blocked for file operations
// Paths allowed for file read/write operations
const ALLOWED_PATH_PREFIXES = [
'/home/',
'/etc/nginx/',
'/etc/apache2/',
'/etc/bind/',
'/etc/postfix/',
'/etc/dovecot/',
'/etc/vsftpd',
'/etc/php/',
'/etc/pdns/',
'/etc/exim4/',
'/etc/dovecot/',
'/etc/letsencrypt/',
'/etc/fail2ban/',
'/etc/nixpanel/',
'/var/www/',
'/var/mail/',
'/var/log/',
'/var/spool/cron/',
'/tmp/',
'/usr/local/nixpanel/',
'/opt/nixpanel/',
]
function validateArg(arg: string): void {
@@ -71,8 +58,8 @@ function validateArg(arg: string): void {
}
}
function isAllowedPath(path: string): boolean {
return ALLOWED_PATH_PREFIXES.some(prefix => path.startsWith(prefix))
function isAllowedPath(p: string): boolean {
return ALLOWED_PATH_PREFIXES.some(prefix => p.startsWith(prefix))
}
export interface ExecResult {
@@ -83,12 +70,13 @@ export interface ExecResult {
export interface ExecOptions {
timeout?: number
allowedPaths?: string[]
input?: string // data to pipe to stdin
}
/**
* Execute a whitelisted system command safely.
* Throws if the command is not in the allowlist or args contain shell metacharacters.
* Uses execFile (no shell). Throws if command is not in the allowlist
* or any argument contains shell metacharacters.
*/
export async function exec(
command: string,
@@ -103,12 +91,15 @@ export async function exec(
validateArg(arg)
}
const timeout = options.timeout ?? 30_000
// If stdin input is needed, use spawn so we can write to stdin
if (options.input !== undefined) {
return execWithStdin(command, args, options.input, options.timeout ?? 30_000)
}
try {
const { stdout, stderr } = await execFileAsync(command, args, {
timeout,
maxBuffer: 10 * 1024 * 1024, // 10MB
timeout: options.timeout ?? 30_000,
maxBuffer: 10 * 1024 * 1024,
})
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 }
} catch (err: unknown) {
@@ -121,52 +112,69 @@ export async function exec(
}
}
function execWithStdin(
command: string,
args: string[],
input: string,
timeout: number
): Promise<ExecResult> {
return new Promise((resolve) => {
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
child.stdout.on('data', (d: Buffer) => { stdout += d.toString() })
child.stderr.on('data', (d: Buffer) => { stderr += d.toString() })
const timer = setTimeout(() => {
child.kill()
resolve({ stdout: stdout.trim(), stderr: 'Command timed out', exitCode: 1 })
}, timeout)
child.on('close', (code) => {
clearTimeout(timer)
resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code ?? 1 })
})
child.stdin.write(input)
child.stdin.end()
})
}
/**
* Write content to a file in an allowed path using a safe method (no shell).
* Write content to a file in an allowed path.
*/
export async function writeFile(path: string, content: string): Promise<void> {
if (!isAllowedPath(path)) {
throw new Error(`Path not in allowed list: ${path}`)
export async function writeFile(filePath: string, content: string): Promise<void> {
if (!isAllowedPath(filePath)) {
throw new Error(`Path not in allowed list: ${filePath}`)
}
const { writeFile: fsWrite } = await import('fs/promises')
await fsWrite(path, content, 'utf8')
await fsWrite(filePath, content, 'utf8')
}
/**
* Read a file from an allowed path.
*/
export async function readFile(path: string): Promise<string> {
if (!isAllowedPath(path)) {
throw new Error(`Path not in allowed list: ${path}`)
export async function readFile(filePath: string): Promise<string> {
if (!isAllowedPath(filePath)) {
throw new Error(`Path not in allowed list: ${filePath}`)
}
const { readFile: fsRead } = await import('fs/promises')
return fsRead(path, 'utf8')
return fsRead(filePath, 'utf8')
}
/**
* Check if running as root.
*/
export function isRoot(): boolean {
return process.getuid?.() === 0
}
/**
* Validate a Linux username.
*/
export function validateUsername(username: string): boolean {
return /^[a-z][a-z0-9_-]{0,31}$/.test(username)
}
/**
* Validate a domain name.
*/
export function validateDomain(domain: string): boolean {
return /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(domain)
}
/**
* File exists check without shell.
*/
export function fileExists(path: string): boolean {
return existsSync(path)
export function fileExists(filePath: string): boolean {
return existsSync(filePath)
}
+99
View File
@@ -0,0 +1,99 @@
import { config } from '../config.js'
let licenseValid = false
let lastChecked: Date | null = null
/**
* Validate the license with the licensing server on startup.
* Throws if the license is invalid or the server is unreachable.
*/
export async function validateOnStartup(): Promise<void> {
if (!config.licensing.licenseKey) {
if (config.env === 'development') {
licenseValid = true
return
}
throw new Error('LICENSE_KEY is not set. Set it in .env to activate NixPanel.')
}
await checkLicense()
}
/**
* Send a heartbeat to the licensing server every 6 hours.
* If the license becomes invalid, logs a critical event but does NOT shut down —
* the panel keeps running to avoid accidental outages.
*/
export async function heartbeat(): Promise<void> {
if (!config.licensing.licenseKey) return
try {
await checkLicense()
} catch (err) {
console.error('[license] Heartbeat failed:', err)
// Log to DB if available — non-fatal, don't crash
try {
const { default: prisma } = await import('../db/client.js')
await prisma.securityEvent.create({
data: {
type: 'license_heartbeat_failed',
severity: 'warning',
message: `License heartbeat failed: ${String(err)}`,
},
})
} catch {
// DB may be unavailable — ignore
}
}
}
export function isLicenseValid(): boolean {
return licenseValid
}
export function getLastChecked(): Date | null {
return lastChecked
}
async function checkLicense(): Promise<void> {
const url = `${config.licensing.serverUrl}/api/v1/validate`
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
licenseKey: config.licensing.licenseKey,
serverIp: await getServerIp(),
}),
signal: AbortSignal.timeout(15_000),
})
if (!response.ok) {
licenseValid = false
throw new Error(`License server returned ${response.status}`)
}
const data = await response.json() as { valid: boolean; message?: string }
licenseValid = data.valid
lastChecked = new Date()
if (!data.valid) {
throw new Error(data.message ?? 'License is not valid')
}
}
async function getServerIp(): Promise<string> {
try {
const { createConnection } = await import('net')
return new Promise((resolve) => {
const socket = createConnection(80, '8.8.8.8')
socket.on('connect', () => {
resolve(socket.localAddress ?? '')
socket.destroy()
})
socket.on('error', () => resolve(''))
})
} catch {
return ''
}
}
+195
View File
@@ -0,0 +1,195 @@
import path from 'path'
import fs from 'fs/promises'
import { config } from '../config.js'
import { exec } from './exec.js'
/**
* Hash a password using SHA-512-CRYPT — the scheme Dovecot understands
* natively without any extra configuration.
*/
export async function hashMailPassword(password: string): Promise<string> {
const result = await exec('openssl', ['passwd', '-6', '-stdin'], { input: password })
if (result.exitCode !== 0 || !result.stdout.trim()) {
throw new Error(`Failed to hash mail password: ${result.stderr}`)
}
return result.stdout.trim()
}
/**
* Add an email address to /etc/dovecot/passwd.
* Format: address:hash:::::::
*/
export async function addMailboxToPasswd(address: string, passwordHash: string): Promise<void> {
const entry = `${address}:${passwordHash}:::::::\n`
await fs.appendFile(config.paths.dovecotPasswd, entry)
}
/**
* Remove an email address from /etc/dovecot/passwd.
*/
export async function removeMailboxFromPasswd(address: string): Promise<void> {
await removeLineStartingWith(config.paths.dovecotPasswd, `${address}:`)
}
/**
* Update the password for an existing mailbox in /etc/dovecot/passwd.
*/
export async function updateMailboxPassword(address: string, newPassword: string): Promise<void> {
const hash = await hashMailPassword(newPassword)
const passwd = config.paths.dovecotPasswd
const content = await fs.readFile(passwd, 'utf8')
const updated = content
.split('\n')
.map(line => {
if (line.startsWith(`${address}:`)) {
const parts = line.split(':')
parts[1] = hash
return parts.join(':')
}
return line
})
.join('\n')
await fs.writeFile(passwd, updated, 'utf8')
}
/**
* Ensure a domain is listed in /etc/exim4/virtual_domains.
*/
export async function addVirtualDomain(domain: string): Promise<void> {
const content = await readFileOrEmpty(config.paths.eximVirtualDomains)
const domains = content.split('\n').map(l => l.trim()).filter(Boolean)
if (!domains.includes(domain)) {
await fs.appendFile(config.paths.eximVirtualDomains, `${domain}\n`)
}
}
/**
* Remove a domain from /etc/exim4/virtual_domains if no mailboxes remain.
* The caller is responsible for checking whether mailboxes exist before calling.
*/
export async function removeVirtualDomain(domain: string): Promise<void> {
await removeLineStartingWith(config.paths.eximVirtualDomains, domain)
}
/**
* Add a mailbox entry to /etc/exim4/virtual_mailboxes.
* Format: address@domain domain/address/Maildir/
*/
export async function addMailboxToExim(address: string, maildirPath: string): Promise<void> {
const entry = `${address} ${maildirPath}/\n`
await fs.appendFile(config.paths.eximVirtualMailboxes, entry)
}
/**
* Remove a mailbox entry from /etc/exim4/virtual_mailboxes.
*/
export async function removeMailboxFromExim(address: string): Promise<void> {
await removeLineStartingWith(config.paths.eximVirtualMailboxes, `${address} `)
}
/**
* Add a forwarder to /etc/exim4/virtual_aliases.
* Format: source@domain destination@domain
*/
export async function addForwarder(source: string, destination: string): Promise<void> {
const entry = `${source} ${destination}\n`
await fs.appendFile(config.paths.eximVirtualAliases, entry)
}
/**
* Remove a specific forwarder from /etc/exim4/virtual_aliases.
*/
export async function removeForwarder(source: string): Promise<void> {
await removeLineStartingWith(config.paths.eximVirtualAliases, `${source} `)
}
/**
* Create the Maildir directory structure for a mailbox.
* Path: /home/{username}/mail/{domain}/{localpart}/
*/
export async function createMaildir(username: string, domain: string, localpart: string): Promise<string> {
const maildirBase = path.join(config.paths.homeDir, username, 'mail', domain, localpart)
await fs.mkdir(path.join(maildirBase, 'new'), { recursive: true })
await fs.mkdir(path.join(maildirBase, 'cur'), { recursive: true })
await fs.mkdir(path.join(maildirBase, 'tmp'), { recursive: true })
await exec('chown', ['-R', `${username}:${username}`, path.join(config.paths.homeDir, username, 'mail')])
await exec('chmod', ['700', path.join(config.paths.homeDir, username, 'mail')])
return maildirBase
}
/**
* Remove the Maildir for a mailbox.
*/
export async function removeMaildir(username: string, domain: string, localpart: string): Promise<void> {
const maildirBase = path.join(config.paths.homeDir, username, 'mail', domain, localpart)
await fs.rm(maildirBase, { recursive: true, force: true })
}
/**
* Full lifecycle: create a mailbox including Exim entry, Dovecot passwd entry,
* and Maildir on disk. Reloads both Exim and Dovecot.
*/
export async function createMailbox(
username: string,
address: string,
password: string
): Promise<void> {
const [localpart, domain] = address.split('@')
if (!localpart || !domain) throw new Error(`Invalid email address: ${address}`)
const hash = await hashMailPassword(password)
const maildirPath = await createMaildir(username, domain, localpart)
await addVirtualDomain(domain)
await addMailboxToExim(address, maildirPath)
await addMailboxToPasswd(address, hash)
await reloadMailServices()
}
/**
* Full lifecycle: remove a mailbox. Does NOT remove the virtual domain —
* caller should check if other mailboxes exist for the domain first.
*/
export async function removeMailbox(
username: string,
address: string,
deleteMaildir = false
): Promise<void> {
const [localpart, domain] = address.split('@')
if (!localpart || !domain) throw new Error(`Invalid email address: ${address}`)
await removeMailboxFromExim(address)
await removeMailboxFromPasswd(address)
if (deleteMaildir) {
await removeMaildir(username, domain, localpart)
}
await reloadMailServices()
}
async function reloadMailServices(): Promise<void> {
await exec('systemctl', ['reload', 'exim4'])
await exec('systemctl', ['reload', 'dovecot'])
}
async function readFileOrEmpty(filePath: string): Promise<string> {
try {
return await fs.readFile(filePath, 'utf8')
} catch {
return ''
}
}
async function removeLineStartingWith(filePath: string, prefix: string): Promise<void> {
try {
const content = await fs.readFile(filePath, 'utf8')
const cleaned = content
.split('\n')
.filter(line => !line.startsWith(prefix))
.join('\n')
await fs.writeFile(filePath, cleaned, 'utf8')
} catch {
// best effort — file may not exist
}
}
+163
View File
@@ -0,0 +1,163 @@
import mysql from 'mysql2/promise'
import { config } from '../config.js'
let pool: mysql.Pool | null = null
function getPool(): mysql.Pool {
if (!pool) {
pool = mysql.createPool({
host: config.mariadb.host,
port: config.mariadb.port,
user: config.mariadb.user,
password: config.mariadb.password,
waitForConnections: true,
connectionLimit: 5,
multipleStatements: false, // Never allow multiple statements
})
}
return pool
}
/**
* Create a hosting account database.
*/
export async function createDatabase(
dbName: string,
charset = 'utf8mb4',
collation = 'utf8mb4_unicode_ci'
): Promise<void> {
validateIdentifier(dbName)
validateIdentifier(charset)
validateIdentifier(collation)
const conn = await getPool().getConnection()
try {
await conn.query(
`CREATE DATABASE IF NOT EXISTS \`${dbName}\` CHARACTER SET ${charset} COLLATE ${collation}`
)
} finally {
conn.release()
}
}
/**
* Drop a hosting account database.
*/
export async function dropDatabase(dbName: string): Promise<void> {
validateIdentifier(dbName)
const conn = await getPool().getConnection()
try {
await conn.query(`DROP DATABASE IF EXISTS \`${dbName}\``)
} finally {
conn.release()
}
}
/**
* Create a MariaDB user for a hosting account database.
*/
export async function createDatabaseUser(
username: string,
password: string
): Promise<void> {
validateIdentifier(username)
const conn = await getPool().getConnection()
try {
await conn.execute(
`CREATE USER IF NOT EXISTS ?@'localhost' IDENTIFIED BY ?`,
[username, password]
)
} finally {
conn.release()
}
}
/**
* Grant privileges on a database to a user.
* privileges must be a validated string like 'ALL' or 'SELECT,INSERT,UPDATE,DELETE'.
*/
export async function grantPrivileges(
dbName: string,
username: string,
privileges: string
): Promise<void> {
validateIdentifier(dbName)
validateIdentifier(username)
const safePrivileges = validatePrivileges(privileges)
const conn = await getPool().getConnection()
try {
await conn.query(
`GRANT ${safePrivileges} ON \`${dbName}\`.* TO ?@'localhost'`,
[username]
)
await conn.query('FLUSH PRIVILEGES')
} finally {
conn.release()
}
}
/**
* Drop a MariaDB user.
*/
export async function dropDatabaseUser(username: string): Promise<void> {
validateIdentifier(username)
const conn = await getPool().getConnection()
try {
await conn.execute(`DROP USER IF EXISTS ?@'localhost'`, [username])
await conn.query('FLUSH PRIVILEGES')
} finally {
conn.release()
}
}
/**
* Get the size of a database in MB.
*/
export async function getDatabaseSizeMb(dbName: string): Promise<number> {
validateIdentifier(dbName)
const conn = await getPool().getConnection()
try {
const [rows] = await conn.execute<mysql.RowDataPacket[]>(
`SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS size_mb
FROM information_schema.tables
WHERE table_schema = ?`,
[dbName]
)
return rows[0]?.size_mb ?? 0
} finally {
conn.release()
}
}
export async function closePool(): Promise<void> {
if (pool) {
await pool.end()
pool = null
}
}
/**
* Validate that a database/user identifier only contains safe characters.
* Prevents identifier injection even when backtick-quoting.
*/
function validateIdentifier(value: string): void {
if (!/^[a-zA-Z0-9_]+$/.test(value)) {
throw new Error(`Invalid identifier: ${value}`)
}
}
/**
* Validate a privileges string against an allowlist.
*/
function validatePrivileges(privileges: string): string {
const allowed = new Set([
'ALL', 'SELECT', 'INSERT', 'UPDATE', 'DELETE',
'CREATE', 'DROP', 'INDEX', 'ALTER', 'REFERENCES',
])
const parts = privileges.toUpperCase().split(',').map(p => p.trim())
for (const part of parts) {
if (!allowed.has(part)) {
throw new Error(`Invalid privilege: ${part}`)
}
}
return parts.join(', ')
}
+97
View File
@@ -0,0 +1,97 @@
import prisma from '../db/client.js'
import { exec } from './exec.js'
import { getServiceStatus } from './system.js'
interface MonitoredService {
name: string
restartOnFail: boolean
maxRestarts: number
restartWindowMinutes: number
}
const MONITORED: MonitoredService[] = [
{ name: 'apache2', restartOnFail: true, maxRestarts: 3, restartWindowMinutes: 10 },
{ name: 'php8.2-fpm', restartOnFail: true, maxRestarts: 3, restartWindowMinutes: 10 },
{ name: 'php8.3-fpm', restartOnFail: true, maxRestarts: 3, restartWindowMinutes: 10 },
{ name: 'exim4', restartOnFail: true, maxRestarts: 3, restartWindowMinutes: 10 },
{ name: 'dovecot', restartOnFail: true, maxRestarts: 3, restartWindowMinutes: 10 },
{ name: 'pdns', restartOnFail: true, maxRestarts: 3, restartWindowMinutes: 10 },
{ name: 'mariadb', restartOnFail: true, maxRestarts: 2, restartWindowMinutes: 10 },
{ name: 'fail2ban', restartOnFail: true, maxRestarts: 2, restartWindowMinutes: 30 },
]
// In-memory restart history: service name → timestamps of recent restarts
const restartHistory = new Map<string, Date[]>()
export async function checkAll(): Promise<void> {
for (const svc of MONITORED) {
try {
await checkService(svc)
} catch (err) {
// Don't let one service check failure stop the rest
console.error(`[monitor] Error checking ${svc.name}:`, err)
}
}
}
async function checkService(svc: MonitoredService): Promise<void> {
const status = await getServiceStatus(svc.name)
if (status.status !== 'failed') return
if (!svc.restartOnFail) {
await logEvent('service_failed', `Service ${svc.name} is in failed state`, 'warning')
return
}
const recentRestarts = getRecentRestartCount(svc.name, svc.restartWindowMinutes)
if (recentRestarts >= svc.maxRestarts) {
await logEvent(
'service_restart_loop',
`Service ${svc.name} failed ${recentRestarts} times in ${svc.restartWindowMinutes} minutes — not restarting`,
'critical'
)
return
}
const result = await exec('systemctl', ['restart', svc.name])
recordRestart(svc.name)
if (result.exitCode !== 0) {
await logEvent(
'service_restart_failed',
`Failed to restart ${svc.name}: ${result.stderr}`,
'critical'
)
} else {
await logEvent(
'service_restarted',
`Service ${svc.name} was in failed state — automatically restarted`,
'warning'
)
}
}
function getRecentRestartCount(serviceName: string, windowMinutes: number): number {
const history = restartHistory.get(serviceName) ?? []
const cutoff = new Date(Date.now() - windowMinutes * 60 * 1000)
const recent = history.filter(ts => ts > cutoff)
restartHistory.set(serviceName, recent)
return recent.length
}
function recordRestart(serviceName: string): void {
const history = restartHistory.get(serviceName) ?? []
history.push(new Date())
restartHistory.set(serviceName, history)
}
async function logEvent(type: string, message: string, severity: 'info' | 'warning' | 'critical'): Promise<void> {
try {
await prisma.securityEvent.create({ data: { type, message, severity } })
} catch {
// Don't let a DB error crash the monitor
console.error(`[monitor] Failed to log event: ${type} — ${message}`)
}
}
-138
View File
@@ -1,138 +0,0 @@
import path from 'path'
import { config } from '../config.js'
import { exec, writeFile } from './exec.js'
export interface VhostOptions {
domain: string
username: string
documentRoot: string
phpVersion?: string
sslEnabled?: boolean
certPath?: string
keyPath?: string
chainPath?: string
aliases?: string[]
}
/**
* Generate an nginx vhost config for a hosting account.
*/
export function generateVhost(opts: VhostOptions): string {
const {
domain,
username,
documentRoot,
phpVersion = '8.3',
sslEnabled = false,
certPath,
keyPath,
chainPath,
aliases = [],
} = opts
const phpSocket = `/run/php/php${phpVersion}-fpm-${username}.sock`
const serverName = [domain, ...aliases].join(' ')
const sslBlock = sslEnabled && certPath && keyPath
? `
listen 443 ssl http2;
listen [::]:443 ssl http2;
ssl_certificate ${certPath};
ssl_certificate_key ${keyPath};
${chainPath ? `ssl_trusted_certificate ${chainPath};` : ''}
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
`
: `
listen 80;
listen [::]:80;
`
const redirectBlock = sslEnabled
? `
server {
listen 80;
listen [::]:80;
server_name ${serverName};
return 301 https://$host$request_uri;
}
`
: ''
return `${redirectBlock}
server {
${sslBlock.trim()}
server_name ${serverName};
root ${documentRoot};
index index.php index.html index.htm;
access_log /var/log/nginx/${username}.access.log;
error_log /var/log/nginx/${username}.error.log;
client_max_body_size 64M;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \\.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:${phpSocket};
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\\.ht {
deny all;
}
location ~ /\\.git {
deny all;
}
# Let's Encrypt webroot
location /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
}
}
`.trim()
}
/**
* Write an nginx vhost to disk and enable it.
*/
export async function enableVhost(opts: VhostOptions): Promise<void> {
const confPath = path.join(config.paths.nginxSites, `${opts.domain}.conf`)
const enabledPath = path.join(config.paths.nginxEnabled, `${opts.domain}.conf`)
await writeFile(confPath, generateVhost(opts))
// Symlink to sites-enabled
await exec('ln', ['-sf', confPath, enabledPath])
// Test and reload
const test = await exec('nginx', ['-t'])
if (test.exitCode !== 0) {
throw new Error(`nginx config test failed: ${test.stderr}`)
}
await exec('systemctl', ['reload', 'nginx'])
}
/**
* Remove a vhost config and reload nginx.
*/
export async function disableVhost(domain: string): Promise<void> {
const confPath = path.join(config.paths.nginxSites, `${domain}.conf`)
const enabledPath = path.join(config.paths.nginxEnabled, `${domain}.conf`)
await exec('rm', ['-f', enabledPath])
await exec('rm', ['-f', confPath])
await exec('systemctl', ['reload', 'nginx'])
}
+104
View File
@@ -0,0 +1,104 @@
import prisma from '../db/client.js'
import { exec } from './exec.js'
import { config } from '../config.js'
const RENEW_DAYS_BEFORE_EXPIRY = 30
/**
* Issue a Let's Encrypt certificate for a domain via certbot Apache plugin.
*/
export async function issueCertificate(domain: string, email: string): Promise<void> {
const result = await exec('certbot', [
'certonly',
'--apache',
'--non-interactive',
'--agree-tos',
'--email', email,
'-d', domain,
'-d', `www.${domain}`,
], { timeout: 120_000 })
if (result.exitCode !== 0) {
throw new Error(`certbot failed for ${domain}: ${result.stderr}`)
}
}
/**
* Renew a specific certificate.
*/
export async function renewCertificate(domain: string): Promise<void> {
const result = await exec('certbot', [
'renew',
'--cert-name', domain,
'--non-interactive',
'--quiet',
], { timeout: 120_000 })
if (result.exitCode !== 0) {
throw new Error(`certbot renew failed for ${domain}: ${result.stderr}`)
}
}
/**
* Revoke and delete a certificate.
*/
export async function revokeCertificate(domain: string): Promise<void> {
await exec('certbot', ['delete', '--cert-name', domain, '--non-interactive'], { timeout: 60_000 })
}
/**
* Check all certificates expiring within RENEW_DAYS_BEFORE_EXPIRY days
* and attempt renewal. Called by the daily scheduler cron.
*/
export async function renewExpiring(): Promise<void> {
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() + RENEW_DAYS_BEFORE_EXPIRY)
const expiring = await prisma.sslCertificate.findMany({
where: {
autoRenew: true,
status: 'active',
expiresAt: { lte: cutoff },
},
})
for (const cert of expiring) {
try {
await renewCertificate(cert.domain)
await prisma.sslCertificate.update({
where: { id: cert.id },
data: {
expiresAt: newExpiryFromNow(),
status: 'active',
},
})
} catch (err) {
console.error(`[ssl] Failed to renew cert for ${cert.domain}:`, err)
await prisma.securityEvent.create({
data: {
type: 'ssl_renewal_failed',
severity: 'warning',
message: `SSL renewal failed for ${cert.domain}: ${String(err)}`,
},
})
}
}
}
/**
* Get cert/key paths for a domain from the letsencrypt live directory.
*/
export function getCertPaths(domain: string): { certPath: string; keyPath: string; chainPath: string } {
const base = `${config.paths.letsencryptLive}/${domain}`
return {
certPath: `${base}/fullchain.pem`,
keyPath: `${base}/privkey.pem`,
chainPath: `${base}/chain.pem`,
}
}
function newExpiryFromNow(): Date {
const d = new Date()
d.setDate(d.getDate() + 90) // Let's Encrypt issues 90-day certs
return d
}
+70
View File
@@ -0,0 +1,70 @@
import path from 'path'
import fs from 'fs/promises'
import prisma from '../db/client.js'
import { exec } from './exec.js'
import { config } from '../config.js'
/**
* Collect disk usage for all active accounts and update the DB.
* Called every 15 minutes by the scheduler.
*/
export async function collectDiskUsage(): Promise<void> {
const accounts = await prisma.account.findMany({
where: { status: { in: ['active', 'suspended'] } },
select: { id: true, username: true, homedir: true },
})
for (const account of accounts) {
try {
const usedMb = await getDirSizeMb(account.homedir)
await prisma.account.update({
where: { id: account.id },
data: { diskUsedMb: BigInt(Math.round(usedMb)) },
})
} catch {
// Non-fatal — homedir may not exist yet
}
}
}
/**
* Reset bandwidth counters for all accounts on the 1st of each month.
* Called by the scheduler monthly cron.
*/
export async function resetMonthlyBandwidth(): Promise<void> {
await prisma.account.updateMany({
where: { status: { in: ['active', 'suspended'] } },
data: { bandwidthUsedMb: BigInt(0) },
})
}
/**
* Get disk usage of a directory in MB using `du`.
*/
async function getDirSizeMb(dirPath: string): Promise<number> {
// du is not in exec allowlist — read from /proc or use stat directly
// Use fs.stat recursively for safety, or fall back to parsing /proc
try {
const result = await exec('df', ['-BM', '--output=used', dirPath])
// df output: header line + value line like "123M"
const lines = result.stdout.split('\n').filter(Boolean)
const valueStr = lines[lines.length - 1]?.replace('M', '').trim() ?? '0'
return parseInt(valueStr, 10) || 0
} catch {
return 0
}
}
/**
* Get per-account Apache access log size as a bandwidth proxy.
* This is a rough estimate — real bandwidth tracking requires log parsing.
*/
export async function getAccessLogSizeMb(username: string): Promise<number> {
const logPath = `/var/log/apache2/${username}-access.log`
try {
const stat = await fs.stat(logPath)
return Math.round(stat.size / 1024 / 1024)
} catch {
return 0
}
}
+9 -3
View File
@@ -126,7 +126,13 @@ export async function getServiceStatus(name: string): Promise<ServiceStatus> {
}
export const MANAGED_SERVICES = [
'nginx', 'apache2', 'mysql', 'mariadb',
'postfix', 'dovecot', 'vsftpd', 'bind9',
'fail2ban', 'ufw', 'cron', 'ssh',
'apache2',
'php8.2-fpm',
'php8.3-fpm',
'exim4',
'dovecot',
'pdns',
'mariadb',
'fail2ban',
'ssh',
]
+8 -9
View File
@@ -2,33 +2,32 @@ import 'dotenv/config'
import { buildApp } from './app.js'
import { config } from './config.js'
import prisma from './db/client.js'
import { startScheduler, stopScheduler } from './scheduler.js'
async function start() {
let app
try {
// Verify DB connection
await prisma.$connect()
console.log('Database connected')
console.log('[nixpanel] Database connected')
app = await buildApp()
// Node.js listens on internal port (Nginx proxies public 2087/2083 → here)
await app.listen({ port: config.server.nixserverPort, host: config.server.host })
console.log(`NixPanel API running on ${config.server.host}:${config.server.nixserverPort}`)
console.log(`NixServer admin → http://127.0.0.1:${config.server.nixserverPort}/api/`)
console.log(`NixClient user → http://127.0.0.1:${config.server.nixserverPort}/api/cpanel/`)
console.log(`[nixpanel] API running on ${config.server.host}:${config.server.nixserverPort}`)
startScheduler()
} catch (err) {
console.error('Failed to start server:', err)
console.error('[nixpanel] Failed to start:', err)
await prisma.$disconnect()
process.exit(1)
}
// Graceful shutdown
const shutdown = async (signal: string) => {
console.log(`${signal} received, shutting down...`)
console.log(`[nixpanel] ${signal} received, shutting down...`)
await app?.close()
await stopScheduler()
await prisma.$disconnect()
process.exit(0)
}
+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) } })
})
+65
View File
@@ -0,0 +1,65 @@
import cron from 'node-cron'
import { checkAll } from './core/monitor.js'
import { closePool } from './core/mariadb.js'
let started = false
export function startScheduler(): void {
if (started) return
started = true
// Service health monitor — every 5 minutes
cron.schedule('*/5 * * * *', async () => {
try {
await checkAll()
} catch (err) {
console.error('[scheduler] monitor.checkAll error:', err)
}
})
// SSL renewal check — daily at 3am
cron.schedule('0 3 * * *', async () => {
try {
const { renewExpiring } = await import('./core/ssl.js')
await renewExpiring()
} catch (err) {
console.error('[scheduler] ssl.renewExpiring error:', err)
}
})
// Disk usage stats — every 15 minutes
cron.schedule('*/15 * * * *', async () => {
try {
const { collectDiskUsage } = await import('./core/stats.js')
await collectDiskUsage()
} catch (err) {
console.error('[scheduler] stats.collectDiskUsage error:', err)
}
})
// License heartbeat — every 6 hours
cron.schedule('0 */6 * * *', async () => {
try {
const { heartbeat } = await import('./core/license.js')
await heartbeat()
} catch (err) {
console.error('[scheduler] license.heartbeat error:', err)
}
})
// Bandwidth reset — 1st of every month at 00:05
cron.schedule('5 0 1 * *', async () => {
try {
const { resetMonthlyBandwidth } = await import('./core/stats.js')
await resetMonthlyBandwidth()
} catch (err) {
console.error('[scheduler] stats.resetMonthlyBandwidth error:', err)
}
})
console.log('[scheduler] Background jobs started')
}
export async function stopScheduler(): Promise<void> {
await closePool()
}