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
175 lines
5.7 KiB
TypeScript
175 lines
5.7 KiB
TypeScript
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`])
|
|
}
|