diff --git a/packages/server/.dockerignore b/packages/server/.dockerignore index e8e39d89..2092bbf0 100644 --- a/packages/server/.dockerignore +++ b/packages/server/.dockerignore @@ -20,6 +20,8 @@ /**/**/d_data /**/**/redis_data /**/**/*.env +/**/**/.nginx +/**/**/nginx-bin # Locks /**/**/package-lock.json diff --git a/packages/server/.gitignore b/packages/server/.gitignore index e8e39d89..2092bbf0 100755 --- a/packages/server/.gitignore +++ b/packages/server/.gitignore @@ -20,6 +20,8 @@ /**/**/d_data /**/**/redis_data /**/**/*.env +/**/**/.nginx +/**/**/nginx-bin # Locks /**/**/package-lock.json diff --git a/packages/server/gateway/index.js b/packages/server/gateway/index.js old mode 100644 new mode 100755 index d0abd2a7..ef5f4a0f --- a/packages/server/gateway/index.js +++ b/packages/server/gateway/index.js @@ -8,24 +8,26 @@ import { onExit } from "signal-exit" import chalk from "chalk" import treeKill from "tree-kill" -import getIgnoredFiles from "./utils/getIgnoredFiles" import scanServices from "./utils/scanServices" -import spawnService from "./utils/spawnService" -import Proxy from "./proxy" import RELP from "./repl" import comtyAscii from "./ascii" import pkg from "../package.json" import ServiceManager from "./services/manager" import Service from "./services/service" +import * as Managers from "./managers" const isProduction = process.env.NODE_ENV === "production" +const sslKey = path.resolve(process.cwd(), ".ssl", "privkey.pem") +const sslCert = path.resolve(process.cwd(), ".ssl", "cert.pem") /** * Gateway class - Main entry point for the service orchestrator * Manages service discovery, spawning, and communication */ export default class Gateway { + static gatewayMode = process.env.GATEWAY_MODE ?? "http_proxy" + eventBus = new EventEmitter() state = { @@ -39,7 +41,8 @@ export default class Gateway { services = [] serviceRegistry = Observable.from({}) - proxy = null + gateway = null + ipcRouter = null /** @@ -134,12 +137,8 @@ export default class Gateway { this.onServiceReady(service) } - if (data.type === "router:register") { - await this.handleRouterRegistration(service, data) - } - - if (data.type === "router:ws:register") { - await this.handleWebsocketRegistration(service, data) + if (data.type === "service:register") { + await this.handleServiceRegistration(service, data) } } @@ -161,64 +160,48 @@ export default class Gateway { console.error(error) } - this.proxy.unregisterAllFromService(id) + if (typeof this.gateway.unregisterAllFromService === "function") { + this.gateway.unregisterAllFromService(id) + } } /** - * Handle router registration requests from services - * @param {Service} service - Service registering a route + * Handle both router and websocket registration requests from services + * @param {Service} service - Service registering a route or websocket * @param {object} msg - Registration message + * @param {boolean} isWebsocket - Whether this is a websocket registration */ - async handleRouterRegistration(service, msg) { - const id = service.id + async handleServiceRegistration(service, data) { + const { id } = service + const { namespace, http, websocket, listen } = data.register - if (msg.data.path_overrides) { - for await (const pathOverride of msg.data.path_overrides) { - await this.proxy.register({ + if (http && http.enabled === true && Array.isArray(http.paths)) { + for (const path of http.paths) { + await this.gateway.register({ serviceId: id, - path: `/${pathOverride}`, - target: `http://${this.state.internalIp}:${msg.data.listen.port}/${pathOverride}`, - pathRewrite: { - [`^/${pathOverride}`]: "", - }, + path: path, + target: `${http.proto}://${listen.ip}:${listen.port}${path}`, }) } - } else { - await this.proxy.register({ + } + + if (websocket && websocket.enabled === true) { + await this.gateway.register({ serviceId: id, - path: `/${id}`, - target: `http://${msg.data.listen.ip}:${msg.data.listen.port}`, + websocket: true, + path: websocket.path, + target: `${http.proto}://${listen.ip}:${listen.port}${websocket.path}`, }) } - } - /** - * Handle websocket registration requests from services - * @param {Service} service - Service registering a websocket - * @param {object} msg - Registration message - */ - async handleWebsocketRegistration(service, msg) { - const id = service.id - const listenPort = msg.data.listen_port ?? msg.data.listen?.port - let target = `http://${this.state.internalIp}:${listenPort}` - - if (!msg.data.ws_path && msg.data.namespace) { - target += `/${msg.data.namespace}` + if (this.state.allReady) { + if (typeof this.gateway.applyConfiguration === "function") { + await this.gateway.applyConfiguration() + } + if (typeof this.gateway.reload === "function") { + await this.gateway.reload() + } } - - if (msg.data.ws_path && msg.data.ws_path !== "/") { - target += `/${msg.data.ws_path}` - } - - await this.proxy.register({ - serviceId: id, - path: `/${msg.data.namespace}`, - target: target, - pathRewrite: { - [`^/${msg.data.namespace}`]: "", - }, - ws: true, - }) } /** @@ -232,6 +215,7 @@ export default class Gateway { ).every((service) => service.initialized) if (allServicesInitialized) { + this.state.allReady = true this.onAllServicesReady() } } @@ -247,29 +231,34 @@ export default class Gateway { * Handle when all services are ready */ onAllServicesReady = async () => { - if (this.state.allReady) { - return false - } + //console.clear() + //console.log(comtyAscii) - console.clear() - this.state.allReady = true - - console.log(comtyAscii) + console.log("\n\n\n") console.log(`🎉 All services[${this.services.length}] ready!\n`) console.log(`USE: select , reload, exit`) - await this.proxy.listen(this.state.proxyPort, this.state.internalIp) + if (typeof this.gateway.applyConfiguration === "function") { + await this.gateway.applyConfiguration() + } + + if (typeof this.gateway.start === "function") { + await this.gateway.start() + } } /** * Clean up resources on gateway exit */ onGatewayExit = () => { - console.clear() + //console.clear() console.log(`\n🛑 Preparing to exit...`) - console.log(`Stopping proxy...`) - this.proxy.close() + if (typeof this.gateway.stop === "function") { + console.log(`Stopping gateway...`) + this.gateway.stop() + } + console.log(`Stopping all services...`) this.serviceManager.stopAllServices() @@ -280,6 +269,13 @@ export default class Gateway { * Initialize the gateway and start all services */ async initialize() { + if (!Managers[this.constructor.gatewayMode]) { + console.error( + `❌ Gateway mode [${this.constructor.gatewayMode}] not supported`, + ) + return 0 + } + onExit(this.onGatewayExit) // Increase limits to handle many services @@ -287,7 +283,6 @@ export default class Gateway { process.stderr.setMaxListeners(150) this.services = await scanServices() - this.proxy = new Proxy() this.ipcRouter = new IPCRouter() if (this.services.length === 0) { @@ -295,12 +290,24 @@ export default class Gateway { return process.exit(1) } + // Initialize gateway + this.gateway = new Managers[this.constructor.gatewayMode]({ + port: this.state.proxyPort, + internalIp: this.state.internalIp, + cert_file_name: sslCert, + key_file_name: sslKey, + }) + + if (typeof this.gateway.initialize === "function") { + await this.gateway.initialize() + } + // Make key components available globally global.eventBus = this.eventBus global.ipcRouter = this.ipcRouter global.proxy = this.proxy - console.clear() + //console.clear() console.log(comtyAscii) console.log( `\nRunning ${chalk.bgBlue(`${pkg.name}`)} | ${chalk.bgMagenta(`[v${pkg.version}]`)} | ${this.state.internalIp} | ${isProduction ? "production" : "development"} \n\n\n`, @@ -316,6 +323,12 @@ export default class Gateway { await this.createServicesRegistry() await this.createServiceInstances() + // WARNING: Starting relp makes uwebsockets unable to work properly, surging some bugs from nodejs (domain.enter) + // use another alternative to parse commands, like stdin reading or something... + //this.startRELP() + } + + startRELP() { // Initialize REPL interface new RELP({ attachAllServicesSTD: () => diff --git a/packages/server/gateway/managers/http-proxy/index.js b/packages/server/gateway/managers/http-proxy/index.js new file mode 100755 index 00000000..e5f44987 --- /dev/null +++ b/packages/server/gateway/managers/http-proxy/index.js @@ -0,0 +1,310 @@ +import httpProxy from "http-proxy" +import defaults from "linebridge/dist/defaults" +import pkg from "../../../package.json" // Ajustado la ruta para que coincida con tu estructura + +import http from "node:http" +import https from "node:https" +import fs from "node:fs" +import path from "node:path" + +function getHttpServerEngine(extraOptions = {}, handler = () => {}) { + const sslKey = path.resolve(process.cwd(), ".ssl", "privkey.pem") + const sslCert = path.resolve(process.cwd(), ".ssl", "cert.pem") + + if (fs.existsSync(sslKey) && fs.existsSync(sslCert)) { + console.log("Using HTTPS server") + return https.createServer( + { + key: fs.readFileSync(sslKey), + cert: fs.readFileSync(sslCert), + ...extraOptions, + }, + handler, + ) + } else { + console.log("Using HTTP server") + return http.createServer(extraOptions, handler) + } +} + +export default class Proxy { + constructor() { + this.routes = new Map() + this.config = {} + + // Crear servidor HTTP + this.server = getHttpServerEngine({}, this.handleRequest.bind(this)) + + // Manejar upgrades de WebSocket + this.server.on("upgrade", this.handleUpgrade.bind(this)) + + // Crear una única instancia de proxy que se reutilizará + this.proxyServer = httpProxy.createProxyServer({ + changeOrigin: true, + xfwd: true, + }) + + // Manejar errores del proxy + this.proxyServer.on("error", (err, req, res) => { + console.error("Proxy error:", err) + if (res && !res.headersSent) { + res.writeHead(502, { "Content-Type": "application/json" }) + res.end( + JSON.stringify({ + error: "Bad Gateway", + message: err.message, + }), + ) + } + }) + } + + register = ({ serviceId, path, target, pathRewrite, websocket } = {}) => { + if (!path || !target) { + throw new Error("Path and target are required") + } + + if (this.routes.has(path)) { + console.warn(`Route already registered [${path}], skipping...`) + return false + } + + const routeObj = { + serviceId: serviceId ?? "default_service", + path, + target, + pathRewrite, + isWebSocket: !!websocket, + } + + console.log( + `🔗 Registering ${websocket ? "websocket" : "http"} route [${path}] -> [${target}]`, + ) + this.routes.set(path, routeObj) + return true + } + + unregister = (path) => { + if (!this.routes.has(path)) { + console.warn(`Route not registered [${path}], skipping...`) + return false + } + + console.log(`🔗 Unregistering route [${path}]`) + this.routes.delete(path) + return true + } + + unregisterAllFromService = (serviceId) => { + const pathsToRemove = [] + + this.routes.forEach((route, path) => { + if (route.serviceId === serviceId) { + pathsToRemove.push(path) + } + }) + + pathsToRemove.forEach(this.unregister) + } + + initialize = async () => { + // No es necesario inicializar nada, el servidor ya está configurado + return true + } + + start = async (port = 9000, host = "0.0.0.0") => { + return new Promise((resolve, reject) => { + this.server.listen(port, host, (err) => { + if (err) { + console.error("Failed to start server:", err) + return reject(err) + } + console.log(`🚀 Server listening on ${host}:${port}`) + resolve() + }) + }) + } + + stop = async () => { + return new Promise((resolve) => { + this.server.close(() => { + console.log("Server stopped") + this.proxyServer.close() + resolve() + }) + }) + } + + applyConfiguration = async () => { + // No se necesita aplicar configuración específica + return true + } + + reload = async () => { + // No es necesario recargar nada + return true + } + + rewritePath = (rewriteConfig, path) => { + let result = path + + if (!rewriteConfig) return result + + for (const [pattern, replacement] of Object.entries(rewriteConfig)) { + const regex = new RegExp(pattern) + if (regex.test(path)) { + result = result.replace(regex, replacement) + break + } + } + + return result + } + + setCorsHeaders = (res) => { + res.setHeader("Access-Control-Allow-Origin", "*") + res.setHeader( + "Access-Control-Allow-Methods", + "GET,HEAD,PUT,PATCH,POST,DELETE", + ) + res.setHeader("Access-Control-Allow-Headers", "*") + return res + } + + findRouteForPath(url) { + const urlPath = url.split("?")[0] + const segments = urlPath.split("/").filter(Boolean) + + if (segments.length === 0) return null + + const namespace = `/${segments[0]}` + return this.routes.get(namespace) + } + + handleRequest = (req, res) => { + this.setCorsHeaders(res) + + // Si es una solicitud OPTIONS, responder inmediatamente + if (req.method === "OPTIONS") { + res.statusCode = 204 + res.end() + return + } + + // Responder a solicitudes raíz + if (req.url === "/") { + res.setHeader("Content-Type", "application/json") + res.end( + JSON.stringify({ + name: pkg.name, + version: pkg.version, + lb_version: defaults?.version || "unknown", + }), + ) + return + } + + // Encontrar la ruta para esta solicitud + const route = this.findRouteForPath(req.url) + + if (!route) { + res.statusCode = 404 + res.setHeader("Content-Type", "application/json") + res.end( + JSON.stringify({ + error: "Gateway route not found", + details: + "The requested route does not exist or the service is down", + path: req.url, + }), + ) + return + } + + // Preparar opciones de proxy + const proxyOptions = { + target: route.target, + changeOrigin: true, + xfwd: true, + } + + // save original url + req.originalUrl = req.url + + // Aplicar reescritura de ruta si está configurada + if (route.pathRewrite) { + req.url = this.rewritePath(route.pathRewrite, req.url) + } else { + req.url = this.rewritePath({ [`^${route.path}`]: "" }, req.url) + } + + // Agregar encabezados personalizados + this.proxyServer.on("proxyReq", (proxyReq, req, res, options) => { + proxyReq.setHeader("x-linebridge-version", pkg.version) + proxyReq.setHeader( + "x-forwarded-for", + req.socket.remoteAddress || req.ip, + ) + proxyReq.setHeader("x-service-id", route.serviceId) + proxyReq.setHeader( + "X-Forwarded-Proto", + req.socket.encrypted ? "https" : "http", + ) + }) + + // Proxy la solicitud + this.proxyServer.web(req, res, proxyOptions, (err) => { + if (err) { + console.error("Proxy error:", err) + if (!res.headersSent) { + res.statusCode = 502 + res.setHeader("Content-Type", "application/json") + res.end( + JSON.stringify({ + error: "Bad Gateway", + message: err.message, + }), + ) + } + } + }) + } + + handleUpgrade = (req, socket, head) => { + // Encontrar la ruta para esta conexión WebSocket + const route = this.findRouteForPath(req.url) + + if (!route) { + console.error(`WebSocket route not found for ${req.url}`) + socket.write("HTTP/1.1 404 Not Found\r\n\r\n") + socket.destroy() + return + } + + // save original url + req.originalUrl = req.url + + // Aplicar reescritura de ruta si está configurada + if (route.pathRewrite) { + req.url = this.rewritePath(route.pathRewrite, req.url) + } else { + req.url = this.rewritePath({ [`^${route.path}`]: "" }, req.url) + } + + // Crear un objeto de opciones de proxy específico para WebSocket + const wsProxyOptions = { + target: route.target, + ws: true, + changeOrigin: true, + } + + // Proxy la conexión WebSocket + this.proxyServer.ws(req, socket, head, wsProxyOptions, (err) => { + if (err) { + console.error("WebSocket proxy error:", err) + socket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n") + socket.destroy() + } + }) + } +} diff --git a/packages/server/gateway/managers/index.js b/packages/server/gateway/managers/index.js new file mode 100644 index 00000000..7abc3535 --- /dev/null +++ b/packages/server/gateway/managers/index.js @@ -0,0 +1,2 @@ +export { default as nginx } from "./nginx" +export { default as http_proxy } from "./http-proxy" diff --git a/packages/server/gateway/managers/nginx/index.js b/packages/server/gateway/managers/nginx/index.js new file mode 100755 index 00000000..1dd4aad2 --- /dev/null +++ b/packages/server/gateway/managers/nginx/index.js @@ -0,0 +1,599 @@ +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 { platform } from "node:os" + +const localNginxBinary = path.resolve(process.cwd(), "nginx") + +/** + * NginxManager - Optimized version that batches configurations + * Waits for all services to register before applying configuration + */ +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 + + // Debug mode + this.debug = options.debug || 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 config = ` +# Nginx configuration for Comty API Gateway +# Auto-generated - Do not edit manually + +worker_processes auto; +error_log ${normalizedLogsDir}/error.log ${this.debug ? "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 ${this.debug ? "debug" : "main"}; + + sendfile on; + tcp_nopush on; + + tcp_nodelay on; + + client_max_body_size 100M; + + # WebSocket support + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + # 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; + + 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};` : ""} + + # Default route + 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 '{"ok":1}'; + } + + # Include service-specific configurations + include ${normalizedConfigDir}/services.conf; + } + } +` + + 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 (this.debug) { + console.log( + `🔍 Registering route for [${serviceId}]: ${normalizedPath} -> ${target} (${websocket ? "WebSocket" : "HTTP"})`, + ) + } + + // Store the route with improved handling of path rewrites + const effectivePathRewrite = pathRewrite || {} + + this.routes.set(normalizedPath, { + serviceId, + 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 (this.debug) { + 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 += `\trewrite ${pattern} ${replacement}$is_args$args break;` + } + } else { + // If no explicit rewrite is defined, but we need to strip the path prefix, + // Generate a default rewrite that preserves the URL structure + if (path !== "/") { + rewriteConfig += "# Default path rewrite to strip prefix\n" + rewriteConfig += `\trewrite ^${path}(/.*)$ $1$is_args$args break;\n` + rewriteConfig += `\trewrite ^${path}$ / 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; + } + + # Set proxy configuration + proxy_http_version 1.1; + proxy_pass_request_headers on; + + # Standard proxy headers + proxy_set_header Host $host:$server_port; + 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; + + # Set headers for WebSocket support + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # 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 (this.debug) { + 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 close() { + 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 + } + } +} diff --git a/packages/server/gateway/proxy.js b/packages/server/gateway/proxy.js deleted file mode 100644 index 29d914ce..00000000 --- a/packages/server/gateway/proxy.js +++ /dev/null @@ -1,227 +0,0 @@ -import httpProxy from "http-proxy" -import defaults from "linebridge/dist/defaults" - -import pkg from "../package.json" - -import http from "node:http" -import https from "node:https" - -import fs from "node:fs" -import path from "node:path" - -function getHttpServerEngine(extraOptions = {}, handler = () => {}) { - const sslKey = path.resolve(process.cwd(), ".ssl", "privkey.pem") - const sslCert = path.resolve(process.cwd(), ".ssl", "cert.pem") - - if (fs.existsSync(sslKey) && fs.existsSync(sslCert)) { - return https.createServer( - { - key: fs.readFileSync(sslKey), - cert: fs.readFileSync(sslCert), - ...extraOptions, - }, - handler, - ) - } else { - return http.createServer(extraOptions, handler) - } -} - -export default class Proxy { - constructor() { - this.proxys = new Map() - this.wsProxys = new Map() - - this.http = getHttpServerEngine({}, this.handleHttpRequest) - this.http.on("upgrade", this.handleHttpUpgrade) - } - - http = null - - register = ({ serviceId, path, target, pathRewrite, ws } = {}) => { - if (!path) { - throw new Error("Path is required") - } - - if (!target) { - throw new Error("Target is required") - } - - if (this.proxys.has(path)) { - console.warn(`Proxy already registered [${path}], skipping...`) - return false - } - - const proxy = httpProxy.createProxyServer({ - target: target, - }) - - proxy.on("proxyReq", (proxyReq, req, res, options) => { - proxyReq.setHeader("x-linebridge-version", pkg.version) - proxyReq.setHeader("x-forwarded-for", req.socket.remoteAddress) - proxyReq.setHeader("x-service-id", serviceId) - proxyReq.setHeader( - "X-Forwarded-Proto", - req.socket.encrypted ? "https" : "http", - ) - }) - - proxy.on("error", (e) => { - console.error(e) - }) - - const proxyObj = { - serviceId: serviceId ?? "default_service", - path: path, - target: target, - pathRewrite: pathRewrite, - proxy: proxy, - } - - if (ws) { - console.log( - `🔗 Registering websocket proxy [${path}] -> [${target}]`, - ) - this.wsProxys.set(path, proxyObj) - } else { - console.log(`🔗 Registering path proxy [${path}] -> [${target}]`) - this.proxys.set(path, proxyObj) - } - - return true - } - - unregister = (path) => { - if (!this.proxys.has(path)) { - console.warn(`Proxy not registered [${path}], skipping...`) - return false - } - - console.log(`🔗 Unregistering path proxy [${path}]`) - - this.proxys.get(path).proxy.close() - this.proxys.delete(path) - } - - unregisterAllFromService = (serviceId) => { - this.proxys.forEach((value, key) => { - if (value.serviceId === serviceId) { - this.unregister(value.path) - } - }) - } - - listen = async (port = 9000, host = "0.0.0.0", cb) => { - return await new Promise((resolve, reject) => { - this.http.listen(port, host, () => { - console.log(`🔗 Proxy listening on ${host}:${port}`) - - if (cb) { - cb(this) - } - - resolve(this) - }) - }) - } - - rewritePath = (rewriteConfig, path) => { - let result = path - const rules = [] - - for (const [key, value] of Object.entries(rewriteConfig)) { - rules.push({ - regex: new RegExp(key), - value: value, - }) - } - - for (const rule of rules) { - if (rule.regex.test(path)) { - result = result.replace(rule.regex, rule.value) - break - } - } - - return result - } - - setCorsHeaders = (res) => { - res.setHeader("Access-Control-Allow-Origin", "*") - res.setHeader( - "Access-Control-Allow-Methods", - "GET,HEAD,PUT,PATCH,POST,DELETE", - ) - res.setHeader("Access-Control-Allow-Headers", "*") - - return res - } - - handleHttpRequest = (req, res) => { - res = this.setCorsHeaders(res) - - const sanitizedUrl = req.url.split("?")[0] - - // preflight continue with code 204 - if (req.method === "OPTIONS") { - res.statusCode = 204 - res.end() - return - } - - if (sanitizedUrl === "/") { - return res.end( - JSON.stringify({ - name: pkg.name, - version: pkg.version, - lb_version: defaults.version, - }), - ) - } - - const namespace = `/${sanitizedUrl.split("/")[1]}` - const route = this.proxys.get(namespace) - - if (!route) { - res.statusCode = 404 - - res.end( - JSON.stringify({ - error: "Gateway route not found", - details: - "The gateway route you are trying to access does not exist, maybe the service is down...", - namespace: namespace, - }), - ) - - return null - } - - if (route.pathRewrite) { - req.url = this.rewritePath(route.pathRewrite, req.url) - } - - route.proxy.web(req, res) - } - - handleHttpUpgrade = (req, socket, head) => { - const namespace = `/${req.url.split("/")[1].split("?")[0]}` - const route = this.wsProxys.get(namespace) - - if (!route) { - // destroy socket - socket.destroy() - return false - } - - if (route.pathRewrite) { - req.url = this.rewritePath(route.pathRewrite, req.url) - } - - route.proxy.ws(req, socket, head) - } - - close = () => { - this.http.close() - } -} diff --git a/packages/server/gateway/repl.js b/packages/server/gateway/repl.js old mode 100644 new mode 100755 diff --git a/packages/server/gateway/services/manager.js b/packages/server/gateway/services/manager.js old mode 100644 new mode 100755 diff --git a/packages/server/gateway/services/service.js b/packages/server/gateway/services/service.js old mode 100644 new mode 100755 diff --git a/packages/server/gateway/utils/createServiceLogTransformer.js b/packages/server/gateway/utils/createServiceLogTransformer.js old mode 100644 new mode 100755 diff --git a/packages/server/gateway/utils/getIgnoredFiles.js b/packages/server/gateway/utils/getIgnoredFiles.js old mode 100644 new mode 100755 diff --git a/packages/server/gateway/utils/scanServices.js b/packages/server/gateway/utils/scanServices.js old mode 100644 new mode 100755 diff --git a/packages/server/gateway/utils/spawnService.js b/packages/server/gateway/utils/spawnService.js old mode 100644 new mode 100755 diff --git a/packages/server/gateway/vars.js b/packages/server/gateway/vars.js old mode 100644 new mode 100755 diff --git a/packages/server/krakend.json b/packages/server/krakend.json deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/server/package.json b/packages/server/package.json index 6523300e..802c98e2 100755 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -11,6 +11,8 @@ "dev": "cross-env NODE_ENV=development hermes-node ./start.js" }, "dependencies": { + "@grpc/grpc-js": "^1.13.2", + "@grpc/proto-loader": "^0.7.13", "@infisical/sdk": "^2.1.8", "@opentelemetry/api": "^1.9.0", "@opentelemetry/auto-instrumentations-node": "^0.56.1", @@ -25,7 +27,7 @@ "cross-env": "^7.0.3", "http-proxy": "^1.18.1", "jsonwebtoken": "^9.0.2", - "linebridge": "^0.24.1", + "linebridge": "^0.25.2", "minimatch": "^10.0.1", "minio": "^8.0.1", "module-alias": "^2.2.3",