diff --git a/.github/prompts b/.github/prompts new file mode 100644 index 0000000..e5de64a --- /dev/null +++ b/.github/prompts @@ -0,0 +1,24 @@ +# Load Balancer Network Feature Documentation +# Feature: Network Load Balancer (NLB) +# Version: 1.0.0 + +## Overview +The Network Load Balancer (NLB) feature provides high availability and scalability for network traffic by distributing incoming requests across multiple servers. It operates at the transport layer (Layer 4) and is designed to handle millions of requests per second while maintaining ultra-low latencies. + +## Key Features +- Tích hợp với nền tảng hiện tại một cách liền mạch +- Hỗ trợ cân bằng tải TCP và UDP +- Giao diện quản lý dễ sử dụng tương thích với giao diện hiện tại nghiêm cấm phá vỡ giao diện +- tính năng đầy đủ để người dùng điền đầy đủ các thông tin để đảm bảo tính năng có thể hoạt động +- một port người dùng có thể bật cả TCP và UDP +- giao diện quản lý có các chức năng +## Requirements + +- Follow the project code flow, git flow, +- Do not create test files, debug files, or unnecessary files +- If a file has errors, fix the file - do not delete it +- Do not creat document file +- optimize database query statements +- NLB must use port above 10000 incoming + + diff --git a/apps/api/prisma/migrations/20251011072500_add_network_load_balancer/migration.sql b/apps/api/prisma/migrations/20251011072500_add_network_load_balancer/migration.sql new file mode 100644 index 0000000..77ca4db --- /dev/null +++ b/apps/api/prisma/migrations/20251011072500_add_network_load_balancer/migration.sql @@ -0,0 +1,100 @@ +-- CreateEnum +CREATE TYPE "NLBStatus" AS ENUM ('active', 'inactive', 'error'); + +-- CreateEnum +CREATE TYPE "NLBProtocol" AS ENUM ('tcp', 'udp', 'tcp_udp'); + +-- CreateEnum +CREATE TYPE "NLBAlgorithm" AS ENUM ('round_robin', 'least_conn', 'ip_hash', 'hash'); + +-- CreateEnum +CREATE TYPE "NLBUpstreamStatus" AS ENUM ('up', 'down', 'checking'); + +-- CreateTable +CREATE TABLE "network_load_balancers" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "port" INTEGER NOT NULL, + "protocol" "NLBProtocol" NOT NULL DEFAULT 'tcp', + "algorithm" "NLBAlgorithm" NOT NULL DEFAULT 'round_robin', + "status" "NLBStatus" NOT NULL DEFAULT 'inactive', + "enabled" BOOLEAN NOT NULL DEFAULT true, + "proxyTimeout" INTEGER NOT NULL DEFAULT 3, + "proxyConnectTimeout" INTEGER NOT NULL DEFAULT 1, + "proxyNextUpstream" BOOLEAN NOT NULL DEFAULT true, + "proxyNextUpstreamTimeout" INTEGER NOT NULL DEFAULT 0, + "proxyNextUpstreamTries" INTEGER NOT NULL DEFAULT 0, + "healthCheckEnabled" BOOLEAN NOT NULL DEFAULT true, + "healthCheckInterval" INTEGER NOT NULL DEFAULT 10, + "healthCheckTimeout" INTEGER NOT NULL DEFAULT 5, + "healthCheckRises" INTEGER NOT NULL DEFAULT 2, + "healthCheckFalls" INTEGER NOT NULL DEFAULT 3, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "network_load_balancers_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "nlb_upstreams" ( + "id" TEXT NOT NULL, + "nlbId" TEXT NOT NULL, + "host" TEXT NOT NULL, + "port" INTEGER NOT NULL, + "weight" INTEGER NOT NULL DEFAULT 1, + "maxFails" INTEGER NOT NULL DEFAULT 3, + "failTimeout" INTEGER NOT NULL DEFAULT 10, + "maxConns" INTEGER NOT NULL DEFAULT 0, + "backup" BOOLEAN NOT NULL DEFAULT false, + "down" BOOLEAN NOT NULL DEFAULT false, + "status" "NLBUpstreamStatus" NOT NULL DEFAULT 'checking', + "lastCheck" TIMESTAMP(3), + "lastError" TEXT, + "responseTime" DOUBLE PRECISION, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "nlb_upstreams_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "nlb_health_checks" ( + "id" TEXT NOT NULL, + "nlbId" TEXT NOT NULL, + "upstreamHost" TEXT NOT NULL, + "upstreamPort" INTEGER NOT NULL, + "status" "NLBUpstreamStatus" NOT NULL, + "responseTime" DOUBLE PRECISION, + "error" TEXT, + "checkedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "nlb_health_checks_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "network_load_balancers_name_key" ON "network_load_balancers"("name"); + +-- CreateIndex +CREATE INDEX "network_load_balancers_status_idx" ON "network_load_balancers"("status"); + +-- CreateIndex +CREATE INDEX "network_load_balancers_port_idx" ON "network_load_balancers"("port"); + +-- CreateIndex +CREATE INDEX "nlb_upstreams_nlbId_idx" ON "nlb_upstreams"("nlbId"); + +-- CreateIndex +CREATE INDEX "nlb_upstreams_status_idx" ON "nlb_upstreams"("status"); + +-- CreateIndex +CREATE INDEX "nlb_health_checks_nlbId_checkedAt_idx" ON "nlb_health_checks"("nlbId", "checkedAt"); + +-- CreateIndex +CREATE INDEX "nlb_health_checks_upstreamHost_upstreamPort_idx" ON "nlb_health_checks"("upstreamHost", "upstreamPort"); + +-- AddForeignKey +ALTER TABLE "nlb_upstreams" ADD CONSTRAINT "nlb_upstreams_nlbId_fkey" FOREIGN KEY ("nlbId") REFERENCES "network_load_balancers"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "nlb_health_checks" ADD CONSTRAINT "nlb_health_checks_nlbId_fkey" FOREIGN KEY ("nlbId") REFERENCES "network_load_balancers"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d852276..80c3de0 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -632,3 +632,112 @@ model ConfigVersion { @@index([createdAt]) @@map("config_versions") } + +// Network Load Balancer Models + +enum NLBStatus { + active + inactive + error +} + +enum NLBProtocol { + tcp + udp + tcp_udp // Both TCP and UDP +} + +enum NLBAlgorithm { + round_robin + least_conn + ip_hash + hash +} + +enum NLBUpstreamStatus { + up + down + checking +} + +model NetworkLoadBalancer { + id String @id @default(cuid()) + name String @unique + description String? @db.Text + port Int // Listen port (must be >= 10000) + protocol NLBProtocol @default(tcp) + algorithm NLBAlgorithm @default(round_robin) + status NLBStatus @default(inactive) + enabled Boolean @default(true) + + // Advanced settings + proxyTimeout Int @default(3) // seconds + proxyConnectTimeout Int @default(1) // seconds + proxyNextUpstream Boolean @default(true) + proxyNextUpstreamTimeout Int @default(0) // seconds, 0 = disabled + proxyNextUpstreamTries Int @default(0) // 0 = unlimited + + // Health check settings + healthCheckEnabled Boolean @default(true) + healthCheckInterval Int @default(10) // seconds + healthCheckTimeout Int @default(5) // seconds + healthCheckRises Int @default(2) // Number of successful checks to mark as up + healthCheckFalls Int @default(3) // Number of failed checks to mark as down + + // Relations + upstreams NLBUpstream[] + healthChecks NLBHealthCheck[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status]) + @@index([port]) + @@map("network_load_balancers") +} + +model NLBUpstream { + id String @id @default(cuid()) + nlbId String + nlb NetworkLoadBalancer @relation(fields: [nlbId], references: [id], onDelete: Cascade) + + host String + port Int + weight Int @default(1) // 1-100 + maxFails Int @default(3) + failTimeout Int @default(10) // seconds + maxConns Int @default(0) // 0 = unlimited + backup Boolean @default(false) + down Boolean @default(false) // Manually mark as down + status NLBUpstreamStatus @default(checking) + + // Metadata + lastCheck DateTime? + lastError String? @db.Text + responseTime Float? // milliseconds + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([nlbId]) + @@index([status]) + @@map("nlb_upstreams") +} + +model NLBHealthCheck { + id String @id @default(cuid()) + nlbId String + nlb NetworkLoadBalancer @relation(fields: [nlbId], references: [id], onDelete: Cascade) + + upstreamHost String + upstreamPort Int + status NLBUpstreamStatus + responseTime Float? // milliseconds + error String? @db.Text + + checkedAt DateTime @default(now()) + + @@index([nlbId, checkedAt]) + @@index([upstreamHost, upstreamPort]) + @@map("nlb_health_checks") +} diff --git a/apps/api/src/domains/nlb/dto/nlb.dto.ts b/apps/api/src/domains/nlb/dto/nlb.dto.ts new file mode 100644 index 0000000..ad59d8f --- /dev/null +++ b/apps/api/src/domains/nlb/dto/nlb.dto.ts @@ -0,0 +1,274 @@ +import { body, param, query } from 'express-validator'; + +/** + * Validation rules for NLB endpoints + */ + +// Upstream validation +export const upstreamValidation = [ + body('host') + .trim() + .notEmpty() + .withMessage('Host is required') + .isString() + .withMessage('Host must be a string'), + body('port') + .isInt({ min: 1, max: 65535 }) + .withMessage('Port must be between 1 and 65535'), + body('weight') + .optional() + .isInt({ min: 1, max: 100 }) + .withMessage('Weight must be between 1 and 100'), + body('maxFails') + .optional() + .isInt({ min: 0, max: 100 }) + .withMessage('Max fails must be between 0 and 100'), + body('failTimeout') + .optional() + .isInt({ min: 1, max: 3600 }) + .withMessage('Fail timeout must be between 1 and 3600 seconds'), + body('maxConns') + .optional() + .isInt({ min: 0 }) + .withMessage('Max connections must be a positive integer'), + body('backup') + .optional() + .isBoolean() + .withMessage('Backup must be a boolean'), + body('down') + .optional() + .isBoolean() + .withMessage('Down must be a boolean'), +]; + +// Create NLB validation +export const createNLBValidation = [ + body('name') + .trim() + .notEmpty() + .withMessage('Name is required') + .isLength({ min: 3, max: 100 }) + .withMessage('Name must be between 3 and 100 characters') + .matches(/^[a-zA-Z0-9_-]+$/) + .withMessage('Name can only contain letters, numbers, hyphens, and underscores'), + body('description') + .optional() + .trim() + .isLength({ max: 500 }) + .withMessage('Description must not exceed 500 characters'), + body('port') + .isInt({ min: 10000, max: 65535 }) + .withMessage('Port must be between 10000 and 65535'), + body('protocol') + .isIn(['tcp', 'udp', 'tcp_udp']) + .withMessage('Protocol must be tcp, udp, or tcp_udp'), + body('algorithm') + .optional() + .isIn(['round_robin', 'least_conn', 'ip_hash', 'hash']) + .withMessage('Algorithm must be round_robin, least_conn, ip_hash, or hash'), + body('upstreams') + .isArray({ min: 1 }) + .withMessage('At least one upstream is required'), + body('upstreams.*.host') + .trim() + .notEmpty() + .withMessage('Upstream host is required'), + body('upstreams.*.port') + .isInt({ min: 1, max: 65535 }) + .withMessage('Upstream port must be between 1 and 65535'), + body('upstreams.*.weight') + .optional() + .isInt({ min: 1, max: 100 }) + .withMessage('Upstream weight must be between 1 and 100'), + body('upstreams.*.maxFails') + .optional() + .isInt({ min: 0, max: 100 }) + .withMessage('Upstream max fails must be between 0 and 100'), + body('upstreams.*.failTimeout') + .optional() + .isInt({ min: 1, max: 3600 }) + .withMessage('Upstream fail timeout must be between 1 and 3600 seconds'), + body('upstreams.*.maxConns') + .optional() + .isInt({ min: 0 }) + .withMessage('Upstream max connections must be a positive integer'), + body('upstreams.*.backup') + .optional() + .isBoolean() + .withMessage('Upstream backup must be a boolean'), + body('upstreams.*.down') + .optional() + .isBoolean() + .withMessage('Upstream down must be a boolean'), + + // Advanced settings + body('proxyTimeout') + .optional() + .isInt({ min: 1, max: 600 }) + .withMessage('Proxy timeout must be between 1 and 600 seconds'), + body('proxyConnectTimeout') + .optional() + .isInt({ min: 1, max: 60 }) + .withMessage('Proxy connect timeout must be between 1 and 60 seconds'), + body('proxyNextUpstream') + .optional() + .isBoolean() + .withMessage('Proxy next upstream must be a boolean'), + body('proxyNextUpstreamTimeout') + .optional() + .isInt({ min: 0, max: 600 }) + .withMessage('Proxy next upstream timeout must be between 0 and 600 seconds'), + body('proxyNextUpstreamTries') + .optional() + .isInt({ min: 0, max: 10 }) + .withMessage('Proxy next upstream tries must be between 0 and 10'), + + // Health check settings + body('healthCheckEnabled') + .optional() + .isBoolean() + .withMessage('Health check enabled must be a boolean'), + body('healthCheckInterval') + .optional() + .isInt({ min: 5, max: 300 }) + .withMessage('Health check interval must be between 5 and 300 seconds'), + body('healthCheckTimeout') + .optional() + .isInt({ min: 1, max: 60 }) + .withMessage('Health check timeout must be between 1 and 60 seconds'), + body('healthCheckRises') + .optional() + .isInt({ min: 1, max: 10 }) + .withMessage('Health check rises must be between 1 and 10'), + body('healthCheckFalls') + .optional() + .isInt({ min: 1, max: 10 }) + .withMessage('Health check falls must be between 1 and 10'), +]; + +// Update NLB validation +export const updateNLBValidation = [ + body('name') + .optional() + .trim() + .isLength({ min: 3, max: 100 }) + .withMessage('Name must be between 3 and 100 characters') + .matches(/^[a-zA-Z0-9_-]+$/) + .withMessage('Name can only contain letters, numbers, hyphens, and underscores'), + body('description') + .optional() + .trim() + .isLength({ max: 500 }) + .withMessage('Description must not exceed 500 characters'), + body('port') + .optional() + .isInt({ min: 10000, max: 65535 }) + .withMessage('Port must be between 10000 and 65535'), + body('protocol') + .optional() + .isIn(['tcp', 'udp', 'tcp_udp']) + .withMessage('Protocol must be tcp, udp, or tcp_udp'), + body('algorithm') + .optional() + .isIn(['round_robin', 'least_conn', 'ip_hash', 'hash']) + .withMessage('Algorithm must be round_robin, least_conn, ip_hash, or hash'), + body('status') + .optional() + .isIn(['active', 'inactive', 'error']) + .withMessage('Status must be active, inactive, or error'), + body('enabled') + .optional() + .isBoolean() + .withMessage('Enabled must be a boolean'), + body('upstreams') + .optional() + .isArray({ min: 1 }) + .withMessage('At least one upstream is required'), + body('upstreams.*.host') + .optional() + .trim() + .notEmpty() + .withMessage('Upstream host is required'), + body('upstreams.*.port') + .optional() + .isInt({ min: 1, max: 65535 }) + .withMessage('Upstream port must be between 1 and 65535'), + + // Advanced settings (same as create) + body('proxyTimeout') + .optional() + .isInt({ min: 1, max: 600 }) + .withMessage('Proxy timeout must be between 1 and 600 seconds'), + body('proxyConnectTimeout') + .optional() + .isInt({ min: 1, max: 60 }) + .withMessage('Proxy connect timeout must be between 1 and 60 seconds'), + body('proxyNextUpstream') + .optional() + .isBoolean() + .withMessage('Proxy next upstream must be a boolean'), + + // Health check settings + body('healthCheckEnabled') + .optional() + .isBoolean() + .withMessage('Health check enabled must be a boolean'), + body('healthCheckInterval') + .optional() + .isInt({ min: 5, max: 300 }) + .withMessage('Health check interval must be between 5 and 300 seconds'), +]; + +// Query validation +export const queryValidation = [ + query('page') + .optional() + .isInt({ min: 1 }) + .withMessage('Page must be a positive integer'), + query('limit') + .optional() + .isInt({ min: 1, max: 100 }) + .withMessage('Limit must be between 1 and 100'), + query('sortBy') + .optional() + .isIn(['name', 'port', 'status', 'createdAt', 'updatedAt']) + .withMessage('Invalid sort field'), + query('sortOrder') + .optional() + .isIn(['asc', 'desc']) + .withMessage('Sort order must be asc or desc'), + query('search') + .optional() + .trim() + .isString() + .withMessage('Search must be a string'), + query('status') + .optional() + .isIn(['active', 'inactive', 'error']) + .withMessage('Status must be active, inactive, or error'), + query('protocol') + .optional() + .isIn(['tcp', 'udp', 'tcp_udp']) + .withMessage('Protocol must be tcp, udp, or tcp_udp'), + query('enabled') + .optional() + .isIn(['true', 'false']) + .withMessage('Enabled must be true or false'), +]; + +// ID parameter validation +export const idValidation = [ + param('id') + .trim() + .notEmpty() + .withMessage('ID is required') + .isString() + .withMessage('ID must be a string'), +]; + +// Toggle enabled validation +export const toggleEnabledValidation = [ + body('enabled') + .isBoolean() + .withMessage('Enabled must be a boolean'), +]; diff --git a/apps/api/src/domains/nlb/index.ts b/apps/api/src/domains/nlb/index.ts new file mode 100644 index 0000000..c8f761d --- /dev/null +++ b/apps/api/src/domains/nlb/index.ts @@ -0,0 +1,5 @@ +export * from './nlb.types'; +export * from './nlb.repository'; +export * from './nlb.service'; +export * from './nlb.controller'; +export { default as nlbRoutes } from './nlb.routes'; diff --git a/apps/api/src/domains/nlb/nlb.controller.ts b/apps/api/src/domains/nlb/nlb.controller.ts new file mode 100644 index 0000000..201fa6b --- /dev/null +++ b/apps/api/src/domains/nlb/nlb.controller.ts @@ -0,0 +1,297 @@ +import { Response } from 'express'; +import { validationResult } from 'express-validator'; +import { AuthRequest } from '../../middleware/auth'; +import logger from '../../utils/logger'; +import { nlbService } from './nlb.service'; +import { NLBQueryOptions } from './nlb.types'; + +/** + * Controller for Network Load Balancer operations + */ +export class NLBController { + /** + * Get all NLBs with search and pagination + */ + async getNLBs(req: AuthRequest, res: Response): Promise { + try { + const { + page = 1, + limit = 10, + search = '', + status = '', + protocol = '', + enabled = '', + sortBy = 'createdAt', + sortOrder = 'desc', + } = req.query; + + const options: NLBQueryOptions = { + page: Number(page), + limit: Number(limit), + sortBy: sortBy as string, + sortOrder: sortOrder as 'asc' | 'desc', + filters: { + search: search as string, + status: status as string, + protocol: protocol as string, + enabled: enabled as string, + }, + }; + + const result = await nlbService.getAllNLBs(options); + + res.json({ + success: true, + data: result.nlbs, + pagination: result.pagination, + }); + } catch (error) { + logger.error('Get NLBs error:', error); + res.status(500).json({ + success: false, + message: 'Internal server error', + }); + } + } + + /** + * Get NLB by ID + */ + async getNLBById(req: AuthRequest, res: Response): Promise { + try { + const { id } = req.params; + const nlb = await nlbService.getNLBById(id); + + res.json({ + success: true, + data: nlb, + }); + } catch (error: any) { + logger.error('Get NLB by ID error:', error); + + if (error.statusCode === 404) { + res.status(404).json({ + success: false, + message: error.message, + }); + return; + } + + res.status(500).json({ + success: false, + message: 'Internal server error', + }); + } + } + + /** + * Create new NLB + */ + async createNLB(req: AuthRequest, res: Response): Promise { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + res.status(400).json({ + success: false, + errors: errors.array(), + }); + return; + } + + const nlb = await nlbService.createNLB(req.body); + + res.status(201).json({ + success: true, + data: nlb, + message: 'NLB created successfully', + }); + } catch (error: any) { + logger.error('Create NLB error:', error); + + if (error.statusCode === 400 || error.statusCode === 409) { + res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + return; + } + + res.status(500).json({ + success: false, + message: 'Failed to create NLB', + }); + } + } + + /** + * Update NLB + */ + async updateNLB(req: AuthRequest, res: Response): Promise { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + res.status(400).json({ + success: false, + errors: errors.array(), + }); + return; + } + + const { id } = req.params; + const nlb = await nlbService.updateNLB(id, req.body); + + res.json({ + success: true, + data: nlb, + message: 'NLB updated successfully', + }); + } catch (error: any) { + logger.error('Update NLB error:', error); + + if (error.statusCode === 404) { + res.status(404).json({ + success: false, + message: error.message, + }); + return; + } + + if (error.statusCode === 400 || error.statusCode === 409) { + res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + return; + } + + res.status(500).json({ + success: false, + message: 'Failed to update NLB', + }); + } + } + + /** + * Delete NLB + */ + async deleteNLB(req: AuthRequest, res: Response): Promise { + try { + const { id } = req.params; + await nlbService.deleteNLB(id); + + res.json({ + success: true, + message: 'NLB deleted successfully', + }); + } catch (error: any) { + logger.error('Delete NLB error:', error); + + if (error.statusCode === 404) { + res.status(404).json({ + success: false, + message: error.message, + }); + return; + } + + res.status(500).json({ + success: false, + message: 'Failed to delete NLB', + }); + } + } + + /** + * Toggle NLB enabled status + */ + async toggleNLB(req: AuthRequest, res: Response): Promise { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + res.status(400).json({ + success: false, + errors: errors.array(), + }); + return; + } + + const { id } = req.params; + const { enabled } = req.body; + + const nlb = await nlbService.toggleNLB(id, enabled); + + res.json({ + success: true, + data: nlb, + message: `NLB ${enabled ? 'enabled' : 'disabled'} successfully`, + }); + } catch (error: any) { + logger.error('Toggle NLB error:', error); + + if (error.statusCode === 404) { + res.status(404).json({ + success: false, + message: error.message, + }); + return; + } + + res.status(500).json({ + success: false, + message: 'Failed to toggle NLB', + }); + } + } + + /** + * Perform health check on NLB upstreams + */ + async performHealthCheck(req: AuthRequest, res: Response): Promise { + try { + const { id } = req.params; + const results = await nlbService.performHealthCheck(id); + + res.json({ + success: true, + data: results, + }); + } catch (error: any) { + logger.error('Perform health check error:', error); + + if (error.statusCode === 404) { + res.status(404).json({ + success: false, + message: error.message, + }); + return; + } + + res.status(500).json({ + success: false, + message: 'Failed to perform health check', + }); + } + } + + /** + * Get NLB statistics + */ + async getStats(req: AuthRequest, res: Response): Promise { + try { + const stats = await nlbService.getStats(); + + res.json({ + success: true, + data: stats, + }); + } catch (error) { + logger.error('Get NLB stats error:', error); + res.status(500).json({ + success: false, + message: 'Internal server error', + }); + } + } +} + +export const nlbController = new NLBController(); diff --git a/apps/api/src/domains/nlb/nlb.repository.ts b/apps/api/src/domains/nlb/nlb.repository.ts new file mode 100644 index 0000000..89560a7 --- /dev/null +++ b/apps/api/src/domains/nlb/nlb.repository.ts @@ -0,0 +1,363 @@ +import prisma from '../../config/database'; +import logger from '../../utils/logger'; +import { + NLBWithRelations, + NLBQueryOptions, + CreateNLBInput, + UpdateNLBInput, + CreateNLBUpstreamData, + NLBStats, +} from './nlb.types'; +import { PaginationMeta } from '../../shared/types/common.types'; + +/** + * Repository for Network Load Balancer database operations + */ +export class NLBRepository { + /** + * Find all NLBs with pagination and filters + */ + async findAll( + options: NLBQueryOptions + ): Promise<{ nlbs: NLBWithRelations[]; pagination: PaginationMeta }> { + const { + page = 1, + limit = 10, + sortBy = 'createdAt', + sortOrder = 'desc', + filters = {}, + } = options; + + const pageNum = parseInt(page.toString()); + const limitNum = parseInt(limit.toString()); + const skip = (pageNum - 1) * limitNum; + + // Build where clause + const where: any = {}; + + if (filters.search) { + where.OR = [ + { name: { contains: filters.search, mode: 'insensitive' } }, + { description: { contains: filters.search, mode: 'insensitive' } }, + ]; + } + + if (filters.status) { + where.status = filters.status; + } + + if (filters.protocol) { + where.protocol = filters.protocol; + } + + if (filters.enabled !== undefined && filters.enabled !== '') { + where.enabled = filters.enabled === 'true'; + } + + // Get total count + const totalCount = await prisma.networkLoadBalancer.count({ where }); + + // Get NLBs with pagination + const nlbs = await prisma.networkLoadBalancer.findMany({ + where, + include: { + upstreams: { + orderBy: { createdAt: 'asc' }, + }, + healthChecks: { + orderBy: { checkedAt: 'desc' }, + take: 10, + }, + }, + orderBy: { [sortBy]: sortOrder }, + skip, + take: limitNum, + }); + + // Calculate pagination + const totalPages = Math.ceil(totalCount / limitNum); + + return { + nlbs: nlbs as NLBWithRelations[], + pagination: { + page: pageNum, + limit: limitNum, + totalCount, + totalPages, + hasNextPage: pageNum < totalPages, + hasPreviousPage: pageNum > 1, + }, + }; + } + + /** + * Find NLB by ID + */ + async findById(id: string): Promise { + const nlb = await prisma.networkLoadBalancer.findUnique({ + where: { id }, + include: { + upstreams: { + orderBy: { createdAt: 'asc' }, + }, + healthChecks: { + orderBy: { checkedAt: 'desc' }, + take: 50, + }, + }, + }); + + return nlb as NLBWithRelations | null; + } + + /** + * Find NLB by name + */ + async findByName(name: string): Promise { + const nlb = await prisma.networkLoadBalancer.findUnique({ + where: { name }, + include: { + upstreams: true, + healthChecks: { + orderBy: { checkedAt: 'desc' }, + take: 10, + }, + }, + }); + + return nlb as NLBWithRelations | null; + } + + /** + * Find NLB by port + */ + async findByPort(port: number): Promise { + const nlb = await prisma.networkLoadBalancer.findFirst({ + where: { port }, + include: { + upstreams: true, + }, + }); + + return nlb as NLBWithRelations | null; + } + + /** + * Create new NLB + */ + async create(input: CreateNLBInput): Promise { + const nlb = await prisma.networkLoadBalancer.create({ + data: { + name: input.name, + description: input.description, + port: input.port, + protocol: input.protocol, + algorithm: input.algorithm || 'round_robin', + status: 'inactive', + enabled: true, + proxyTimeout: input.proxyTimeout || 3, + proxyConnectTimeout: input.proxyConnectTimeout || 1, + proxyNextUpstream: input.proxyNextUpstream !== undefined ? input.proxyNextUpstream : true, + proxyNextUpstreamTimeout: input.proxyNextUpstreamTimeout || 0, + proxyNextUpstreamTries: input.proxyNextUpstreamTries || 0, + healthCheckEnabled: input.healthCheckEnabled !== undefined ? input.healthCheckEnabled : true, + healthCheckInterval: input.healthCheckInterval || 10, + healthCheckTimeout: input.healthCheckTimeout || 5, + healthCheckRises: input.healthCheckRises || 2, + healthCheckFalls: input.healthCheckFalls || 3, + upstreams: { + create: input.upstreams.map((u: CreateNLBUpstreamData) => ({ + host: u.host, + port: u.port, + weight: u.weight || 1, + maxFails: u.maxFails || 3, + failTimeout: u.failTimeout || 10, + maxConns: u.maxConns || 0, + backup: u.backup || false, + down: u.down || false, + status: 'checking', + })), + }, + }, + include: { + upstreams: true, + healthChecks: true, + }, + }); + + return nlb as NLBWithRelations; + } + + /** + * Update NLB + */ + async update(id: string, input: UpdateNLBInput): Promise { + // If upstreams are provided, delete existing and create new ones + if (input.upstreams) { + await prisma.nLBUpstream.deleteMany({ + where: { nlbId: id }, + }); + } + + const nlb = await prisma.networkLoadBalancer.update({ + where: { id }, + data: { + name: input.name, + description: input.description, + port: input.port, + protocol: input.protocol, + algorithm: input.algorithm, + status: input.status, + enabled: input.enabled, + proxyTimeout: input.proxyTimeout, + proxyConnectTimeout: input.proxyConnectTimeout, + proxyNextUpstream: input.proxyNextUpstream, + proxyNextUpstreamTimeout: input.proxyNextUpstreamTimeout, + proxyNextUpstreamTries: input.proxyNextUpstreamTries, + healthCheckEnabled: input.healthCheckEnabled, + healthCheckInterval: input.healthCheckInterval, + healthCheckTimeout: input.healthCheckTimeout, + healthCheckRises: input.healthCheckRises, + healthCheckFalls: input.healthCheckFalls, + ...(input.upstreams && { + upstreams: { + create: input.upstreams.map((u: CreateNLBUpstreamData) => ({ + host: u.host, + port: u.port, + weight: u.weight || 1, + maxFails: u.maxFails || 3, + failTimeout: u.failTimeout || 10, + maxConns: u.maxConns || 0, + backup: u.backup || false, + down: u.down || false, + status: 'checking', + })), + }, + }), + }, + include: { + upstreams: true, + healthChecks: { + orderBy: { checkedAt: 'desc' }, + take: 10, + }, + }, + }); + + return nlb as NLBWithRelations; + } + + /** + * Delete NLB + */ + async delete(id: string): Promise { + await prisma.networkLoadBalancer.delete({ + where: { id }, + }); + } + + /** + * Update NLB status + */ + async updateStatus(id: string, status: 'active' | 'inactive' | 'error'): Promise { + await prisma.networkLoadBalancer.update({ + where: { id }, + data: { status }, + }); + } + + /** + * Toggle NLB enabled status + */ + async toggleEnabled(id: string, enabled: boolean): Promise { + const nlb = await prisma.networkLoadBalancer.update({ + where: { id }, + data: { + enabled, + status: enabled ? 'active' : 'inactive', + }, + include: { + upstreams: true, + healthChecks: { + orderBy: { checkedAt: 'desc' }, + take: 10, + }, + }, + }); + + return nlb as NLBWithRelations; + } + + /** + * Update upstream status + */ + async updateUpstreamStatus( + upstreamId: string, + status: 'up' | 'down' | 'checking', + responseTime?: number, + error?: string + ): Promise { + await prisma.nLBUpstream.update({ + where: { id: upstreamId }, + data: { + status, + lastCheck: new Date(), + responseTime, + lastError: error, + }, + }); + } + + /** + * Create health check record + */ + async createHealthCheck( + nlbId: string, + upstreamHost: string, + upstreamPort: number, + status: 'up' | 'down' | 'checking', + responseTime?: number, + error?: string + ): Promise { + await prisma.nLBHealthCheck.create({ + data: { + nlbId, + upstreamHost, + upstreamPort, + status, + responseTime, + error, + }, + }); + } + + /** + * Get NLB statistics + */ + async getStats(): Promise { + const [totalNLBs, activeNLBs, inactiveNLBs, upstreamStats] = await Promise.all([ + prisma.networkLoadBalancer.count(), + prisma.networkLoadBalancer.count({ where: { status: 'active' } }), + prisma.networkLoadBalancer.count({ where: { status: 'inactive' } }), + prisma.nLBUpstream.groupBy({ + by: ['status'], + _count: true, + }), + ]); + + const totalUpstreams = upstreamStats.reduce((acc, stat) => acc + stat._count, 0); + const healthyUpstreams = upstreamStats + .filter((s) => s.status === 'up' || s.status === 'checking') + .reduce((acc, stat) => acc + stat._count, 0); + const unhealthyUpstreams = upstreamStats.find((s) => s.status === 'down')?._count || 0; + + return { + totalNLBs, + activeNLBs, + inactiveNLBs, + totalUpstreams, + healthyUpstreams, + unhealthyUpstreams, + }; + } +} diff --git a/apps/api/src/domains/nlb/nlb.routes.ts b/apps/api/src/domains/nlb/nlb.routes.ts new file mode 100644 index 0000000..8cf01a8 --- /dev/null +++ b/apps/api/src/domains/nlb/nlb.routes.ts @@ -0,0 +1,100 @@ +import { Router } from 'express'; +import { nlbController } from './nlb.controller'; +import { authenticate, authorize } from '../../middleware/auth'; +import { + createNLBValidation, + updateNLBValidation, + queryValidation, + idValidation, + toggleEnabledValidation, +} from './dto/nlb.dto'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +/** + * @route GET /api/nlb + * @desc Get all NLBs with pagination and filters + * @access Private (viewer+) + */ +router.get('/', queryValidation, nlbController.getNLBs.bind(nlbController)); + +/** + * @route GET /api/nlb/stats + * @desc Get NLB statistics + * @access Private (viewer+) + */ +router.get('/stats', nlbController.getStats.bind(nlbController)); + +/** + * @route GET /api/nlb/:id + * @desc Get NLB by ID + * @access Private (viewer+) + */ +router.get('/:id', idValidation, nlbController.getNLBById.bind(nlbController)); + +/** + * @route POST /api/nlb + * @desc Create new NLB + * @access Private (moderator+) + */ +router.post( + '/', + authorize('admin', 'moderator'), + createNLBValidation, + nlbController.createNLB.bind(nlbController) +); + +/** + * @route PUT /api/nlb/:id + * @desc Update NLB + * @access Private (moderator+) + */ +router.put( + '/:id', + authorize('admin', 'moderator'), + idValidation, + updateNLBValidation, + nlbController.updateNLB.bind(nlbController) +); + +/** + * @route DELETE /api/nlb/:id + * @desc Delete NLB + * @access Private (admin only) + */ +router.delete( + '/:id', + authorize('admin'), + idValidation, + nlbController.deleteNLB.bind(nlbController) +); + +/** + * @route POST /api/nlb/:id/toggle + * @desc Toggle NLB enabled status + * @access Private (moderator+) + */ +router.post( + '/:id/toggle', + authorize('admin', 'moderator'), + idValidation, + toggleEnabledValidation, + nlbController.toggleNLB.bind(nlbController) +); + +/** + * @route POST /api/nlb/:id/health-check + * @desc Perform health check on NLB upstreams + * @access Private (moderator+) + */ +router.post( + '/:id/health-check', + authorize('admin', 'moderator'), + idValidation, + nlbController.performHealthCheck.bind(nlbController) +); + +export default router; diff --git a/apps/api/src/domains/nlb/nlb.service.ts b/apps/api/src/domains/nlb/nlb.service.ts new file mode 100644 index 0000000..0ea4792 --- /dev/null +++ b/apps/api/src/domains/nlb/nlb.service.ts @@ -0,0 +1,507 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import logger from '../../utils/logger'; +import { PATHS } from '../../shared/constants/paths.constants'; +import { NLBRepository } from './nlb.repository'; +import { + CreateNLBInput, + UpdateNLBInput, + NLBWithRelations, + NLBQueryOptions, + HealthCheckResult, + NLBStats, +} from './nlb.types'; +import { PaginationMeta } from '../../shared/types/common.types'; +import { AppError } from '../../middleware/errorHandler'; + +const execAsync = promisify(exec); + +/** + * Service for Network Load Balancer business logic + */ +export class NLBService { + private repository: NLBRepository; + private readonly streamsAvailablePath = '/etc/nginx/streams-available'; + private readonly streamsEnabledPath = '/etc/nginx/streams-enabled'; + private readonly streamIncludeFile = '/etc/nginx/stream.conf'; + + constructor() { + this.repository = new NLBRepository(); + } + + /** + * Get all NLBs with pagination + */ + async getAllNLBs( + options: NLBQueryOptions + ): Promise<{ nlbs: NLBWithRelations[]; pagination: PaginationMeta }> { + return this.repository.findAll(options); + } + + /** + * Get NLB by ID + */ + async getNLBById(id: string): Promise { + const nlb = await this.repository.findById(id); + if (!nlb) { + throw new AppError('NLB not found', 404); + } + return nlb; + } + + /** + * Create new NLB + */ + async createNLB(input: CreateNLBInput): Promise { + // Validate port range + if (input.port < 10000) { + throw new AppError('NLB port must be 10000 or higher', 400); + } + + // Check if name already exists + const existingByName = await this.repository.findByName(input.name); + if (existingByName) { + throw new AppError(`NLB with name "${input.name}" already exists`, 409); + } + + // Check if port already in use + const existingByPort = await this.repository.findByPort(input.port); + if (existingByPort) { + throw new AppError(`Port ${input.port} is already in use by NLB "${existingByPort.name}"`, 409); + } + + // Create NLB in database + const nlb = await this.repository.create(input); + + // Generate Nginx stream configuration + try { + await this.generateStreamConfig(nlb); + await this.enableStreamConfig(nlb.name); + await this.ensureStreamInclude(); + await this.testNginxConfig(); + await this.reloadNginx(); + + // Update status to active + await this.repository.updateStatus(nlb.id, 'active'); + + // Perform initial health check if enabled + if (nlb.healthCheckEnabled) { + try { + await this.performHealthCheck(nlb.id); + logger.info(`Initial health check completed for NLB: ${nlb.name}`); + } catch (error) { + logger.warn(`Initial health check failed for NLB: ${nlb.name}`, error); + // Don't fail NLB creation if health check fails + } + } + + logger.info(`NLB created successfully: ${nlb.name}`); + return this.repository.findById(nlb.id) as Promise; + } catch (error) { + logger.error(`Failed to create NLB config for ${nlb.name}:`, error); + await this.repository.updateStatus(nlb.id, 'error'); + throw new AppError('Failed to create NLB configuration', 500); + } + } + + /** + * Update NLB + */ + async updateNLB(id: string, input: UpdateNLBInput): Promise { + const nlb = await this.getNLBById(id); + + // Validate port if changed + if (input.port && input.port < 10000) { + throw new AppError('NLB port must be 10000 or higher', 400); + } + + // Check if name already exists (if changing name) + if (input.name && input.name !== nlb.name) { + const existingByName = await this.repository.findByName(input.name); + if (existingByName) { + throw new AppError(`NLB with name "${input.name}" already exists`, 409); + } + } + + // Check if port already in use (if changing port) + if (input.port && input.port !== nlb.port) { + const existingByPort = await this.repository.findByPort(input.port); + if (existingByPort && existingByPort.id !== id) { + throw new AppError(`Port ${input.port} is already in use by NLB "${existingByPort.name}"`, 409); + } + } + + // Update NLB in database + const updatedNLB = await this.repository.update(id, input); + + // Regenerate Nginx configuration if enabled + if (updatedNLB.enabled) { + try { + await this.generateStreamConfig(updatedNLB); + await this.enableStreamConfig(updatedNLB.name); + await this.testNginxConfig(); + await this.reloadNginx(); + await this.repository.updateStatus(id, 'active'); + logger.info(`NLB updated successfully: ${updatedNLB.name}`); + } catch (error) { + logger.error(`Failed to update NLB config for ${updatedNLB.name}:`, error); + await this.repository.updateStatus(id, 'error'); + throw new AppError('Failed to update NLB configuration', 500); + } + } else { + // If disabled, disable symlink + await this.disableStreamConfig(nlb.name); + await this.testNginxConfig(); + await this.reloadNginx(); + await this.repository.updateStatus(id, 'inactive'); + } + + return this.repository.findById(id) as Promise; + } + + /** + * Delete NLB + */ + async deleteNLB(id: string): Promise { + const nlb = await this.getNLBById(id); + + // Remove Nginx configuration + await this.disableStreamConfig(nlb.name); + await this.removeStreamConfig(nlb.name); + await this.testNginxConfig(); + await this.reloadNginx(); + + // Delete from database + await this.repository.delete(id); + + logger.info(`NLB deleted successfully: ${nlb.name}`); + } + + /** + * Toggle NLB enabled status + */ + async toggleNLB(id: string, enabled: boolean): Promise { + const nlb = await this.getNLBById(id); + + if (enabled) { + // Enable: generate config and create symlink + await this.generateStreamConfig(nlb); + await this.enableStreamConfig(nlb.name); + await this.testNginxConfig(); + await this.reloadNginx(); + await this.repository.toggleEnabled(id, true); + await this.repository.updateStatus(id, 'active'); + logger.info(`NLB enabled: ${nlb.name}`); + } else { + // Disable: remove symlink + await this.disableStreamConfig(nlb.name); + await this.testNginxConfig(); + await this.reloadNginx(); + await this.repository.toggleEnabled(id, false); + await this.repository.updateStatus(id, 'inactive'); + logger.info(`NLB disabled: ${nlb.name}`); + } + + return this.repository.findById(id) as Promise; + } + + /** + * Get NLB statistics + */ + async getStats(): Promise { + return this.repository.getStats(); + } + + /** + * Generate Nginx stream configuration for NLB + * Tạo file config trong streams-available (tương tự sites-available) + */ + private async generateStreamConfig(nlb: NLBWithRelations): Promise { + const configPath = path.join(this.streamsAvailablePath, `${nlb.name}.conf`); + + // Generate upstream block + const upstreamName = nlb.name.replace(/[^a-zA-Z0-9_]/g, '_'); + const algorithm = this.getAlgorithmDirective(nlb.algorithm); + + const upstreamServers = nlb.upstreams + .map((u) => { + const params = []; + if (u.weight !== 1) params.push(`weight=${u.weight}`); + if (u.maxFails !== 3) params.push(`max_fails=${u.maxFails}`); + if (u.failTimeout !== 10) params.push(`fail_timeout=${u.failTimeout}s`); + if (u.maxConns > 0) params.push(`max_conns=${u.maxConns}`); + if (u.backup) params.push('backup'); + if (u.down) params.push('down'); + + return ` server ${u.host}:${u.port}${params.length > 0 ? ' ' + params.join(' ') : ''};`; + }) + .join('\n'); + + const upstreamBlock = `upstream ${upstreamName} { +${algorithm ? ` ${algorithm}\n` : ''}${upstreamServers} +}`; + + // Generate server blocks based on protocol + const serverBlocks = []; + + if (nlb.protocol === 'tcp' || nlb.protocol === 'tcp_udp') { + serverBlocks.push(this.generateServerBlock(nlb, upstreamName, false)); + } + + if (nlb.protocol === 'udp' || nlb.protocol === 'tcp_udp') { + serverBlocks.push(this.generateServerBlock(nlb, upstreamName, true)); + } + + const fullConfig = `# Network Load Balancer: ${nlb.name} +# Protocol: ${nlb.protocol} +# Port: ${nlb.port} +# Algorithm: ${nlb.algorithm} +# Generated at: ${new Date().toISOString()} +# DO NOT EDIT THIS FILE MANUALLY - Managed by Nginx WAF Management Platform + +${upstreamBlock} + +${serverBlocks.join('\n\n')} +`; + + // Write configuration file to streams-available + try { + await fs.mkdir(this.streamsAvailablePath, { recursive: true }); + await fs.mkdir(this.streamsEnabledPath, { recursive: true }); + await fs.writeFile(configPath, fullConfig); + logger.info(`Stream configuration written to streams-available for NLB: ${nlb.name}`); + } catch (error) { + logger.error(`Failed to write stream config for ${nlb.name}:`, error); + throw error; + } + } + + /** + * Generate server block for stream + */ + private generateServerBlock(nlb: NLBWithRelations, upstreamName: string, isUdp: boolean): string { + const protocol = isUdp ? ' udp' : ''; + const lines = []; + + lines.push(`server {`); + lines.push(` listen ${nlb.port}${protocol};`); + lines.push(` proxy_pass ${upstreamName};`); + + if (nlb.proxyTimeout !== 3) { + lines.push(` proxy_timeout ${nlb.proxyTimeout}s;`); + } + + if (nlb.proxyConnectTimeout !== 1) { + lines.push(` proxy_connect_timeout ${nlb.proxyConnectTimeout}s;`); + } + + if (nlb.proxyNextUpstream) { + lines.push(` proxy_next_upstream on;`); + if (nlb.proxyNextUpstreamTimeout > 0) { + lines.push(` proxy_next_upstream_timeout ${nlb.proxyNextUpstreamTimeout}s;`); + } + if (nlb.proxyNextUpstreamTries > 0) { + lines.push(` proxy_next_upstream_tries ${nlb.proxyNextUpstreamTries};`); + } + } + + lines.push(`}`); + + return lines.join('\n'); + } + + /** + * Get algorithm directive for Nginx + */ + private getAlgorithmDirective(algorithm: string): string { + switch (algorithm) { + case 'least_conn': + return 'least_conn;'; + case 'ip_hash': + return 'hash $remote_addr consistent;'; + case 'hash': + return 'hash $remote_addr;'; + default: + return ''; // round_robin is default + } + } + + /** + * Enable stream configuration (create symlink) + * Tương tự ln -s /etc/nginx/streams-available/xxx.conf /etc/nginx/streams-enabled/xxx.conf + */ + private async enableStreamConfig(name: string): Promise { + const availablePath = path.join(this.streamsAvailablePath, `${name}.conf`); + const enabledPath = path.join(this.streamsEnabledPath, `${name}.conf`); + + try { + // Remove existing symlink if exists + try { + await fs.unlink(enabledPath); + } catch (err: any) { + if (err.code !== 'ENOENT') throw err; + } + + // Create symlink + await fs.symlink(availablePath, enabledPath); + logger.info(`Stream configuration enabled for NLB: ${name}`); + } catch (error) { + logger.error(`Failed to enable stream config for ${name}:`, error); + throw error; + } + } + + /** + * Disable stream configuration (remove symlink) + */ + private async disableStreamConfig(name: string): Promise { + const enabledPath = path.join(this.streamsEnabledPath, `${name}.conf`); + + try { + await fs.unlink(enabledPath); + logger.info(`Stream configuration disabled for NLB: ${name}`); + } catch (error: any) { + if (error.code !== 'ENOENT') { + logger.error(`Failed to disable stream config for ${name}:`, error); + throw error; + } + } + } + + /** + * Remove stream configuration file permanently + */ + private async removeStreamConfig(name: string): Promise { + const availablePath = path.join(this.streamsAvailablePath, `${name}.conf`); + + try { + await fs.unlink(availablePath); + logger.info(`Stream configuration removed for NLB: ${name}`); + } catch (error: any) { + if (error.code !== 'ENOENT') { + logger.error(`Failed to remove stream config for ${name}:`, error); + throw error; + } + } + } + + /** + * Ensure stream directories exist + * Tạo các thư mục streams-available và streams-enabled nếu chưa có + */ + private async ensureStreamInclude(): Promise { + try { + // Ensure directories exist + await fs.mkdir(this.streamsAvailablePath, { recursive: true }); + await fs.mkdir(this.streamsEnabledPath, { recursive: true }); + + logger.info('Stream directories ensured'); + } catch (error) { + logger.error('Failed to ensure stream directories:', error); + throw new AppError('Failed to create stream directories', 500); + } + } + + /** + * Test Nginx configuration + */ + private async testNginxConfig(): Promise { + try { + const { stdout, stderr } = await execAsync('nginx -t 2>&1'); + logger.info('Nginx config test output:', stdout); + + if (stderr && !stdout.includes('syntax is ok')) { + throw new Error(`Nginx config test failed: ${stderr}`); + } + } catch (error: any) { + logger.error('Nginx configuration test failed:', error); + throw new AppError(`Nginx configuration test failed: ${error.message}`, 500); + } + } + + /** + * Reload Nginx configuration + */ + private async reloadNginx(): Promise { + try { + // Reload Nginx + await execAsync('nginx -s reload'); + logger.info('Nginx reloaded successfully'); + } catch (error: any) { + logger.error('Failed to reload Nginx:', error); + throw new AppError(`Failed to reload Nginx: ${error.message}`, 500); + } + } + + /** + * Perform health check on all upstreams of an NLB + */ + async performHealthCheck(id: string): Promise { + const nlb = await this.getNLBById(id); + + if (!nlb.healthCheckEnabled) { + return []; + } + + const results: HealthCheckResult[] = []; + + for (const upstream of nlb.upstreams) { + const startTime = Date.now(); + let status: 'up' | 'down' | 'checking' = 'checking'; + let error: string | undefined; + let responseTime: number | undefined; + + try { + // Simple TCP connection check + const net = require('net'); + await new Promise((resolve, reject) => { + const socket = net.createConnection( + { host: upstream.host, port: upstream.port, timeout: nlb.healthCheckTimeout * 1000 }, + () => { + socket.end(); + resolve(); + } + ); + socket.on('error', reject); + socket.on('timeout', () => { + socket.destroy(); + reject(new Error('Connection timeout')); + }); + }); + + status = 'up'; + responseTime = Date.now() - startTime; + } catch (err: any) { + status = 'down'; + error = err.message; + } + + // Update upstream status + await this.repository.updateUpstreamStatus(upstream.id, status, responseTime, error); + + // Create health check record + await this.repository.createHealthCheck( + nlb.id, + upstream.host, + upstream.port, + status, + responseTime, + error + ); + + results.push({ + upstreamHost: upstream.host, + upstreamPort: upstream.port, + status, + responseTime, + error, + }); + } + + return results; + } +} + +export const nlbService = new NLBService(); diff --git a/apps/api/src/domains/nlb/nlb.types.ts b/apps/api/src/domains/nlb/nlb.types.ts new file mode 100644 index 0000000..8e8f075 --- /dev/null +++ b/apps/api/src/domains/nlb/nlb.types.ts @@ -0,0 +1,114 @@ +import { NetworkLoadBalancer, NLBUpstream, NLBHealthCheck } from '@prisma/client'; + +/** + * Network Load Balancer types and interfaces + */ + +// NLB with all relations +export interface NLBWithRelations extends NetworkLoadBalancer { + upstreams: NLBUpstream[]; + healthChecks?: NLBHealthCheck[]; +} + +// Upstream creation data +export interface CreateNLBUpstreamData { + host: string; + port: number; + weight?: number; + maxFails?: number; + failTimeout?: number; + maxConns?: number; + backup?: boolean; + down?: boolean; +} + +// NLB creation input +export interface CreateNLBInput { + name: string; + description?: string; + port: number; // Must be >= 10000 + protocol: 'tcp' | 'udp' | 'tcp_udp'; + algorithm?: 'round_robin' | 'least_conn' | 'ip_hash' | 'hash'; + upstreams: CreateNLBUpstreamData[]; + + // Advanced settings + proxyTimeout?: number; + proxyConnectTimeout?: number; + proxyNextUpstream?: boolean; + proxyNextUpstreamTimeout?: number; + proxyNextUpstreamTries?: number; + + // Health check settings + healthCheckEnabled?: boolean; + healthCheckInterval?: number; + healthCheckTimeout?: number; + healthCheckRises?: number; + healthCheckFalls?: number; +} + +// NLB update input +export interface UpdateNLBInput { + name?: string; + description?: string; + port?: number; + protocol?: 'tcp' | 'udp' | 'tcp_udp'; + algorithm?: 'round_robin' | 'least_conn' | 'ip_hash' | 'hash'; + status?: 'active' | 'inactive' | 'error'; + enabled?: boolean; + upstreams?: CreateNLBUpstreamData[]; + + // Advanced settings + proxyTimeout?: number; + proxyConnectTimeout?: number; + proxyNextUpstream?: boolean; + proxyNextUpstreamTimeout?: number; + proxyNextUpstreamTries?: number; + + // Health check settings + healthCheckEnabled?: boolean; + healthCheckInterval?: number; + healthCheckTimeout?: number; + healthCheckRises?: number; + healthCheckFalls?: number; +} + +// NLB query filters +export interface NLBQueryFilters { + search?: string; + status?: string; + protocol?: string; + enabled?: string; +} + +// NLB query options +export interface NLBQueryOptions { + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + filters?: NLBQueryFilters; +} + +// Nginx stream config generation options +export interface NginxStreamConfigOptions { + nlb: NLBWithRelations; +} + +// Health check result +export interface HealthCheckResult { + upstreamHost: string; + upstreamPort: number; + status: 'up' | 'down' | 'checking'; + responseTime?: number; + error?: string; +} + +// NLB statistics +export interface NLBStats { + totalNLBs: number; + activeNLBs: number; + inactiveNLBs: number; + totalUpstreams: number; + healthyUpstreams: number; + unhealthyUpstreams: number; +} diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 3728bb9..478a37c 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -15,6 +15,7 @@ import dashboardRoutes from '../domains/dashboard/dashboard.routes'; import backupRoutes from '../domains/backup/backup.routes'; import slaveRoutes from '../domains/cluster/cluster.routes'; import nodeSyncRoutes from '../domains/cluster/node-sync.routes'; +import nlbRoutes from '../domains/nlb/nlb.routes'; const router = Router(); @@ -44,5 +45,6 @@ router.use('/backup', backupRoutes); router.use('/slave', slaveRoutes); router.use('/system-config', systemConfigRoutes); router.use('/node-sync', nodeSyncRoutes); +router.use('/nlb', nlbRoutes); export default router; diff --git a/apps/web/src/components/forms/NLBFormDialog.tsx b/apps/web/src/components/forms/NLBFormDialog.tsx new file mode 100644 index 0000000..1e6bef8 --- /dev/null +++ b/apps/web/src/components/forms/NLBFormDialog.tsx @@ -0,0 +1,572 @@ +import { useEffect, useState } from 'react'; +import { useForm, useFieldArray, Controller } from 'react-hook-form'; +import { useCreateNLB, useUpdateNLB } from '@/queries/nlb.query-options'; +import { NetworkLoadBalancer, CreateNLBInput, NLBUpstream } from '@/types'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Card, CardContent } from '@/components/ui/card'; +import { Plus, Trash2, HelpCircle } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; + +interface NLBFormDialogProps { + isOpen: boolean; + onClose: () => void; + nlb?: NetworkLoadBalancer | null; + mode: 'create' | 'edit'; +} + +type FormData = CreateNLBInput; + +export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDialogProps) { + const { toast } = useToast(); + const createMutation = useCreateNLB(); + const updateMutation = useUpdateNLB(); + + const { + register, + handleSubmit, + control, + watch, + setValue, + reset, + formState: { errors }, + } = useForm({ + defaultValues: { + name: '', + description: '', + port: 10000, + protocol: 'tcp', + algorithm: 'round_robin', + upstreams: [{ host: '', port: 80, weight: 1, maxFails: 3, failTimeout: 10, maxConns: 0, backup: false, down: false }], + proxyTimeout: 3, + proxyConnectTimeout: 1, + proxyNextUpstream: true, + proxyNextUpstreamTimeout: 0, + proxyNextUpstreamTries: 0, + healthCheckEnabled: true, + healthCheckInterval: 10, + healthCheckTimeout: 5, + healthCheckRises: 2, + healthCheckFalls: 3, + }, + }); + + const { fields, append, remove } = useFieldArray({ + control, + name: 'upstreams', + }); + + const protocol = watch('protocol'); + + useEffect(() => { + if (isOpen && nlb && mode === 'edit') { + reset({ + name: nlb.name, + description: nlb.description || '', + port: nlb.port, + protocol: nlb.protocol, + algorithm: nlb.algorithm, + upstreams: nlb.upstreams.map(u => ({ + host: u.host, + port: u.port, + weight: u.weight, + maxFails: u.maxFails, + failTimeout: u.failTimeout, + maxConns: u.maxConns, + backup: u.backup, + down: u.down, + })), + proxyTimeout: nlb.proxyTimeout, + proxyConnectTimeout: nlb.proxyConnectTimeout, + proxyNextUpstream: nlb.proxyNextUpstream, + proxyNextUpstreamTimeout: nlb.proxyNextUpstreamTimeout, + proxyNextUpstreamTries: nlb.proxyNextUpstreamTries, + healthCheckEnabled: nlb.healthCheckEnabled, + healthCheckInterval: nlb.healthCheckInterval, + healthCheckTimeout: nlb.healthCheckTimeout, + healthCheckRises: nlb.healthCheckRises, + healthCheckFalls: nlb.healthCheckFalls, + }); + } else if (isOpen && mode === 'create') { + reset({ + name: '', + description: '', + port: 10000, + protocol: 'tcp', + algorithm: 'round_robin', + upstreams: [{ host: '', port: 80, weight: 1, maxFails: 3, failTimeout: 10, maxConns: 0, backup: false, down: false }], + proxyTimeout: 3, + proxyConnectTimeout: 1, + proxyNextUpstream: true, + proxyNextUpstreamTimeout: 0, + proxyNextUpstreamTries: 0, + healthCheckEnabled: true, + healthCheckInterval: 10, + healthCheckTimeout: 5, + healthCheckRises: 2, + healthCheckFalls: 3, + }); + } + }, [isOpen, nlb, mode, reset]); + + const onSubmit = async (data: FormData) => { + try { + // Convert all string numbers to actual numbers + const processedData = { + ...data, + port: Number(data.port), + proxyTimeout: Number(data.proxyTimeout), + proxyConnectTimeout: Number(data.proxyConnectTimeout), + proxyNextUpstream: Boolean(data.proxyNextUpstream), + proxyNextUpstreamTimeout: Number(data.proxyNextUpstreamTimeout), + proxyNextUpstreamTries: Number(data.proxyNextUpstreamTries), + healthCheckEnabled: Boolean(data.healthCheckEnabled), + healthCheckInterval: Number(data.healthCheckInterval), + healthCheckTimeout: Number(data.healthCheckTimeout), + healthCheckRises: Number(data.healthCheckRises), + healthCheckFalls: Number(data.healthCheckFalls), + upstreams: data.upstreams.map(upstream => ({ + ...upstream, + port: Number(upstream.port), + weight: Number(upstream.weight), + maxFails: Number(upstream.maxFails), + failTimeout: Number(upstream.failTimeout), + maxConns: Number(upstream.maxConns), + backup: Boolean(upstream.backup), + down: Boolean(upstream.down), + })), + }; + + if (mode === 'create') { + await createMutation.mutateAsync(processedData); + toast({ + title: 'Success', + description: 'NLB created successfully', + }); + } else if (nlb) { + await updateMutation.mutateAsync({ id: nlb.id, data: processedData }); + toast({ + title: 'Success', + description: 'NLB updated successfully', + }); + } + onClose(); + } catch (error: any) { + const message = error.response?.data?.message; + let description = `Failed to ${mode} NLB`; + + if (message?.includes('already exists')) { + description = 'An NLB with this name already exists'; + } else if (message) { + description = message; + } + + toast({ + title: 'Error', + description, + variant: 'destructive', + }); + } + }; + + const addUpstream = () => { + append({ host: '', port: 80, weight: 1, maxFails: 3, failTimeout: 10, maxConns: 0, backup: false, down: false }); + }; + + return ( + !open && onClose()}> + + + {mode === 'create' ? 'Create' : 'Edit'} Network Load Balancer + + Configure a Layer 4 load balancer for TCP/UDP traffic distribution. + + + +
+ + + Basic + Upstreams + Advanced + + + +
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ +
+ +