1046 lines
30 KiB
TypeScript
1046 lines
30 KiB
TypeScript
import { ConfigStore, SiteConfig } from './ConfigStore'
|
||
import { exec, spawn } from 'child_process'
|
||
import { promisify } from 'util'
|
||
import { existsSync, readFileSync, writeFileSync, readdirSync, unlinkSync, rmdirSync, mkdirSync, copyFileSync } from 'fs'
|
||
import { join } from 'path'
|
||
import https from 'https'
|
||
import http from 'http'
|
||
import { createWriteStream } from 'fs'
|
||
import { sendDownloadProgress } from '../main'
|
||
|
||
const execAsync = promisify(exec)
|
||
|
||
interface NginxVersion {
|
||
version: string
|
||
path: string
|
||
}
|
||
|
||
interface AvailableNginxVersion {
|
||
version: string
|
||
downloadUrl: string
|
||
}
|
||
|
||
interface NginxStatus {
|
||
running: boolean
|
||
pid?: number
|
||
activeConnections?: number
|
||
}
|
||
|
||
export class NginxManager {
|
||
private configStore: ConfigStore
|
||
private versionCache: { versions: AvailableNginxVersion[]; timestamp: number } | null = null
|
||
private readonly CACHE_TTL = 5 * 60 * 1000 // 5分钟缓存
|
||
|
||
constructor(configStore: ConfigStore) {
|
||
this.configStore = configStore
|
||
}
|
||
|
||
/**
|
||
* 根据 PHP 版本获取 FastCGI 端口
|
||
* PHP 8.0.x -> 9080, PHP 8.1.x -> 9081, etc.
|
||
*/
|
||
private getPhpCgiPort(version: string): number {
|
||
const match = version.match(/^(\d+)\.(\d+)/)
|
||
if (match) {
|
||
const major = parseInt(match[1])
|
||
const minor = parseInt(match[2])
|
||
return 9000 + major * 10 + minor // 8.5 -> 9085, 8.4 -> 9084, 8.3 -> 9083
|
||
}
|
||
return 9000
|
||
}
|
||
|
||
/**
|
||
* 获取已安装的 Nginx 版本列表
|
||
*/
|
||
async getInstalledVersions(): Promise<NginxVersion[]> {
|
||
const versions: NginxVersion[] = []
|
||
const nginxDir = this.configStore.getNginxPath()
|
||
|
||
if (!existsSync(nginxDir)) {
|
||
return versions
|
||
}
|
||
|
||
// 检查是否存在 nginx.exe
|
||
if (existsSync(join(nginxDir, 'nginx.exe'))) {
|
||
// 获取版本号
|
||
try {
|
||
const { stdout } = await execAsync(`"${join(nginxDir, 'nginx.exe')}" -v 2>&1`)
|
||
const match = stdout.match(/nginx\/(\d+\.\d+\.\d+)/)
|
||
if (match) {
|
||
versions.push({
|
||
version: match[1],
|
||
path: nginxDir
|
||
})
|
||
}
|
||
} catch (error: any) {
|
||
// nginx -v 输出到 stderr
|
||
const match = error.message?.match(/nginx\/(\d+\.\d+\.\d+)/) ||
|
||
error.stderr?.match(/nginx\/(\d+\.\d+\.\d+)/)
|
||
if (match) {
|
||
versions.push({
|
||
version: match[1],
|
||
path: nginxDir
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
return versions
|
||
}
|
||
|
||
/**
|
||
* 获取可用的 Nginx 版本列表
|
||
* 动态从 nginx.org 获取
|
||
*/
|
||
async getAvailableVersions(): Promise<AvailableNginxVersion[]> {
|
||
// 检查缓存
|
||
if (this.versionCache && (Date.now() - this.versionCache.timestamp) < this.CACHE_TTL) {
|
||
console.log('使用缓存的 Nginx 版本列表')
|
||
return this.versionCache.versions
|
||
}
|
||
|
||
let versions: AvailableNginxVersion[] = []
|
||
|
||
try {
|
||
console.log('从 nginx.org 获取版本列表...')
|
||
versions = await this.fetchNginxVersions()
|
||
console.log(`从 nginx.org 获取到 ${versions.length} 个 Nginx 版本`)
|
||
} catch (error: any) {
|
||
console.error('从 nginx.org 获取 Nginx 版本失败:', error.message)
|
||
}
|
||
|
||
// 如果获取失败或为空,使用备用列表
|
||
if (versions.length === 0) {
|
||
console.log('使用备用 Nginx 版本列表')
|
||
versions = this.getFallbackVersions()
|
||
}
|
||
|
||
// 更新缓存
|
||
this.versionCache = { versions, timestamp: Date.now() }
|
||
|
||
return versions
|
||
}
|
||
|
||
/**
|
||
* 从 nginx.org 下载页面获取版本列表
|
||
*/
|
||
private async fetchNginxVersions(): Promise<AvailableNginxVersion[]> {
|
||
return new Promise((resolve, reject) => {
|
||
const options = {
|
||
hostname: 'nginx.org',
|
||
path: '/download/',
|
||
method: 'GET',
|
||
headers: {
|
||
'User-Agent': 'PHPer-Dev-Manager'
|
||
},
|
||
timeout: 15000
|
||
}
|
||
|
||
const request = http.request(options, (response) => {
|
||
let data = ''
|
||
response.on('data', chunk => data += chunk)
|
||
response.on('end', () => {
|
||
try {
|
||
const versions: AvailableNginxVersion[] = []
|
||
// 解析 HTML 页面中的 Windows 版本链接
|
||
// 格式: nginx-1.27.3.zip
|
||
const regex = /href="\/download\/nginx-(\d+\.\d+\.\d+)\.zip"/g
|
||
let match
|
||
const seen = new Set<string>()
|
||
|
||
while ((match = regex.exec(data)) !== null) {
|
||
const version = match[1]
|
||
if (!seen.has(version)) {
|
||
seen.add(version)
|
||
versions.push({
|
||
version,
|
||
downloadUrl: `https://nginx.org/download/nginx-${version}.zip`
|
||
})
|
||
}
|
||
}
|
||
|
||
// 按版本号排序(降序)
|
||
versions.sort((a, b) => {
|
||
const aParts = a.version.split('.').map(Number)
|
||
const bParts = b.version.split('.').map(Number)
|
||
for (let i = 0; i < 3; i++) {
|
||
if (aParts[i] !== bParts[i]) {
|
||
return bParts[i] - aParts[i]
|
||
}
|
||
}
|
||
return 0
|
||
})
|
||
|
||
// 只返回前 10 个版本
|
||
resolve(versions.slice(0, 10))
|
||
} catch (e) {
|
||
reject(e)
|
||
}
|
||
})
|
||
})
|
||
|
||
request.on('error', reject)
|
||
request.on('timeout', () => {
|
||
request.destroy()
|
||
reject(new Error('请求超时'))
|
||
})
|
||
request.end()
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 备用版本列表
|
||
*/
|
||
private getFallbackVersions(): AvailableNginxVersion[] {
|
||
return [
|
||
{
|
||
version: '1.27.3',
|
||
downloadUrl: 'https://nginx.org/download/nginx-1.27.3.zip'
|
||
},
|
||
{
|
||
version: '1.26.2',
|
||
downloadUrl: 'https://nginx.org/download/nginx-1.26.2.zip'
|
||
},
|
||
{
|
||
version: '1.25.5',
|
||
downloadUrl: 'https://nginx.org/download/nginx-1.25.5.zip'
|
||
},
|
||
{
|
||
version: '1.24.0',
|
||
downloadUrl: 'https://nginx.org/download/nginx-1.24.0.zip'
|
||
}
|
||
]
|
||
}
|
||
|
||
/**
|
||
* 安装 Nginx 版本
|
||
*/
|
||
async install(version: string): Promise<{ success: boolean; message: string }> {
|
||
try {
|
||
const available = await this.getAvailableVersions()
|
||
const versionInfo = available.find(v => v.version === version)
|
||
|
||
if (!versionInfo) {
|
||
return { success: false, message: `未找到 Nginx ${version} 版本` }
|
||
}
|
||
|
||
const nginxPath = this.configStore.getNginxPath()
|
||
const tempPath = this.configStore.getTempPath()
|
||
const zipPath = join(tempPath, `nginx-${version}.zip`)
|
||
|
||
// 如果已有 Nginx 安装,先备份配置
|
||
let oldConfig = ''
|
||
const configPath = join(nginxPath, 'conf', 'nginx.conf')
|
||
if (existsSync(configPath)) {
|
||
oldConfig = readFileSync(configPath, 'utf-8')
|
||
}
|
||
|
||
// 下载 Nginx
|
||
await this.downloadFile(versionInfo.downloadUrl, zipPath)
|
||
|
||
// 解压
|
||
const basePath = this.configStore.getBasePath()
|
||
await this.unzip(zipPath, basePath)
|
||
|
||
// 重命名目录
|
||
const extractedDir = join(basePath, `nginx-${version}`)
|
||
if (existsSync(extractedDir) && extractedDir !== nginxPath) {
|
||
// 如果目标目录已存在,先删除
|
||
if (existsSync(nginxPath)) {
|
||
this.removeDirectory(nginxPath)
|
||
}
|
||
const { rename } = await import('fs/promises')
|
||
await rename(extractedDir, nginxPath)
|
||
}
|
||
|
||
// 删除临时文件
|
||
if (existsSync(zipPath)) {
|
||
unlinkSync(zipPath)
|
||
}
|
||
|
||
// 创建必要的目录
|
||
const sitesAvailable = this.configStore.getSitesAvailablePath()
|
||
const sitesEnabled = this.configStore.getSitesEnabledPath()
|
||
const sslPath = this.configStore.getSSLPath()
|
||
|
||
if (!existsSync(sitesAvailable)) mkdirSync(sitesAvailable, { recursive: true })
|
||
if (!existsSync(sitesEnabled)) mkdirSync(sitesEnabled, { recursive: true })
|
||
if (!existsSync(sslPath)) mkdirSync(sslPath, { recursive: true })
|
||
|
||
// 恢复或创建配置
|
||
if (oldConfig) {
|
||
writeFileSync(configPath, oldConfig)
|
||
} else {
|
||
await this.createDefaultConfig()
|
||
}
|
||
|
||
return { success: true, message: `Nginx ${version} 安装成功` }
|
||
} catch (error: any) {
|
||
return { success: false, message: `安装失败: ${error.message}` }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 卸载 Nginx
|
||
*/
|
||
async uninstall(version: string): Promise<{ success: boolean; message: string }> {
|
||
try {
|
||
// 先停止服务
|
||
await this.stop()
|
||
|
||
const nginxPath = this.configStore.getNginxPath()
|
||
|
||
if (!existsSync(nginxPath)) {
|
||
return { success: false, message: 'Nginx 未安装' }
|
||
}
|
||
|
||
// 递归删除目录(保留 sites 和 ssl 目录)
|
||
const items = readdirSync(nginxPath, { withFileTypes: true })
|
||
for (const item of items) {
|
||
const itemPath = join(nginxPath, item.name)
|
||
if (item.name !== 'sites-available' && item.name !== 'sites-enabled' && item.name !== 'ssl') {
|
||
if (item.isDirectory()) {
|
||
this.removeDirectory(itemPath)
|
||
} else {
|
||
unlinkSync(itemPath)
|
||
}
|
||
}
|
||
}
|
||
|
||
return { success: true, message: 'Nginx 已卸载' }
|
||
} catch (error: any) {
|
||
return { success: false, message: `卸载失败: ${error.message}` }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 启动 Nginx
|
||
*/
|
||
async start(): Promise<{ success: boolean; message: string }> {
|
||
try {
|
||
const nginxPath = this.configStore.getNginxPath()
|
||
const nginxExe = join(nginxPath, 'nginx.exe')
|
||
|
||
if (!existsSync(nginxExe)) {
|
||
return { success: false, message: 'Nginx 未安装' }
|
||
}
|
||
|
||
// 检查是否已在运行
|
||
const status = await this.getStatus()
|
||
if (status.running) {
|
||
return { success: true, message: 'Nginx 已经在运行' }
|
||
}
|
||
|
||
// 启动 Nginx
|
||
const child = spawn(nginxExe, [], {
|
||
cwd: nginxPath,
|
||
detached: true,
|
||
stdio: 'ignore',
|
||
windowsHide: true
|
||
})
|
||
child.unref()
|
||
|
||
// 等待启动
|
||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||
|
||
const newStatus = await this.getStatus()
|
||
if (newStatus.running) {
|
||
return { success: true, message: 'Nginx 启动成功' }
|
||
} else {
|
||
return { success: false, message: 'Nginx 启动失败,请检查配置' }
|
||
}
|
||
} catch (error: any) {
|
||
return { success: false, message: `启动失败: ${error.message}` }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 停止 Nginx
|
||
*/
|
||
async stop(): Promise<{ success: boolean; message: string }> {
|
||
try {
|
||
const nginxPath = this.configStore.getNginxPath()
|
||
const nginxExe = join(nginxPath, 'nginx.exe')
|
||
|
||
if (existsSync(nginxExe)) {
|
||
try {
|
||
await execAsync(`"${nginxExe}" -s stop`, { cwd: nginxPath, timeout: 10000 })
|
||
} catch (e) {
|
||
// 如果 -s stop 失败,尝试强制结束
|
||
try {
|
||
await execAsync('taskkill /F /IM nginx.exe', { timeout: 5000 })
|
||
} catch (e2) {
|
||
// 进程可能不存在
|
||
}
|
||
}
|
||
}
|
||
|
||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||
|
||
const status = await this.getStatus()
|
||
if (!status.running) {
|
||
return { success: true, message: 'Nginx 已停止' }
|
||
} else {
|
||
return { success: false, message: 'Nginx 停止失败' }
|
||
}
|
||
} catch (error: any) {
|
||
return { success: false, message: `停止失败: ${error.message}` }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 重启 Nginx
|
||
*/
|
||
async restart(): Promise<{ success: boolean; message: string }> {
|
||
await this.stop()
|
||
await new Promise(resolve => setTimeout(resolve, 500))
|
||
return await this.start()
|
||
}
|
||
|
||
/**
|
||
* 重载配置
|
||
*/
|
||
async reload(): Promise<{ success: boolean; message: string }> {
|
||
try {
|
||
const nginxPath = this.configStore.getNginxPath()
|
||
const nginxExe = join(nginxPath, 'nginx.exe')
|
||
|
||
if (!existsSync(nginxExe)) {
|
||
return { success: false, message: 'Nginx 未安装' }
|
||
}
|
||
|
||
await execAsync(`"${nginxExe}" -s reload`, { cwd: nginxPath })
|
||
return { success: true, message: '配置已重载' }
|
||
} catch (error: any) {
|
||
return { success: false, message: `重载失败: ${error.message}` }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取 Nginx 状态
|
||
*/
|
||
async getStatus(): Promise<NginxStatus> {
|
||
try {
|
||
const { stdout } = await execAsync('tasklist /FI "IMAGENAME eq nginx.exe" /FO CSV /NH')
|
||
const lines = stdout.trim().split('\n')
|
||
|
||
if (lines.length > 0 && lines[0].includes('nginx.exe')) {
|
||
const parts = lines[0].split(',')
|
||
const pid = parseInt(parts[1].replace(/"/g, ''))
|
||
return { running: true, pid }
|
||
}
|
||
} catch (e) {
|
||
// 忽略错误
|
||
}
|
||
|
||
return { running: false }
|
||
}
|
||
|
||
/**
|
||
* 获取 nginx.conf 配置内容
|
||
*/
|
||
async getConfig(): Promise<string> {
|
||
const configPath = join(this.configStore.getNginxPath(), 'conf', 'nginx.conf')
|
||
|
||
if (!existsSync(configPath)) {
|
||
return ''
|
||
}
|
||
|
||
return readFileSync(configPath, 'utf-8')
|
||
}
|
||
|
||
/**
|
||
* 保存 nginx.conf 配置
|
||
*/
|
||
async saveConfig(config: string): Promise<{ success: boolean; message: string }> {
|
||
try {
|
||
const configPath = join(this.configStore.getNginxPath(), 'conf', 'nginx.conf')
|
||
|
||
// 先测试配置
|
||
const tempPath = join(this.configStore.getTempPath(), 'nginx-test.conf')
|
||
writeFileSync(tempPath, config)
|
||
|
||
const nginxExe = join(this.configStore.getNginxPath(), 'nginx.exe')
|
||
try {
|
||
await execAsync(`"${nginxExe}" -t -c "${tempPath}"`, { cwd: this.configStore.getNginxPath() })
|
||
} catch (testError: any) {
|
||
unlinkSync(tempPath)
|
||
return { success: false, message: `配置验证失败: ${testError.stderr || testError.message}` }
|
||
}
|
||
|
||
unlinkSync(tempPath)
|
||
writeFileSync(configPath, config)
|
||
return { success: true, message: 'nginx.conf 保存成功' }
|
||
} catch (error: any) {
|
||
return { success: false, message: `保存失败: ${error.message}` }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取站点列表
|
||
*/
|
||
async getSites(): Promise<SiteConfig[]> {
|
||
return this.configStore.getSites()
|
||
}
|
||
|
||
/**
|
||
* 添加站点
|
||
*/
|
||
async addSite(site: SiteConfig): Promise<{ success: boolean; message: string }> {
|
||
try {
|
||
// 生成配置文件
|
||
let config: string
|
||
if (site.isProxy && site.proxyTarget) {
|
||
config = this.generateProxySiteConfig(site)
|
||
} else if (site.isLaravel) {
|
||
config = this.generateLaravelSiteConfig(site)
|
||
} else {
|
||
config = this.generateSiteConfig(site)
|
||
}
|
||
|
||
const sitesAvailable = this.configStore.getSitesAvailablePath()
|
||
const configPath = join(sitesAvailable, `${site.name}.conf`)
|
||
|
||
writeFileSync(configPath, config)
|
||
|
||
// 启用站点
|
||
if (site.enabled) {
|
||
await this.enableSite(site.name)
|
||
}
|
||
|
||
// 保存到配置
|
||
this.configStore.addSite(site)
|
||
|
||
return { success: true, message: `站点 ${site.name} 创建成功` }
|
||
} catch (error: any) {
|
||
return { success: false, message: `创建站点失败: ${error.message}` }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除站点
|
||
*/
|
||
async removeSite(name: string): Promise<{ success: boolean; message: string }> {
|
||
try {
|
||
const sitesAvailable = this.configStore.getSitesAvailablePath()
|
||
const sitesEnabled = this.configStore.getSitesEnabledPath()
|
||
|
||
const availablePath = join(sitesAvailable, `${name}.conf`)
|
||
const enabledPath = join(sitesEnabled, `${name}.conf`)
|
||
|
||
if (existsSync(enabledPath)) unlinkSync(enabledPath)
|
||
if (existsSync(availablePath)) unlinkSync(availablePath)
|
||
|
||
this.configStore.removeSite(name)
|
||
|
||
return { success: true, message: `站点 ${name} 已删除` }
|
||
} catch (error: any) {
|
||
return { success: false, message: `删除站点失败: ${error.message}` }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新站点
|
||
*/
|
||
async updateSite(originalName: string, site: SiteConfig): Promise<{ success: boolean; message: string }> {
|
||
try {
|
||
const sitesAvailable = this.configStore.getSitesAvailablePath()
|
||
const sitesEnabled = this.configStore.getSitesEnabledPath()
|
||
|
||
// 如果站点名称没变,直接更新配置文件
|
||
const configPath = join(sitesAvailable, `${originalName}.conf`)
|
||
const enabledPath = join(sitesEnabled, `${originalName}.conf`)
|
||
|
||
// 检查是否之前是启用状态
|
||
const wasEnabled = existsSync(enabledPath)
|
||
|
||
// 生成新的配置内容
|
||
let config: string
|
||
if (site.isProxy && site.proxyTarget) {
|
||
config = this.generateProxySiteConfig(site)
|
||
} else if (site.isLaravel) {
|
||
config = this.generateLaravelSiteConfig(site)
|
||
} else {
|
||
config = this.generateSiteConfig(site)
|
||
}
|
||
|
||
// 写入配置文件
|
||
writeFileSync(configPath, config)
|
||
|
||
// 如果之前是启用状态,更新启用的配置
|
||
if (wasEnabled) {
|
||
writeFileSync(enabledPath, config)
|
||
}
|
||
|
||
// 更新存储的配置
|
||
this.configStore.updateSite(originalName, site)
|
||
|
||
return { success: true, message: `站点 ${site.name} 更新成功` }
|
||
} catch (error: any) {
|
||
return { success: false, message: `更新站点失败: ${error.message}` }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 启用站点
|
||
*/
|
||
async enableSite(name: string): Promise<{ success: boolean; message: string }> {
|
||
try {
|
||
const sitesAvailable = this.configStore.getSitesAvailablePath()
|
||
const sitesEnabled = this.configStore.getSitesEnabledPath()
|
||
|
||
const availablePath = join(sitesAvailable, `${name}.conf`)
|
||
const enabledPath = join(sitesEnabled, `${name}.conf`)
|
||
|
||
if (!existsSync(availablePath)) {
|
||
return { success: false, message: `站点配置 ${name} 不存在` }
|
||
}
|
||
|
||
// 复制配置到 enabled 目录
|
||
copyFileSync(availablePath, enabledPath)
|
||
|
||
this.configStore.updateSite(name, { enabled: true })
|
||
|
||
return { success: true, message: `站点 ${name} 已启用` }
|
||
} catch (error: any) {
|
||
return { success: false, message: `启用站点失败: ${error.message}` }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 禁用站点
|
||
*/
|
||
async disableSite(name: string): Promise<{ success: boolean; message: string }> {
|
||
try {
|
||
const sitesEnabled = this.configStore.getSitesEnabledPath()
|
||
const enabledPath = join(sitesEnabled, `${name}.conf`)
|
||
|
||
if (existsSync(enabledPath)) {
|
||
unlinkSync(enabledPath)
|
||
}
|
||
|
||
this.configStore.updateSite(name, { enabled: false })
|
||
|
||
return { success: true, message: `站点 ${name} 已禁用` }
|
||
} catch (error: any) {
|
||
return { success: false, message: `禁用站点失败: ${error.message}` }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成 Laravel 配置
|
||
*/
|
||
async generateLaravelConfig(site: SiteConfig): Promise<string> {
|
||
return this.generateLaravelSiteConfig(site)
|
||
}
|
||
|
||
/**
|
||
* 申请 SSL 证书(Let's Encrypt)
|
||
*/
|
||
async requestSSLCertificate(domain: string, email: string): Promise<{ success: boolean; message: string }> {
|
||
try {
|
||
// 检查是否安装了 win-acme
|
||
const acmePath = join(this.configStore.getBasePath(), 'tools', 'win-acme')
|
||
const wacs = join(acmePath, 'wacs.exe')
|
||
|
||
if (!existsSync(wacs)) {
|
||
return {
|
||
success: false,
|
||
message: '请先下载 win-acme 工具到 tools/win-acme 目录。下载地址: https://www.win-acme.com/'
|
||
}
|
||
}
|
||
|
||
const sslPath = this.configStore.getSSLPath()
|
||
const certPath = join(sslPath, domain)
|
||
|
||
if (!existsSync(certPath)) {
|
||
mkdirSync(certPath, { recursive: true })
|
||
}
|
||
|
||
// 使用 win-acme 申请证书
|
||
const command = `"${wacs}" --target manual --host ${domain} --validation selfhosting --emailaddress ${email} --accepttos --store pemfiles --pemfilespath "${certPath}"`
|
||
|
||
await execAsync(command, { timeout: 120000 })
|
||
|
||
return { success: true, message: `SSL 证书已申请成功,保存在 ${certPath}` }
|
||
} catch (error: any) {
|
||
return { success: false, message: `申请 SSL 证书失败: ${error.message}` }
|
||
}
|
||
}
|
||
|
||
// ==================== 私有方法 ====================
|
||
|
||
private async createDefaultConfig(): Promise<void> {
|
||
const nginxPath = this.configStore.getNginxPath()
|
||
const configPath = join(nginxPath, 'conf', 'nginx.conf')
|
||
const logsPath = this.configStore.getLogsPath()
|
||
const sitesEnabled = this.configStore.getSitesEnabledPath()
|
||
|
||
const config = `
|
||
worker_processes auto;
|
||
|
||
error_log "${logsPath.replace(/\\/g, '/')}/nginx-error.log";
|
||
pid "${nginxPath.replace(/\\/g, '/')}/nginx.pid";
|
||
|
||
events {
|
||
worker_connections 1024;
|
||
}
|
||
|
||
http {
|
||
include mime.types;
|
||
default_type application/octet-stream;
|
||
|
||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||
'$status $body_bytes_sent "$http_referer" '
|
||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||
|
||
access_log "${logsPath.replace(/\\/g, '/')}/nginx-access.log" main;
|
||
|
||
sendfile on;
|
||
keepalive_timeout 65;
|
||
|
||
# Gzip 压缩
|
||
gzip on;
|
||
gzip_vary on;
|
||
gzip_proxied any;
|
||
gzip_comp_level 6;
|
||
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
|
||
|
||
# 上传大小限制
|
||
client_max_body_size 100M;
|
||
|
||
# 包含所有启用的站点配置
|
||
include "${sitesEnabled.replace(/\\/g, '/')}/*.conf";
|
||
|
||
# 默认服务器
|
||
server {
|
||
listen 80;
|
||
server_name localhost;
|
||
|
||
location / {
|
||
root html;
|
||
index index.html index.htm index.php;
|
||
}
|
||
|
||
error_page 500 502 503 504 /50x.html;
|
||
location = /50x.html {
|
||
root html;
|
||
}
|
||
}
|
||
}
|
||
`
|
||
|
||
writeFileSync(configPath, config)
|
||
}
|
||
|
||
private generateSiteConfig(site: SiteConfig): string {
|
||
const phpPath = this.configStore.getPhpPath(site.phpVersion)
|
||
const logsPath = this.configStore.getLogsPath()
|
||
const phpCgiPort = this.getPhpCgiPort(site.phpVersion)
|
||
|
||
let config = `
|
||
# PHP Version: ${site.phpVersion} -> Port ${phpCgiPort}
|
||
server {
|
||
listen 80;
|
||
server_name ${site.domain};
|
||
root "${site.rootPath.replace(/\\/g, '/')}";
|
||
index index.php index.html index.htm;
|
||
|
||
access_log "${logsPath.replace(/\\/g, '/')}/${site.name}-access.log";
|
||
error_log "${logsPath.replace(/\\/g, '/')}/${site.name}-error.log";
|
||
|
||
location / {
|
||
try_files $uri $uri/ /index.php?$query_string;
|
||
}
|
||
|
||
location ~ \\.php$ {
|
||
fastcgi_pass 127.0.0.1:${phpCgiPort};
|
||
fastcgi_index index.php;
|
||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||
include fastcgi_params;
|
||
}
|
||
|
||
location ~ /\\.(?!well-known).* {
|
||
deny all;
|
||
}
|
||
}
|
||
`
|
||
|
||
if (site.ssl) {
|
||
const sslPath = join(this.configStore.getSSLPath(), site.domain)
|
||
config += `
|
||
server {
|
||
listen 443 ssl http2;
|
||
server_name ${site.domain};
|
||
root "${site.rootPath.replace(/\\/g, '/')}";
|
||
index index.php index.html index.htm;
|
||
|
||
ssl_certificate "${sslPath.replace(/\\/g, '/')}/${site.domain}-chain.pem";
|
||
ssl_certificate_key "${sslPath.replace(/\\/g, '/')}/${site.domain}-key.pem";
|
||
ssl_protocols TLSv1.2 TLSv1.3;
|
||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||
|
||
access_log "${logsPath.replace(/\\/g, '/')}/${site.name}-ssl-access.log";
|
||
error_log "${logsPath.replace(/\\/g, '/')}/${site.name}-ssl-error.log";
|
||
|
||
location / {
|
||
try_files $uri $uri/ /index.php?$query_string;
|
||
}
|
||
|
||
location ~ \\.php$ {
|
||
fastcgi_pass 127.0.0.1:${phpCgiPort};
|
||
fastcgi_index index.php;
|
||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||
include fastcgi_params;
|
||
}
|
||
|
||
location ~ /\\.(?!well-known).* {
|
||
deny all;
|
||
}
|
||
}
|
||
`
|
||
}
|
||
|
||
return config
|
||
}
|
||
|
||
/**
|
||
* 生成反向代理站点配置
|
||
*/
|
||
private generateProxySiteConfig(site: SiteConfig): string {
|
||
const logsPath = this.configStore.getLogsPath()
|
||
const proxyTarget = site.proxyTarget || 'http://127.0.0.1:3000'
|
||
|
||
let config = `
|
||
# Reverse Proxy Site
|
||
server {
|
||
listen 80;
|
||
server_name ${site.domain};
|
||
|
||
access_log "${logsPath.replace(/\\/g, '/')}/${site.name}-access.log";
|
||
error_log "${logsPath.replace(/\\/g, '/')}/${site.name}-error.log";
|
||
|
||
location / {
|
||
proxy_pass ${proxyTarget};
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Upgrade $http_upgrade;
|
||
proxy_set_header Connection 'upgrade';
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
proxy_cache_bypass $http_upgrade;
|
||
|
||
# WebSocket 支持
|
||
proxy_read_timeout 86400;
|
||
}
|
||
}
|
||
`
|
||
|
||
if (site.ssl) {
|
||
const sslPath = join(this.configStore.getSSLPath(), site.domain)
|
||
config += `
|
||
server {
|
||
listen 443 ssl http2;
|
||
server_name ${site.domain};
|
||
|
||
ssl_certificate "${sslPath.replace(/\\/g, '/')}/${site.domain}-chain.pem";
|
||
ssl_certificate_key "${sslPath.replace(/\\/g, '/')}/${site.domain}-key.pem";
|
||
ssl_protocols TLSv1.2 TLSv1.3;
|
||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||
|
||
access_log "${logsPath.replace(/\\/g, '/')}/${site.name}-ssl-access.log";
|
||
error_log "${logsPath.replace(/\\/g, '/')}/${site.name}-ssl-error.log";
|
||
|
||
location / {
|
||
proxy_pass ${proxyTarget};
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Upgrade $http_upgrade;
|
||
proxy_set_header Connection 'upgrade';
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
proxy_cache_bypass $http_upgrade;
|
||
|
||
# WebSocket 支持
|
||
proxy_read_timeout 86400;
|
||
}
|
||
}
|
||
`
|
||
}
|
||
|
||
return config
|
||
}
|
||
|
||
private generateLaravelSiteConfig(site: SiteConfig): string {
|
||
const logsPath = this.configStore.getLogsPath()
|
||
// Laravel 项目 public 目录
|
||
const publicPath = join(site.rootPath, 'public').replace(/\\/g, '/')
|
||
const phpCgiPort = this.getPhpCgiPort(site.phpVersion)
|
||
|
||
let config = `
|
||
# Laravel Project - PHP Version: ${site.phpVersion} -> Port ${phpCgiPort}
|
||
server {
|
||
listen 80;
|
||
server_name ${site.domain};
|
||
root "${publicPath}";
|
||
index index.php;
|
||
|
||
access_log "${logsPath.replace(/\\/g, '/')}/${site.name}-access.log";
|
||
error_log "${logsPath.replace(/\\/g, '/')}/${site.name}-error.log";
|
||
|
||
add_header X-Frame-Options "SAMEORIGIN";
|
||
add_header X-Content-Type-Options "nosniff";
|
||
|
||
charset utf-8;
|
||
|
||
location / {
|
||
try_files $uri $uri/ /index.php?$query_string;
|
||
}
|
||
|
||
location = /favicon.ico { access_log off; log_not_found off; }
|
||
location = /robots.txt { access_log off; log_not_found off; }
|
||
|
||
error_page 404 /index.php;
|
||
|
||
location ~ \\.php$ {
|
||
fastcgi_pass 127.0.0.1:${phpCgiPort};
|
||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||
include fastcgi_params;
|
||
}
|
||
|
||
location ~ /\\.(?!well-known).* {
|
||
deny all;
|
||
}
|
||
}
|
||
`
|
||
|
||
if (site.ssl) {
|
||
const sslPath = join(this.configStore.getSSLPath(), site.domain)
|
||
config += `
|
||
server {
|
||
listen 443 ssl http2;
|
||
server_name ${site.domain};
|
||
root "${publicPath}";
|
||
index index.php;
|
||
|
||
ssl_certificate "${sslPath.replace(/\\/g, '/')}/${site.domain}-chain.pem";
|
||
ssl_certificate_key "${sslPath.replace(/\\/g, '/')}/${site.domain}-key.pem";
|
||
ssl_protocols TLSv1.2 TLSv1.3;
|
||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||
ssl_prefer_server_ciphers off;
|
||
|
||
access_log "${logsPath.replace(/\\/g, '/')}/${site.name}-ssl-access.log";
|
||
error_log "${logsPath.replace(/\\/g, '/')}/${site.name}-ssl-error.log";
|
||
|
||
add_header X-Frame-Options "SAMEORIGIN";
|
||
add_header X-Content-Type-Options "nosniff";
|
||
|
||
charset utf-8;
|
||
|
||
location / {
|
||
try_files $uri $uri/ /index.php?$query_string;
|
||
}
|
||
|
||
location = /favicon.ico { access_log off; log_not_found off; }
|
||
location = /robots.txt { access_log off; log_not_found off; }
|
||
|
||
error_page 404 /index.php;
|
||
|
||
location ~ \\.php$ {
|
||
fastcgi_pass 127.0.0.1:${phpCgiPort};
|
||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||
include fastcgi_params;
|
||
}
|
||
|
||
location ~ /\\.(?!well-known).* {
|
||
deny all;
|
||
}
|
||
}
|
||
`
|
||
}
|
||
|
||
return config
|
||
}
|
||
|
||
private async downloadFile(url: string, dest: string): Promise<void> {
|
||
return new Promise((resolve, reject) => {
|
||
const file = createWriteStream(dest)
|
||
const protocol = url.startsWith('https') ? https : http
|
||
|
||
const request = protocol.get(url, (response) => {
|
||
if (response.statusCode === 301 || response.statusCode === 302) {
|
||
const redirectUrl = response.headers.location
|
||
if (redirectUrl) {
|
||
file.close()
|
||
unlinkSync(dest)
|
||
this.downloadFile(redirectUrl, dest).then(resolve).catch(reject)
|
||
return
|
||
}
|
||
}
|
||
|
||
if (response.statusCode !== 200) {
|
||
reject(new Error(`下载失败,状态码: ${response.statusCode}`))
|
||
return
|
||
}
|
||
|
||
const totalSize = parseInt(response.headers['content-length'] || '0', 10)
|
||
let downloadedSize = 0
|
||
let lastProgressTime = Date.now()
|
||
|
||
response.on('data', (chunk) => {
|
||
downloadedSize += chunk.length
|
||
const now = Date.now()
|
||
if (now - lastProgressTime > 500) {
|
||
const progress = totalSize > 0 ? Math.round((downloadedSize / totalSize) * 100) : 0
|
||
sendDownloadProgress('nginx', progress, downloadedSize, totalSize)
|
||
lastProgressTime = now
|
||
}
|
||
})
|
||
|
||
response.pipe(file)
|
||
file.on('finish', () => {
|
||
file.close()
|
||
sendDownloadProgress('nginx', 100, totalSize, totalSize)
|
||
resolve()
|
||
})
|
||
})
|
||
|
||
request.on('error', (err) => {
|
||
file.close()
|
||
if (existsSync(dest)) unlinkSync(dest)
|
||
reject(err)
|
||
})
|
||
})
|
||
}
|
||
|
||
private async unzip(zipPath: string, destPath: string): Promise<void> {
|
||
const { createReadStream } = await import('fs')
|
||
const unzipper = await import('unzipper')
|
||
|
||
return new Promise((resolve, reject) => {
|
||
createReadStream(zipPath)
|
||
.pipe(unzipper.Extract({ path: destPath }))
|
||
.on('close', resolve)
|
||
.on('error', reject)
|
||
})
|
||
}
|
||
|
||
private removeDirectory(dir: string): void {
|
||
if (existsSync(dir)) {
|
||
const files = readdirSync(dir, { withFileTypes: true })
|
||
for (const file of files) {
|
||
const fullPath = join(dir, file.name)
|
||
if (file.isDirectory()) {
|
||
this.removeDirectory(fullPath)
|
||
} else {
|
||
unlinkSync(fullPath)
|
||
}
|
||
}
|
||
rmdirSync(dir)
|
||
}
|
||
}
|
||
}
|
||
|