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}

+ )} +
+ +
+ +