mirror of
https://github.com/ragestudio/comty.git
synced 2025-06-09 02:24:16 +00:00
612 lines
16 KiB
JavaScript
Executable File
612 lines
16 KiB
JavaScript
Executable File
import fs from "node:fs/promises"
|
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
|
|
import path from "node:path"
|
|
import { execSync, spawn } from "node:child_process"
|
|
import defaults from "linebridge/dist/defaults"
|
|
|
|
const localNginxBinary = path.resolve(process.cwd(), "nginx-bin")
|
|
const serverPkg = require("../../../package.json")
|
|
|
|
export default class NginxManager {
|
|
constructor(options = {}) {
|
|
this.options = options
|
|
|
|
this.ssl = {
|
|
on: false,
|
|
cert_file_name: null,
|
|
key_file_name: null,
|
|
}
|
|
this.port = options.port || 9000
|
|
this.internalIp = options.internalIp || "0.0.0.0"
|
|
|
|
// Set binary path
|
|
this.nginxBinary = existsSync(localNginxBinary)
|
|
? localNginxBinary
|
|
: "nginx"
|
|
|
|
// Directory structure
|
|
this.nginxWorkDir =
|
|
options.nginxWorkDir || path.join(process.cwd(), ".nginx")
|
|
this.configDir = path.join(this.nginxWorkDir, "conf")
|
|
this.tempDir = path.join(this.nginxWorkDir, "temp")
|
|
this.logsDir = path.join(this.tempDir, "logs")
|
|
this.cacheDir = path.join(this.tempDir, "cache")
|
|
|
|
// Configuration files
|
|
this.mainConfigPath = path.join(this.configDir, "nginx.conf")
|
|
this.servicesConfigPath = path.join(this.configDir, "services.conf")
|
|
|
|
// Process reference
|
|
this.nginxProcess = null
|
|
this.isNginxRunning = false
|
|
|
|
if (
|
|
existsSync(this.options.cert_file_name) &&
|
|
existsSync(this.options.key_file_name)
|
|
) {
|
|
console.log("[nginx] Setting SSL listen mode")
|
|
this.ssl.on = true
|
|
this.ssl.cert_file_name = this.options.cert_file_name
|
|
this.ssl.key_file_name = this.options.key_file_name
|
|
}
|
|
}
|
|
|
|
routes = new Map() // key: path, value: { serviceId, target, pathRewrite, ws }
|
|
|
|
/**
|
|
* Initialize the directory structure and configuration files
|
|
*/
|
|
async initialize() {
|
|
try {
|
|
// Create directories
|
|
this._ensureDirectories()
|
|
|
|
// Create mime.types file
|
|
await this.writeMimeTypes()
|
|
|
|
// Generate main config file
|
|
await this.generateMainConfig()
|
|
|
|
console.log(`🔧 Using Nginx binary: ${this.nginxBinary}`)
|
|
return true
|
|
} catch (error) {
|
|
console.error("❌ Failed to initialize Nginx configuration:", error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure all required directories exist
|
|
*/
|
|
_ensureDirectories() {
|
|
const dirs = [
|
|
this.configDir,
|
|
this.tempDir,
|
|
this.logsDir,
|
|
this.cacheDir,
|
|
path.join(this.cacheDir, "client_body"),
|
|
path.join(this.cacheDir, "proxy"),
|
|
]
|
|
|
|
// Create all directories
|
|
for (const dir of dirs) {
|
|
if (!existsSync(dir)) {
|
|
mkdirSync(dir, { recursive: true })
|
|
}
|
|
}
|
|
|
|
// Create empty log files
|
|
const logFiles = [
|
|
path.join(this.logsDir, "access.log"),
|
|
path.join(this.logsDir, "error.log"),
|
|
]
|
|
|
|
for (const file of logFiles) {
|
|
if (!existsSync(file)) {
|
|
writeFileSync(file, "")
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate the main Nginx configuration file
|
|
*/
|
|
async generateMainConfig() {
|
|
// Normalize paths for Nginx
|
|
const normalizedConfigDir = this.configDir.replace(/\\/g, "/")
|
|
const normalizedTempDir = this.tempDir.replace(/\\/g, "/")
|
|
const normalizedLogsDir = path
|
|
.join(this.tempDir, "logs")
|
|
.replace(/\\/g, "/")
|
|
const normalizedCacheDir = path
|
|
.join(this.tempDir, "cache")
|
|
.replace(/\\/g, "/")
|
|
|
|
const mainEndpointJSON = JSON.stringify({
|
|
name: serverPkg.name,
|
|
version: serverPkg.version,
|
|
lb_version: defaults?.version ?? "unknown",
|
|
gateway: "nginx",
|
|
})
|
|
|
|
const config = `
|
|
# Nginx configuration for Comty API Gateway
|
|
# Auto-generated - Do not edit manually
|
|
|
|
worker_processes auto;
|
|
error_log ${normalizedLogsDir}/error.log ${debugFlag ? "debug" : "error"};
|
|
pid ${normalizedTempDir}/nginx.pid;
|
|
|
|
events {
|
|
worker_connections 1024;
|
|
}
|
|
|
|
http {
|
|
include ${normalizedConfigDir}/mime.types;
|
|
|
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
|
'$status $body_bytes_sent "$http_referer" '
|
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
|
|
|
log_format debug '$remote_addr - $remote_user [$time_local] "$request" '
|
|
'$status $body_bytes_sent "$http_referer" '
|
|
'"$http_user_agent" "$http_x_forwarded_for" '
|
|
'upstream_addr: $upstream_addr '
|
|
'upstream_status: $upstream_status '
|
|
'request_time: $request_time '
|
|
'http_version: $server_protocol';
|
|
|
|
access_log ${normalizedLogsDir}/access.log ${debugFlag ? "debug" : "main"};
|
|
|
|
sendfile on;
|
|
tcp_nodelay on;
|
|
|
|
client_max_body_size 100M;
|
|
|
|
# Temp directories
|
|
client_body_temp_path ${normalizedCacheDir}/client_body;
|
|
proxy_temp_path ${normalizedCacheDir}/proxy;
|
|
|
|
# Set proxy timeouts
|
|
proxy_connect_timeout 60s;
|
|
proxy_read_timeout 60s;
|
|
proxy_send_timeout 60s;
|
|
|
|
map $http_x_forwarded_proto $proxy_x_forwarded_proto {
|
|
default $http_x_forwarded_proto;
|
|
'' $scheme;
|
|
}
|
|
|
|
server {
|
|
${this.ssl.on ? `listen ${this.port} ssl;` : `listen ${this.port};`}
|
|
server_name _;
|
|
|
|
${this.ssl.cert_file_name ? `ssl_certificate ${this.ssl.cert_file_name};` : ""}
|
|
${this.ssl.key_file_name ? `ssl_certificate_key ${this.ssl.key_file_name};` : ""}
|
|
|
|
|
|
|
|
# Include service-specific configurations
|
|
include ${normalizedConfigDir}/services.conf;
|
|
|
|
# Default route for /
|
|
location = / {
|
|
add_header Content-Type application/json;
|
|
add_header 'Access-Control-Allow-Origin' '*' always;
|
|
add_header 'Access-Control-Allow-Headers' '*' always;
|
|
add_header 'Access-Control-Allow-Methods' 'GET,HEAD,PUT,PATCH,POST,DELETE' always;
|
|
|
|
return 200 '${mainEndpointJSON}';
|
|
}
|
|
|
|
# Catch-all for any other unmatched path
|
|
location / {
|
|
add_header Content-Type application/json;
|
|
add_header 'Access-Control-Allow-Origin' '*' always;
|
|
add_header 'Access-Control-Allow-Headers' '*' always;
|
|
add_header 'Access-Control-Allow-Methods' 'GET,HEAD,PUT,PATCH,POST,DELETE' always;
|
|
|
|
return 404 '{"error":"Not found"}';
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
console.log(`📝 Nginx configuration initialized at ${this.configDir}`)
|
|
|
|
await fs.writeFile(this.mainConfigPath, config)
|
|
}
|
|
|
|
// Create mime.types file if it doesn't exist
|
|
async writeMimeTypes() {
|
|
const mimeTypesPath = path.join(this.configDir, "mime.types")
|
|
|
|
if (!existsSync(mimeTypesPath)) {
|
|
// Basic MIME types
|
|
const mimeTypes = `types {
|
|
text/html html htm shtml;
|
|
text/css css;
|
|
text/xml xml;
|
|
image/gif gif;
|
|
image/jpeg jpeg jpg;
|
|
application/javascript js;
|
|
text/plain txt;
|
|
image/png png;
|
|
image/svg+xml svg svgz;
|
|
application/json json;
|
|
}`
|
|
|
|
await fs.writeFile(mimeTypesPath, mimeTypes)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register a new service route in Nginx - queues for batch processing
|
|
* @param {Object} routeConfig - Route configuration
|
|
* @returns {Boolean} - Success status
|
|
*/
|
|
async register(routeConfig) {
|
|
try {
|
|
const {
|
|
serviceId,
|
|
path: routePath,
|
|
target,
|
|
pathRewrite,
|
|
websocket,
|
|
} = routeConfig
|
|
|
|
// Normalize path
|
|
let normalizedPath = routePath.startsWith("/")
|
|
? routePath
|
|
: `/${routePath}`
|
|
|
|
if (debugFlag) {
|
|
console.log(
|
|
`🔍 Registering route for [${serviceId}]: ${normalizedPath} -> ${target}`,
|
|
)
|
|
}
|
|
|
|
// Store the route with improved handling of path rewrites
|
|
const effectivePathRewrite = pathRewrite || {}
|
|
|
|
this.routes.set(normalizedPath, {
|
|
serviceId: serviceId,
|
|
target: target,
|
|
pathRewrite: effectivePathRewrite,
|
|
websocket: !!websocket,
|
|
})
|
|
|
|
return true
|
|
} catch (error) {
|
|
console.error(
|
|
`❌ Failed to register route for [${routeConfig.serviceId}]:`,
|
|
error,
|
|
)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply the current configuration (generate config and reload/start Nginx)
|
|
*/
|
|
async applyConfiguration() {
|
|
try {
|
|
console.log(
|
|
`🔄 Applying configuration with ${this.routes.size} routes...`,
|
|
)
|
|
|
|
// Generate services configuration
|
|
await this.regenerateServicesConfig()
|
|
|
|
// Verify configuration is valid
|
|
const configTest = this.execNginxCommand(
|
|
["-t", "-c", this.mainConfigPath],
|
|
true,
|
|
)
|
|
if (!configTest.success) {
|
|
throw new Error(
|
|
`Configuration validation failed: ${configTest.error}`,
|
|
)
|
|
}
|
|
|
|
console.log(`✅ Configuration applied successfully`)
|
|
} catch (error) {
|
|
console.error(`❌ Failed to apply configuration:`, error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unregister all routes for a specific service
|
|
* @param {String} serviceId - Service ID to unregister
|
|
* @returns {Boolean} - Success status
|
|
*/
|
|
async unregisterAllFromService(serviceId) {
|
|
try {
|
|
// Find and remove all routes for this service
|
|
for (const [path, route] of this.routes.entries()) {
|
|
if (route.serviceId === serviceId) {
|
|
this.routes.delete(path)
|
|
}
|
|
}
|
|
|
|
console.log(`📝 Removed routes for service [${serviceId}]`)
|
|
|
|
return true
|
|
} catch (error) {
|
|
console.error(
|
|
`❌ Failed to unregister routes for service [${serviceId}]:`,
|
|
error,
|
|
)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Regenerate the services configuration file
|
|
*/
|
|
async regenerateServicesConfig() {
|
|
let config = `# Service routes\n# Last updated: ${new Date().toISOString()}\n# Total routes: ${this.routes.size}\n\n`
|
|
|
|
// Special case - no routes yet
|
|
if (this.routes.size === 0) {
|
|
config += "# No services registered yet\n"
|
|
await fs.writeFile(this.servicesConfigPath, config)
|
|
return
|
|
}
|
|
|
|
// Add all routes
|
|
for (const [path, route] of this.routes.entries()) {
|
|
config += this.generateLocationBlock(path, route)
|
|
}
|
|
|
|
// Write the config
|
|
await fs.writeFile(this.servicesConfigPath, config)
|
|
|
|
if (debugFlag) {
|
|
console.log(`📄 Writted [${this.routes.size}] service routes`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a location block for a route
|
|
* @param {String} path - Route path
|
|
* @param {Object} route - Route configuration
|
|
* @returns {String} - Nginx location block
|
|
*/
|
|
generateLocationBlock(path, route) {
|
|
// Create rewrite configuration if needed
|
|
let rewriteConfig = ""
|
|
|
|
if (route.pathRewrite && Object.keys(route.pathRewrite).length > 0) {
|
|
rewriteConfig += "# Path rewrite rules\n"
|
|
|
|
for (const [pattern, replacement] of Object.entries(
|
|
route.pathRewrite,
|
|
)) {
|
|
// Improved rewrite pattern that preserves query parameters
|
|
rewriteConfig += `\nrewrite ${pattern} ${replacement} break;`
|
|
}
|
|
}
|
|
|
|
// Determine if this is a root location or a more specific path
|
|
const locationDirective =
|
|
path === "/" ? "location /" : `location ${path}`
|
|
|
|
// Build the full location block with proper indentation
|
|
return `
|
|
${locationDirective} {
|
|
if ($request_method = OPTIONS) {
|
|
add_header 'Access-Control-Allow-Origin' '*';
|
|
add_header 'Access-Control-Allow-Headers' '*';
|
|
add_header 'Access-Control-Allow-Methods' 'GET,HEAD,PUT,PATCH,POST,DELETE';
|
|
|
|
return 200;
|
|
}
|
|
|
|
# Add some missing headers
|
|
add_header 'X-Accel-Buffering' 'no';
|
|
|
|
# Set proxy configuration
|
|
proxy_http_version 1.1;
|
|
proxy_pass_request_headers on;
|
|
chunked_transfer_encoding off;
|
|
proxy_buffering off;
|
|
proxy_cache off;
|
|
|
|
# Standard proxy headers
|
|
proxy_set_header Host $http_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 $proxy_x_forwarded_proto;
|
|
|
|
# Set headers for WebSocket support
|
|
proxy_set_header Upgrade $http_upgrade;
|
|
proxy_set_header Connection "upgrade";
|
|
|
|
${rewriteConfig}
|
|
|
|
# Proxy pass to service
|
|
proxy_pass ${route.target};
|
|
}
|
|
`
|
|
}
|
|
|
|
/**
|
|
* Start the Nginx server
|
|
* @returns {Boolean} - Success status
|
|
*/
|
|
async start() {
|
|
try {
|
|
// Start Nginx
|
|
this.nginxProcess = spawn(
|
|
this.nginxBinary,
|
|
[
|
|
"-c",
|
|
this.mainConfigPath,
|
|
"-g",
|
|
"daemon off;",
|
|
"-p",
|
|
this.tempDir,
|
|
],
|
|
{
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
},
|
|
)
|
|
|
|
this.nginxProcess.stdout.on("data", (data) => {
|
|
console.log(`[Nginx] ${data.toString().trim()}`)
|
|
})
|
|
|
|
this.nginxProcess.stderr.on("data", (data) => {
|
|
console.error(`[Nginx] ${data.toString().trim()}`)
|
|
})
|
|
|
|
this.nginxProcess.on("close", (code) => {
|
|
this.isNginxRunning = false
|
|
if (code !== 0 && code !== null) {
|
|
console.error(`Nginx process exited with code ${code}`)
|
|
}
|
|
this.nginxProcess = null
|
|
})
|
|
|
|
// Wait briefly to check for immediate startup errors
|
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
|
|
if (this.nginxProcess.exitCode !== null) {
|
|
throw new Error(
|
|
`Nginx failed to start (exit code: ${this.nginxProcess.exitCode})`,
|
|
)
|
|
}
|
|
|
|
this.isNginxRunning = true
|
|
console.log(`🚀 Nginx started on port ${this.port}`)
|
|
return true
|
|
} catch (error) {
|
|
this.isNginxRunning = false
|
|
console.error("❌ Failed to start Nginx:", error.message)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute an Nginx command
|
|
* @param {Array} args - Command arguments
|
|
* @param {Boolean} returnOutput - Whether to return command output
|
|
* @returns {Object} - Success status and output/error
|
|
*/
|
|
execNginxCommand(args, returnOutput = false) {
|
|
try {
|
|
// Always include prefix to set the temp directory
|
|
const allArgs = [...args, "-p", this.tempDir]
|
|
|
|
const cmdString = `"${this.nginxBinary}" ${allArgs.join(" ")}`
|
|
|
|
if (debugFlag) {
|
|
console.log(`🔍 Executing: ${cmdString}`)
|
|
}
|
|
|
|
const output = execSync(cmdString, {
|
|
encoding: "utf8",
|
|
stdio: returnOutput ? "pipe" : "inherit",
|
|
})
|
|
|
|
return {
|
|
success: true,
|
|
output: returnOutput ? output : null,
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error.message,
|
|
output: error.stdout,
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reload the Nginx configuration
|
|
* @returns {Boolean} - Success status
|
|
*/
|
|
async reload() {
|
|
try {
|
|
// Test configuration validity
|
|
const configTest = this.execNginxCommand(
|
|
["-t", "-c", this.mainConfigPath],
|
|
true,
|
|
)
|
|
if (!configTest.success) {
|
|
throw new Error(
|
|
`Configuration test failed: ${configTest.error}`,
|
|
)
|
|
}
|
|
|
|
// If Nginx isn't running, start it
|
|
if (
|
|
!this.isNginxRunning ||
|
|
!this.nginxProcess ||
|
|
this.nginxProcess.exitCode !== null
|
|
) {
|
|
return await this.start()
|
|
}
|
|
|
|
// Send reload signal
|
|
const result = this.execNginxCommand([
|
|
"-s",
|
|
"reload",
|
|
"-c",
|
|
this.mainConfigPath,
|
|
])
|
|
|
|
if (!result.success) {
|
|
throw new Error(`Failed to reload Nginx: ${result.error}`)
|
|
}
|
|
|
|
console.log("🔄 Nginx configuration reloaded")
|
|
return true
|
|
} catch (error) {
|
|
console.error("❌ Failed to reload Nginx:", error.message)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop the Nginx server
|
|
* @returns {Boolean} - Success status
|
|
*/
|
|
async stop() {
|
|
try {
|
|
if (this.nginxProcess) {
|
|
// Try graceful shutdown first
|
|
this.execNginxCommand(["-s", "quit", "-c", this.mainConfigPath])
|
|
|
|
// Give Nginx time to shut down
|
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
|
|
// If still running, force kill
|
|
if (this.nginxProcess && this.nginxProcess.exitCode === null) {
|
|
this.nginxProcess.kill("SIGTERM")
|
|
|
|
// If STILL running after another second, use SIGKILL
|
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
if (
|
|
this.nginxProcess &&
|
|
this.nginxProcess.exitCode === null
|
|
) {
|
|
this.nginxProcess.kill("SIGKILL")
|
|
}
|
|
}
|
|
|
|
this.nginxProcess = null
|
|
}
|
|
|
|
this.isNginxRunning = false
|
|
console.log("🛑 Nginx stopped")
|
|
return true
|
|
} catch (error) {
|
|
console.error("❌ Failed to stop Nginx:", error.message)
|
|
return false
|
|
}
|
|
}
|
|
}
|