diff --git a/apps/api/prisma/migrations/20251006084450_add_slave_node_feature/migration.sql b/apps/api/prisma/migrations/20251006084450_add_slave_node_feature/migration.sql new file mode 100644 index 0000000..232cb55 --- /dev/null +++ b/apps/api/prisma/migrations/20251006084450_add_slave_node_feature/migration.sql @@ -0,0 +1,85 @@ +-- CreateEnum +CREATE TYPE "SlaveNodeStatus" AS ENUM ('online', 'offline', 'syncing', 'error'); + +-- CreateEnum +CREATE TYPE "SyncLogStatus" AS ENUM ('success', 'failed', 'partial', 'running'); + +-- CreateEnum +CREATE TYPE "SyncLogType" AS ENUM ('full_sync', 'incremental_sync', 'health_check'); + +-- CreateTable +CREATE TABLE "slave_nodes" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "host" TEXT NOT NULL, + "port" INTEGER NOT NULL DEFAULT 3001, + "apiKey" TEXT NOT NULL, + "status" "SlaveNodeStatus" NOT NULL DEFAULT 'offline', + "lastSeen" TIMESTAMP(3), + "version" TEXT, + "syncEnabled" BOOLEAN NOT NULL DEFAULT true, + "syncInterval" INTEGER NOT NULL DEFAULT 60, + "configHash" TEXT, + "lastSyncAt" TIMESTAMP(3), + "latency" INTEGER, + "cpuUsage" DOUBLE PRECISION, + "memoryUsage" DOUBLE PRECISION, + "diskUsage" DOUBLE PRECISION, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "slave_nodes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sync_logs" ( + "id" TEXT NOT NULL, + "nodeId" TEXT NOT NULL, + "type" "SyncLogType" NOT NULL, + "status" "SyncLogStatus" NOT NULL DEFAULT 'running', + "configHash" TEXT, + "changesCount" INTEGER, + "errorMessage" TEXT, + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completedAt" TIMESTAMP(3), + "duration" INTEGER, + + CONSTRAINT "sync_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "config_versions" ( + "id" TEXT NOT NULL, + "version" SERIAL NOT NULL, + "configHash" TEXT NOT NULL, + "configData" JSONB NOT NULL, + "createdBy" TEXT, + "description" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "config_versions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "slave_nodes_name_key" ON "slave_nodes"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "slave_nodes_apiKey_key" ON "slave_nodes"("apiKey"); + +-- CreateIndex +CREATE INDEX "slave_nodes_status_idx" ON "slave_nodes"("status"); + +-- CreateIndex +CREATE INDEX "slave_nodes_lastSeen_idx" ON "slave_nodes"("lastSeen"); + +-- CreateIndex +CREATE INDEX "sync_logs_nodeId_startedAt_idx" ON "sync_logs"("nodeId", "startedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "config_versions_configHash_key" ON "config_versions"("configHash"); + +-- CreateIndex +CREATE INDEX "config_versions_createdAt_idx" ON "config_versions"("createdAt"); + +-- AddForeignKey +ALTER TABLE "sync_logs" ADD CONSTRAINT "sync_logs_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "slave_nodes"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20251006092848_add_system_config_and_node_mode/migration.sql b/apps/api/prisma/migrations/20251006092848_add_system_config_and_node_mode/migration.sql new file mode 100644 index 0000000..d43c3d3 --- /dev/null +++ b/apps/api/prisma/migrations/20251006092848_add_system_config_and_node_mode/migration.sql @@ -0,0 +1,20 @@ +-- CreateEnum +CREATE TYPE "NodeMode" AS ENUM ('master', 'slave'); + +-- CreateTable +CREATE TABLE "system_configs" ( + "id" TEXT NOT NULL, + "nodeMode" "NodeMode" NOT NULL DEFAULT 'master', + "masterApiEnabled" BOOLEAN NOT NULL DEFAULT true, + "slaveApiEnabled" BOOLEAN NOT NULL DEFAULT false, + "masterHost" TEXT, + "masterPort" INTEGER, + "masterApiKey" TEXT, + "connected" BOOLEAN NOT NULL DEFAULT false, + "lastConnectedAt" TIMESTAMP(3), + "connectionError" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "system_configs_pkey" PRIMARY KEY ("id") +); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index fe145b5..372931c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -31,21 +31,21 @@ enum ActivityType { } model User { - id String @id @default(cuid()) - username String @unique - email String @unique - password String - fullName String - role UserRole @default(viewer) - status UserStatus @default(active) - avatar String? - phone String? - timezone String @default("Asia/Ho_Chi_Minh") - language String @default("en") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - lastLogin DateTime? - + id String @id @default(cuid()) + username String @unique + email String @unique + password String + fullName String + role UserRole @default(viewer) + status UserStatus @default(active) + avatar String? + phone String? + timezone String @default("Asia/Ho_Chi_Minh") + language String @default("en") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLogin DateTime? + // Relations profile UserProfile? twoFactor TwoFactorAuth? @@ -57,15 +57,15 @@ model User { } model UserProfile { - id String @id @default(cuid()) - userId String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + // Additional profile fields can be added here - bio String? - location String? - website String? - + bio String? + location String? + website String? + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -73,34 +73,34 @@ model UserProfile { } model TwoFactorAuth { - id String @id @default(cuid()) - userId String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - enabled Boolean @default(false) - method String @default("totp") // totp, sms - secret String? - backupCodes String[] // Encrypted backup codes - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + enabled Boolean @default(false) + method String @default("totp") // totp, sms + secret String? + backupCodes String[] // Encrypted backup codes + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@map("two_factor_auth") } model ActivityLog { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - action String - type ActivityType - ip String - userAgent String @db.Text - details String? @db.Text - success Boolean @default(true) - - timestamp DateTime @default(now()) + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + action String + type ActivityType + ip String + userAgent String @db.Text + details String? @db.Text + success Boolean @default(true) + + timestamp DateTime @default(now()) @@index([userId, timestamp]) @@index([type, timestamp]) @@ -108,35 +108,35 @@ model ActivityLog { } model RefreshToken { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - token String @unique + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + token String @unique expiresAt DateTime - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) revokedAt DateTime? - + @@index([userId]) @@index([token]) @@map("refresh_tokens") } model UserSession { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - sessionId String @unique - ip String - userAgent String @db.Text - device String? - location String? - - lastActive DateTime @default(now()) - expiresAt DateTime - createdAt DateTime @default(now()) - + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + sessionId String @unique + ip String + userAgent String @db.Text + device String? + location String? + + lastActive DateTime @default(now()) + expiresAt DateTime + createdAt DateTime @default(now()) + @@index([userId]) @@index([sessionId]) @@map("user_sessions") @@ -169,22 +169,22 @@ enum SSLStatus { } model Domain { - id String @id @default(cuid()) - name String @unique - status DomainStatus @default(inactive) - sslEnabled Boolean @default(false) - sslExpiry DateTime? - modsecEnabled Boolean @default(true) - + id String @id @default(cuid()) + name String @unique + status DomainStatus @default(inactive) + sslEnabled Boolean @default(false) + sslExpiry DateTime? + modsecEnabled Boolean @default(true) + // Relations - upstreams Upstream[] - loadBalancer LoadBalancerConfig? - sslCertificate SSLCertificate? - modsecCRSRules ModSecCRSRule[] - modsecRules ModSecRule[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + upstreams Upstream[] + loadBalancer LoadBalancerConfig? + sslCertificate SSLCertificate? + modsecCRSRules ModSecCRSRule[] + modsecRules ModSecRule[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([name]) @@index([status]) @@ -192,62 +192,62 @@ model Domain { } model Upstream { - id String @id @default(cuid()) - domainId String - domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade) - + id String @id @default(cuid()) + domainId String + domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade) + host String port Int - protocol String @default("http") // http or https - sslVerify Boolean @default(true) // proxy_ssl_verify on/off - weight Int @default(1) - maxFails Int @default(3) - failTimeout Int @default(10) // seconds - status UpstreamStatus @default(checking) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + protocol String @default("http") // http or https + sslVerify Boolean @default(true) // proxy_ssl_verify on/off + weight Int @default(1) + maxFails Int @default(3) + failTimeout Int @default(10) // seconds + status UpstreamStatus @default(checking) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([domainId]) @@map("upstreams") } model LoadBalancerConfig { - id String @id @default(cuid()) - domainId String @unique - domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade) - - algorithm LoadBalancerAlgorithm @default(round_robin) - healthCheckEnabled Boolean @default(true) - healthCheckInterval Int @default(30) // seconds - healthCheckTimeout Int @default(5) // seconds - healthCheckPath String @default("/") - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + domainId String @unique + domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade) + + algorithm LoadBalancerAlgorithm @default(round_robin) + healthCheckEnabled Boolean @default(true) + healthCheckInterval Int @default(30) // seconds + healthCheckTimeout Int @default(5) // seconds + healthCheckPath String @default("/") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@map("load_balancer_configs") } model SSLCertificate { - id String @id @default(cuid()) - domainId String @unique - domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade) - + id String @id @default(cuid()) + domainId String @unique + domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade) + commonName String - sans String[] // Subject Alternative Names + sans String[] // Subject Alternative Names issuer String - certificate String @db.Text // PEM format - privateKey String @db.Text // PEM format - chain String? @db.Text // PEM format - - validFrom DateTime - validTo DateTime - autoRenew Boolean @default(true) - status SSLStatus @default(valid) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + certificate String @db.Text // PEM format + privateKey String @db.Text // PEM format + chain String? @db.Text // PEM format + + validFrom DateTime + validTo DateTime + autoRenew Boolean @default(true) + status SSLStatus @default(valid) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([domainId]) @@index([validTo]) @@ -258,19 +258,19 @@ model SSLCertificate { // Only stores metadata and enabled status // Actual rules come from CRS files model ModSecCRSRule { - id String @id @default(cuid()) - domainId String? - domain Domain? @relation(fields: [domainId], references: [id], onDelete: Cascade) - - ruleFile String // e.g., "REQUEST-942-APPLICATION-ATTACK-SQLI.conf" + id String @id @default(cuid()) + domainId String? + domain Domain? @relation(fields: [domainId], references: [id], onDelete: Cascade) + + ruleFile String // e.g., "REQUEST-942-APPLICATION-ATTACK-SQLI.conf" name String category String - description String? @db.Text - enabled Boolean @default(true) - paranoia Int @default(1) // Paranoia level 1-4 - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + description String? @db.Text + enabled Boolean @default(true) + paranoia Int @default(1) // Paranoia level 1-4 + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([ruleFile, domainId]) @@index([domainId]) @@ -281,18 +281,18 @@ model ModSecCRSRule { // ModSecurity Custom Rules (kept from original, renamed table) // Stores full rule content for user-defined rules model ModSecRule { - id String @id @default(cuid()) - domainId String? - domain Domain? @relation(fields: [domainId], references: [id], onDelete: Cascade) - + id String @id @default(cuid()) + domainId String? + domain Domain? @relation(fields: [domainId], references: [id], onDelete: Cascade) + name String category String - ruleContent String @db.Text - enabled Boolean @default(true) - description String? @db.Text - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + ruleContent String @db.Text + enabled Boolean @default(true) + description String? @db.Text + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([domainId]) @@index([category]) @@ -300,30 +300,30 @@ model ModSecRule { } model NginxConfig { - id String @id @default(cuid()) - configType String // main, site, upstream, etc. - name String - content String @db.Text - enabled Boolean @default(true) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + configType String // main, site, upstream, etc. + name String + content String @db.Text + enabled Boolean @default(true) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([configType]) @@map("nginx_configs") } model InstallationStatus { - id String @id @default(cuid()) - component String @unique // nginx, modsecurity, etc. - status String // pending, running, completed, failed - step String? - message String? @db.Text - progress Int @default(0) // 0-100 - - startedAt DateTime @default(now()) + id String @id @default(cuid()) + component String @unique // nginx, modsecurity, etc. + status String // pending, running, completed, failed + step String? + message String? @db.Text + progress Int @default(0) // 0-100 + + startedAt DateTime @default(now()) completedAt DateTime? - updatedAt DateTime @updatedAt + updatedAt DateTime @updatedAt @@map("installation_status") } @@ -340,46 +340,46 @@ enum AlertSeverity { } model NotificationChannel { - id String @id @default(cuid()) - name String - type NotificationChannelType - enabled Boolean @default(true) - config Json // { email?, chatId?, botToken? } - + id String @id @default(cuid()) + name String + type NotificationChannelType + enabled Boolean @default(true) + config Json // { email?, chatId?, botToken? } + alertRules AlertRuleChannel[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@map("notification_channels") } model AlertRule { - id String @id @default(cuid()) - name String - condition String // cpu > threshold, upstream_status == down, etc. + id String @id @default(cuid()) + name String + condition String // cpu > threshold, upstream_status == down, etc. threshold Int severity AlertSeverity - enabled Boolean @default(true) - checkInterval Int @default(60) // Check interval in seconds (default: 60s) - - channels AlertRuleChannel[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + enabled Boolean @default(true) + checkInterval Int @default(60) // Check interval in seconds (default: 60s) + + channels AlertRuleChannel[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@map("alert_rules") } model AlertRuleChannel { - id String @id @default(cuid()) + id String @id @default(cuid()) ruleId String channelId String - - rule AlertRule @relation(fields: [ruleId], references: [id], onDelete: Cascade) - channel NotificationChannel @relation(fields: [channelId], references: [id], onDelete: Cascade) - - createdAt DateTime @default(now()) + + rule AlertRule @relation(fields: [ruleId], references: [id], onDelete: Cascade) + channel NotificationChannel @relation(fields: [channelId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) @@unique([ruleId, channelId]) @@index([ruleId]) @@ -388,16 +388,16 @@ model AlertRuleChannel { } model AlertHistory { - id String @id @default(cuid()) - severity AlertSeverity - message String @db.Text - source String - acknowledged Boolean @default(false) + id String @id @default(cuid()) + severity AlertSeverity + message String @db.Text + source String + acknowledged Boolean @default(false) acknowledgedBy String? acknowledgedAt DateTime? - - timestamp DateTime @default(now()) - createdAt DateTime @default(now()) + + timestamp DateTime @default(now()) + createdAt DateTime @default(now()) @@index([severity]) @@index([acknowledged]) @@ -440,27 +440,190 @@ model AclRule { conditionValue String action AclAction enabled Boolean @default(true) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@map("acl_rules") } model PerformanceMetric { - id String @id @default(cuid()) - domain String - timestamp DateTime @default(now()) - responseTime Float - throughput Float - errorRate Float - requestCount Int - - createdAt DateTime @default(now()) + id String @id @default(cuid()) + domain String + timestamp DateTime @default(now()) + responseTime Float + throughput Float + errorRate Float + requestCount Int + + createdAt DateTime @default(now()) - @@map("performance_metrics") @@index([domain, timestamp]) @@index([timestamp]) + @@map("performance_metrics") +} + +enum BackupStatus { + success + failed + running + pending +} + +model BackupSchedule { + id String @id @default(cuid()) + name String + schedule String // Cron expression + enabled Boolean @default(true) + lastRun DateTime? + nextRun DateTime? + status BackupStatus @default(pending) + + backups BackupFile[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("backup_schedules") +} + +model BackupFile { + id String @id @default(cuid()) + scheduleId String? + schedule BackupSchedule? @relation(fields: [scheduleId], references: [id], onDelete: SetNull) + + filename String + filepath String + size BigInt // Size in bytes + status BackupStatus @default(success) + type String @default("full") // full, incremental, manual + + metadata Json? // Additional metadata (domains count, rules count, etc.) + + createdAt DateTime @default(now()) + + @@index([scheduleId]) + @@index([createdAt]) + @@map("backup_files") +} + +enum SlaveNodeStatus { + online + offline + syncing + error +} + +enum SyncLogStatus { + success + failed + partial + running +} + +enum SyncLogType { + full_sync + incremental_sync + health_check +} + +enum NodeMode { + master + slave +} + +model SlaveNode { + id String @id @default(cuid()) + name String @unique + host String + port Int @default(3001) + apiKey String @unique // Authentication token for slave + + status SlaveNodeStatus @default(offline) + lastSeen DateTime? + version String? + + // Sync configuration + syncEnabled Boolean @default(true) + syncInterval Int @default(60) // seconds + configHash String? // SHA256 hash of current config + lastSyncAt DateTime? + + // Metrics + latency Int? // milliseconds + cpuUsage Float? + memoryUsage Float? + diskUsage Float? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + syncLogs SyncLog[] + + @@index([status]) + @@index([lastSeen]) + @@map("slave_nodes") +} + +model SystemConfig { + id String @id @default(cuid()) + nodeMode NodeMode @default(master) // master or slave + + // Master mode settings + masterApiEnabled Boolean @default(true) + + // Slave mode settings + slaveApiEnabled Boolean @default(false) + masterHost String? // IP of master node + masterPort Int? // Port of master node + masterApiKey String? // API key to connect to master + syncInterval Int @default(60) // Sync interval in seconds (for slave mode) + lastSyncHash String? // Hash of last synced config (for change detection) + + // Connection status (for slave mode) + connected Boolean @default(false) + lastConnectedAt DateTime? + connectionError String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("system_configs") +} + +model SyncLog { + id String @id @default(cuid()) + nodeId String + node SlaveNode @relation(fields: [nodeId], references: [id], onDelete: Cascade) + + type SyncLogType + status SyncLogStatus @default(running) + + configHash String? + changesCount Int? + errorMessage String? @db.Text + + startedAt DateTime @default(now()) + completedAt DateTime? + duration Int? // milliseconds + + @@index([nodeId, startedAt]) + @@map("sync_logs") +} + +model ConfigVersion { + id String @id @default(cuid()) + version Int @default(autoincrement()) + configHash String @unique + configData Json // Serialized config + + createdBy String? + description String? + + createdAt DateTime @default(now()) + + @@index([createdAt]) + @@map("config_versions") } enum BackupStatus { diff --git a/apps/api/src/controllers/node-sync.controller.ts b/apps/api/src/controllers/node-sync.controller.ts new file mode 100644 index 0000000..e837509 --- /dev/null +++ b/apps/api/src/controllers/node-sync.controller.ts @@ -0,0 +1,474 @@ +import { Response } from 'express'; +import { AuthRequest } from '../middleware/auth'; +import { SlaveRequest } from '../middleware/slaveAuth'; +import logger from '../utils/logger'; +import prisma from '../config/database'; +import crypto from 'crypto'; + +/** + * Export configuration for slave sync (NO timestamps to keep hash stable) + * This is DIFFERENT from backup export - optimized for sync with hash comparison + */ +export const exportForSync = async (req: SlaveRequest, res: Response): Promise => { + try { + logger.info('[NODE-SYNC] Exporting config for slave sync', { + slaveNode: req.slaveNode?.name + }); + + // Collect data WITHOUT timestamps/IDs that change + const syncData = await collectSyncData(); + + // Calculate hash for comparison + const dataString = JSON.stringify(syncData); + const hash = crypto.createHash('sha256').update(dataString).digest('hex'); + + // Update slave node's config hash (master knows what config slave should have) + if (req.slaveNode?.id) { + await prisma.slaveNode.update({ + where: { id: req.slaveNode.id }, + data: { configHash: hash } + }).catch((err) => { + logger.warn('[NODE-SYNC] Failed to update configHash', { + nodeId: req.slaveNode?.id, + error: err.message + }); + }); + } + + res.json({ + success: true, + data: { + hash, + config: syncData + } + }); + } catch (error) { + logger.error('[NODE-SYNC] Export for sync error:', error); + res.status(500).json({ + success: false, + message: 'Export for sync failed' + }); + } +}; + +/** + * Import configuration from master (slave imports synced config) + */ +export const importFromMaster = async (req: AuthRequest, res: Response): Promise => { + try { + const { hash, config } = req.body; + + if (!hash || !config) { + return res.status(400).json({ + success: false, + message: 'Invalid sync data: hash and config required' + }); + } + + // Get current config hash + const currentConfig = await collectSyncData(); + const currentHash = crypto.createHash('sha256').update(JSON.stringify(currentConfig)).digest('hex'); + + logger.info('[NODE-SYNC] Import check', { + currentHash, + newHash: hash, + needsImport: currentHash !== hash + }); + + // If hash is same, skip import + if (currentHash === hash) { + return res.json({ + success: true, + message: 'Configuration already up to date (hash match)', + data: { + imported: false, + hash: currentHash, + changes: 0 + } + }); + } + + // Hash different → Import config + logger.info('[NODE-SYNC] Hash mismatch, importing config...'); + const results = await importSyncConfig(config); + + // Update SystemConfig with new hash + const systemConfig = await prisma.systemConfig.findFirst(); + if (systemConfig) { + await prisma.systemConfig.update({ + where: { id: systemConfig.id }, + data: { + lastConnectedAt: new Date() + } + }); + } + + logger.info('[NODE-SYNC] Import completed', results); + + res.json({ + success: true, + message: 'Configuration imported successfully', + data: { + imported: true, + hash, + changes: results.totalChanges, + details: results + } + }); + } catch (error: any) { + logger.error('[NODE-SYNC] Import error:', error); + res.status(500).json({ + success: false, + message: error.message || 'Import failed' + }); + } +}; + +/** + * Get current config hash of slave node + */ +export const getCurrentConfigHash = async (req: AuthRequest, res: Response) => { + try { + const currentConfig = await collectSyncData(); + const configString = JSON.stringify(currentConfig); + const hash = crypto.createHash('sha256').update(configString).digest('hex'); + + logger.info('[NODE-SYNC] Current config hash calculated', { hash }); + + res.json({ + success: true, + data: { hash } + }); + } catch (error: any) { + logger.error('[NODE-SYNC] Get current hash error:', error); + res.status(500).json({ + success: false, + message: error.message || 'Failed to calculate current config hash' + }); + } +}; + +/** + * Collect sync data (NO timestamps for stable hash) + */ +async function collectSyncData() { + const domains = await prisma.domain.findMany({ + include: { + upstreams: true, + loadBalancer: true + } + }); + + const ssl = await prisma.sSLCertificate.findMany({ + include: { + domain: true + } + }); + + const modsecCRS = await prisma.modSecCRSRule.findMany(); + const modsecCustom = await prisma.modSecRule.findMany(); + const acl = await prisma.aclRule.findMany(); + const users = await prisma.user.findMany(); + + return { + // Domains (NO timestamps, NO IDs) + domains: domains.map(d => ({ + name: d.name, + status: d.status, + sslEnabled: d.sslEnabled, + modsecEnabled: d.modsecEnabled, + upstreams: d.upstreams.map(u => ({ + host: u.host, + port: u.port, + protocol: u.protocol, + sslVerify: u.sslVerify, + weight: u.weight, + maxFails: u.maxFails, + failTimeout: u.failTimeout + })), + loadBalancer: d.loadBalancer ? { + algorithm: d.loadBalancer.algorithm, + healthCheckEnabled: d.loadBalancer.healthCheckEnabled, + healthCheckPath: d.loadBalancer.healthCheckPath, + healthCheckInterval: d.loadBalancer.healthCheckInterval, + healthCheckTimeout: d.loadBalancer.healthCheckTimeout + } : null + })), + + // SSL Certificates (NO timestamps, NO IDs) + sslCertificates: ssl.map(s => ({ + domainName: s.domain?.name, + commonName: s.commonName, + sans: s.sans, + issuer: s.issuer, + certificate: s.certificate, + privateKey: s.privateKey, + chain: s.chain, + autoRenew: s.autoRenew, + validFrom: s.validFrom.toISOString(), + validTo: s.validTo.toISOString() + })), + + // ModSecurity CRS Rules (NO timestamps, NO IDs) + modsecCRSRules: modsecCRS.map(r => ({ + ruleFile: r.ruleFile, + name: r.name, + category: r.category, + description: r.description, + enabled: r.enabled, + paranoia: r.paranoia + })), + + // ModSecurity Custom Rules (NO timestamps, NO IDs) + modsecCustomRules: modsecCustom.map(r => ({ + name: r.name, + category: r.category, + ruleContent: r.ruleContent, + description: r.description, + enabled: r.enabled + })), + + // ACL (NO timestamps, NO IDs) + aclRules: acl.map(a => ({ + name: a.name, + type: a.type, + conditionField: a.conditionField, + conditionOperator: a.conditionOperator, + conditionValue: a.conditionValue, + action: a.action, + enabled: a.enabled + })), + + // Users (NO timestamps, NO IDs, keep password hashes) + users: users.map(u => ({ + email: u.email, + username: u.username, + fullName: u.fullName, + password: u.password, // Already hashed + role: u.role + })) + }; +} + +/** + * Import sync config into database + */ +async function importSyncConfig(config: any) { + const results = { + domains: 0, + upstreams: 0, + loadBalancers: 0, + ssl: 0, + modsecCRS: 0, + modsecCustom: 0, + acl: 0, + users: 0, + totalChanges: 0 + }; + + try { + // 1. Import Domains + Upstreams + Load Balancers + if (config.domains && Array.isArray(config.domains)) { + for (const domainData of config.domains) { + try { + const domain = await prisma.domain.upsert({ + where: { name: domainData.name }, + update: { + status: domainData.status, + sslEnabled: domainData.sslEnabled, + modsecEnabled: domainData.modsecEnabled + }, + create: { + name: domainData.name, + status: domainData.status, + sslEnabled: domainData.sslEnabled, + modsecEnabled: domainData.modsecEnabled + } + }); + results.domains++; + + // Import upstreams + if (domainData.upstreams && Array.isArray(domainData.upstreams)) { + await prisma.upstream.deleteMany({ where: { domainId: domain.id } }); + + for (const upstream of domainData.upstreams) { + await prisma.upstream.create({ + data: { + domainId: domain.id, + host: upstream.host, + port: upstream.port, + protocol: upstream.protocol || 'http', + sslVerify: upstream.sslVerify !== false, + weight: upstream.weight || 1, + maxFails: upstream.maxFails || 3, + failTimeout: upstream.failTimeout || 10 + } + }); + results.upstreams++; + } + } + + // Import load balancer + if (domainData.loadBalancer) { + await prisma.loadBalancerConfig.upsert({ + where: { domainId: domain.id }, + update: { + algorithm: domainData.loadBalancer.algorithm, + healthCheckEnabled: domainData.loadBalancer.healthCheckEnabled, + healthCheckPath: domainData.loadBalancer.healthCheckPath, + healthCheckInterval: domainData.loadBalancer.healthCheckInterval, + healthCheckTimeout: domainData.loadBalancer.healthCheckTimeout + }, + create: { + domainId: domain.id, + algorithm: domainData.loadBalancer.algorithm, + healthCheckEnabled: domainData.loadBalancer.healthCheckEnabled, + healthCheckPath: domainData.loadBalancer.healthCheckPath, + healthCheckInterval: domainData.loadBalancer.healthCheckInterval, + healthCheckTimeout: domainData.loadBalancer.healthCheckTimeout + } + }); + results.loadBalancers++; + } + } catch (err: any) { + logger.error(`[NODE-SYNC] Domain import error (${domainData.name}):`, err.message); + } + } + } + + // 2. Import SSL Certificates + if (config.sslCertificates && Array.isArray(config.sslCertificates)) { + for (const sslData of config.sslCertificates) { + try { + const domain = await prisma.domain.findUnique({ + where: { name: sslData.domainName } + }); + + if (!domain) continue; + + await prisma.sSLCertificate.upsert({ + where: { domainId: domain.id }, + update: { + commonName: sslData.commonName, + sans: sslData.sans || [], + issuer: sslData.issuer, + certificate: sslData.certificate, + privateKey: sslData.privateKey, + chain: sslData.chain, + validFrom: sslData.validFrom ? new Date(sslData.validFrom) : new Date(), + validTo: sslData.validTo ? new Date(sslData.validTo) : new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + autoRenew: sslData.autoRenew || false + }, + create: { + domainId: domain.id, + commonName: sslData.commonName, + sans: sslData.sans || [], + issuer: sslData.issuer, + certificate: sslData.certificate, + privateKey: sslData.privateKey, + chain: sslData.chain, + validFrom: sslData.validFrom ? new Date(sslData.validFrom) : new Date(), + validTo: sslData.validTo ? new Date(sslData.validTo) : new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + autoRenew: sslData.autoRenew || false + } + }); + results.ssl++; + } catch (err: any) { + logger.error(`[NODE-SYNC] SSL import error:`, err.message); + } + } + } + + // 3. Import ModSecurity CRS Rules + if (config.modsecCRSRules && Array.isArray(config.modsecCRSRules)) { + await prisma.modSecCRSRule.deleteMany({}); + + for (const rule of config.modsecCRSRules) { + await prisma.modSecCRSRule.create({ + data: { + ruleFile: rule.ruleFile, + name: rule.name, + category: rule.category, + description: rule.description || '', + enabled: rule.enabled, + paranoia: rule.paranoia || 1 + } + }); + results.modsecCRS++; + } + } + + // 4. Import ModSecurity Custom Rules + if (config.modsecCustomRules && Array.isArray(config.modsecCustomRules)) { + await prisma.modSecRule.deleteMany({}); + + for (const rule of config.modsecCustomRules) { + await prisma.modSecRule.create({ + data: { + name: rule.name, + category: rule.category, + ruleContent: rule.ruleContent, + enabled: rule.enabled, + description: rule.description + } + }); + results.modsecCustom++; + } + } + + // 5. Import ACL Rules + if (config.aclRules && Array.isArray(config.aclRules)) { + await prisma.aclRule.deleteMany({}); + + for (const rule of config.aclRules) { + await prisma.aclRule.create({ + data: { + name: rule.name, + type: rule.type, + conditionField: rule.conditionField, + conditionOperator: rule.conditionOperator, + conditionValue: rule.conditionValue, + action: rule.action, + enabled: rule.enabled + } + }); + results.acl++; + } + } + + // 6. Import Users + if (config.users && Array.isArray(config.users)) { + for (const userData of config.users) { + try { + await prisma.user.upsert({ + where: { email: userData.email }, + update: { + username: userData.username, + fullName: userData.fullName, + role: userData.role + // Don't update password for security + }, + create: { + email: userData.email, + username: userData.username, + fullName: userData.fullName, + password: userData.password, // Already hashed + role: userData.role + } + }); + results.users++; + } catch (err: any) { + logger.error(`[NODE-SYNC] User import error (${userData.email}):`, err.message); + } + } + } + + results.totalChanges = results.domains + results.ssl + results.modsecCRS + + results.modsecCustom + results.acl + results.users; + + return results; + } catch (error) { + logger.error('[NODE-SYNC] Import config error:', error); + throw error; + } +} diff --git a/apps/api/src/controllers/slave.controller.ts b/apps/api/src/controllers/slave.controller.ts new file mode 100644 index 0000000..bd39b77 --- /dev/null +++ b/apps/api/src/controllers/slave.controller.ts @@ -0,0 +1,210 @@ +import { Response } from 'express'; +import { AuthRequest } from '../middleware/auth'; +import { SlaveRequest } from '../middleware/slaveAuth'; +import prisma from '../config/database'; +import logger from '../utils/logger'; +import crypto from 'crypto'; + +/** + * Generate random API key for slave authentication + */ +function generateApiKey(): string { + return crypto.randomBytes(32).toString('hex'); +} + +/** + * Register new slave node + */ +export const registerSlaveNode = async (req: AuthRequest, res: Response): Promise => { + try { + const { name, host, port = 3001, syncInterval = 60 } = req.body; + + // Check if name already exists + const existing = await prisma.slaveNode.findUnique({ + where: { name } + }); + + if (existing) { + res.status(400).json({ + success: false, + message: 'Slave node with this name already exists' + }); + return; + } + + // Generate API key for slave authentication + const apiKey = generateApiKey(); + + const node = await prisma.slaveNode.create({ + data: { + name, + host, + port, + syncInterval, + apiKey, + syncEnabled: true, + status: 'offline' + } + }); + + logger.info(`Slave node registered: ${name}`, { + userId: req.user?.userId, + host, + port + }); + + res.status(201).json({ + success: true, + message: 'Slave node registered successfully', + data: { + id: node.id, + name: node.name, + host: node.host, + port: node.port, + apiKey: node.apiKey, // Return API key ONLY on creation + status: node.status + } + }); + } catch (error: any) { + logger.error('Register slave node error:', error); + res.status(500).json({ + success: false, + message: 'Failed to register slave node' + }); + } +}; + +/** + * Get all slave nodes + */ +export const getSlaveNodes = async (req: AuthRequest, res: Response): Promise => { + try { + const nodes = await prisma.slaveNode.findMany({ + orderBy: { + createdAt: 'desc' + }, + select: { + id: true, + name: true, + host: true, + port: true, + status: true, + syncEnabled: true, + syncInterval: true, + lastSeen: true, + configHash: true, + createdAt: true, + updatedAt: true + // DO NOT return apiKey + } + }); + + res.json({ + success: true, + data: nodes + }); + } catch (error) { + logger.error('Get slave nodes error:', error); + res.status(500).json({ + success: false, + message: 'Failed to get slave nodes' + }); + } +}; + +/** + * Get single slave node + */ +export const getSlaveNode = async (req: AuthRequest, res: Response): Promise => { + try { + const { id } = req.params; + + const node = await prisma.slaveNode.findUnique({ + where: { id }, + select: { + id: true, + name: true, + host: true, + port: true, + status: true, + syncEnabled: true, + syncInterval: true, + lastSeen: true, + configHash: true, + createdAt: true, + updatedAt: true + // DO NOT return apiKey + } + }); + + if (!node) { + res.status(404).json({ + success: false, + message: 'Slave node not found' + }); + return; + } + + res.json({ + success: true, + data: node + }); + } catch (error) { + logger.error('Get slave node error:', error); + res.status(500).json({ + success: false, + message: 'Failed to get slave node' + }); + } +}; + +/** + * Delete slave node + */ +export const deleteSlaveNode = async (req: AuthRequest, res: Response): Promise => { + try { + const { id } = req.params; + + await prisma.slaveNode.delete({ + where: { id } + }); + + logger.info(`Slave node deleted: ${id}`, { + userId: req.user?.userId + }); + + res.json({ + success: true, + message: 'Slave node deleted successfully' + }); + } catch (error) { + logger.error('Delete slave node error:', error); + res.status(500).json({ + success: false, + message: 'Failed to delete slave node' + }); + } +}; + +/** + * Health check endpoint (called by master to verify slave is alive) + */ +export const healthCheck = async (req: SlaveRequest, res: Response): Promise => { + try { + res.json({ + success: true, + message: 'Slave node is healthy', + data: { + timestamp: new Date().toISOString(), + nodeId: req.slaveNode?.id, + nodeName: req.slaveNode?.name + } + }); + } catch (error) { + logger.error('Health check error:', error); + res.status(500).json({ + success: false, + message: 'Health check failed' + }); + } +}; diff --git a/apps/api/src/controllers/system-config.controller.ts b/apps/api/src/controllers/system-config.controller.ts new file mode 100644 index 0000000..f546db2 --- /dev/null +++ b/apps/api/src/controllers/system-config.controller.ts @@ -0,0 +1,506 @@ +import { Response } from 'express'; +import { AuthRequest } from '../middleware/auth'; +import prisma from '../config/database'; +import logger from '../utils/logger'; +import axios from 'axios'; + +/** + * Get system configuration (node mode, }); + + logger.info('Disconnected from master node', { + userId: req.user?.userId + });er/slave settings) + */ +export const getSystemConfig = async (req: AuthRequest, res: Response) => { + try { + let config = await prisma.systemConfig.findFirst(); + + // Create default config if not exists + if (!config) { + config = await prisma.systemConfig.create({ + data: { + nodeMode: 'master', + masterApiEnabled: true, + slaveApiEnabled: false + } + }); + } + + res.json({ + success: true, + data: config + }); + } catch (error) { + logger.error('Get system config error:', error); + res.status(500).json({ + success: false, + message: 'Failed to get system configuration' + }); + } +}; + +/** + * Update node mode (master or slave) + */ +export const updateNodeMode = async (req: AuthRequest, res: Response) => { + try { + const { nodeMode } = req.body; + + if (!['master', 'slave'].includes(nodeMode)) { + return res.status(400).json({ + success: false, + message: 'Invalid node mode. Must be "master" or "slave"' + }); + } + + let config = await prisma.systemConfig.findFirst(); + + if (!config) { + config = await prisma.systemConfig.create({ + data: { + nodeMode: nodeMode as any, + masterApiEnabled: nodeMode === 'master', + slaveApiEnabled: nodeMode === 'slave' + } + }); + } else { + // Build update data + const updateData: any = { + nodeMode: nodeMode as any, + masterApiEnabled: nodeMode === 'master', + slaveApiEnabled: nodeMode === 'slave' + }; + + // Reset slave connection if switching to master + if (nodeMode === 'master') { + updateData.masterHost = null; + updateData.masterPort = null; + updateData.masterApiKey = null; + updateData.connected = false; + updateData.connectionError = null; + updateData.lastConnectedAt = null; + } + + config = await prisma.systemConfig.update({ + where: { id: config.id }, + data: updateData + }); + } + + logger.info(`Node mode changed to: ${nodeMode}`, { + userId: req.user?.userId, + configId: config.id + }); + + res.json({ + success: true, + data: config, + message: `Node mode changed to ${nodeMode}` + }); + } catch (error) { + logger.error('Update node mode error:', error); + res.status(500).json({ + success: false, + message: 'Failed to update node mode' + }); + } +}; + +/** + * Connect to master node (for slave mode) + */ +export const connectToMaster = async (req: AuthRequest, res: Response) => { + try { + const { masterHost, masterPort, masterApiKey } = req.body; + + if (!masterHost || !masterPort || !masterApiKey) { + return res.status(400).json({ + success: false, + message: 'Master host, port, and API key are required' + }); + } + + // Get current config + let config = await prisma.systemConfig.findFirst(); + + if (!config) { + return res.status(400).json({ + success: false, + message: 'System config not found. Please set node mode first.' + }); + } + + if (config.nodeMode !== 'slave') { + return res.status(400).json({ + success: false, + message: 'Cannot connect to master. Node mode must be "slave".' + }); + } + + // Test connection to master + try { + logger.info('Testing connection to master...', { masterHost, masterPort }); + + const response = await axios.get( + `http://${masterHost}:${masterPort}/api/slave/health`, + { + headers: { + 'X-API-Key': masterApiKey + }, + timeout: 10000 + } + ); + + if (!response.data.success) { + throw new Error('Master health check failed'); + } + + // Connection successful, update config + config = await prisma.systemConfig.update({ + where: { id: config.id }, + data: { + masterHost, + masterPort: parseInt(masterPort.toString()), + masterApiKey, + connected: true, + lastConnectedAt: new Date(), + connectionError: null + } + }); + + logger.info('Successfully connected to master', { + userId: req.user?.userId, + masterHost, + masterPort + }); + + res.json({ + success: true, + data: config, + message: 'Successfully connected to master node' + }); + + } catch (connectionError: any) { + // Connection failed, update config with error + const errorMessage = connectionError.response?.data?.message || + connectionError.message || + 'Failed to connect to master'; + + config = await prisma.systemConfig.update({ + where: { id: config.id }, + data: { + masterHost, + masterPort: parseInt(masterPort.toString()), + masterApiKey, + connected: false, + connectionError: errorMessage + } + }); + + logger.error('Failed to connect to master:', { + error: errorMessage, + masterHost, + masterPort + }); + + return res.status(400).json({ + success: false, + message: errorMessage, + data: config + }); + } + + } catch (error: any) { + logger.error('Connect to master error:', error); + res.status(500).json({ + success: false, + message: error.message || 'Failed to connect to master' + }); + } +}; + +/** + * Disconnect from master node (for slave mode) + */ +export const disconnectFromMaster = async (req: AuthRequest, res: Response) => { + try { + let config = await prisma.systemConfig.findFirst(); + + if (!config) { + return res.status(400).json({ + success: false, + message: 'System config not found' + }); + } + + config = await prisma.systemConfig.update({ + where: { id: config.id }, + data: { + masterHost: null, + masterPort: null, + masterApiKey: null, + connected: false, + lastConnectedAt: null, + connectionError: null + } + }); + + logger.info('Disconnected from master', { + userId: req.user?.userId + }); + + res.json({ + success: true, + data: config, + message: 'Disconnected from master node' + }); + + } catch (error) { + logger.error('Disconnect from master error:', error); + res.status(500).json({ + success: false, + message: 'Failed to disconnect from master' + }); + } +}; + +/** + * Test connection to master (for slave mode) + */ +export const testMasterConnection = async (req: AuthRequest, res: Response) => { + try { + const config = await prisma.systemConfig.findFirst(); + + if (!config) { + return res.status(400).json({ + success: false, + message: 'System config not found' + }); + } + + if (!config.masterHost || !config.masterPort || !config.masterApiKey) { + return res.status(400).json({ + success: false, + message: 'Master connection not configured' + }); + } + + // Test connection + const startTime = Date.now(); + const response = await axios.get( + `http://${config.masterHost}:${config.masterPort}/api/slave/health`, + { + headers: { + 'X-API-Key': config.masterApiKey + }, + timeout: 10000 + } + ); + const latency = Date.now() - startTime; + + // Update config + await prisma.systemConfig.update({ + where: { id: config.id }, + data: { + connected: true, + lastConnectedAt: new Date(), + connectionError: null + } + }); + + res.json({ + success: true, + message: 'Connection to master successful', + data: { + latency, + masterVersion: response.data.version, + masterStatus: response.data.status + } + }); + + } catch (error: any) { + logger.error('Test master connection error:', error); + + // Update config with error + const config = await prisma.systemConfig.findFirst(); + if (config) { + await prisma.systemConfig.update({ + where: { id: config.id }, + data: { + connected: false, + connectionError: error.message + } + }); + } + + res.status(400).json({ + success: false, + message: error.response?.data?.message || error.message || 'Connection test failed' + }); + } +}; + +/** + * Sync configuration from master (slave pulls all config) + * NEW APPROACH: Download config → Calculate hash → Compare → Import only if changed + */ +export const syncWithMaster = async (req: AuthRequest, res: Response) => { + try { + logger.info('========== SYNC WITH MASTER CALLED =========='); + + const config = await prisma.systemConfig.findFirst(); + + if (!config) { + return res.status(400).json({ + success: false, + message: 'System config not found' + }); + } + + if (config.nodeMode !== 'slave') { + return res.status(400).json({ + success: false, + message: 'Cannot sync. Node mode must be "slave".' + }); + } + + if (!config.connected || !config.masterHost || !config.masterApiKey) { + return res.status(400).json({ + success: false, + message: 'Not connected to master. Please connect first.' + }); + } + + logger.info('Starting sync from master...', { + masterHost: config.masterHost, + masterPort: config.masterPort + }); + + // Download config from master using new node-sync API + const masterUrl = `http://${config.masterHost}:${config.masterPort || 3001}/api/node-sync/export`; + + const response = await axios.get(masterUrl, { + headers: { + 'X-Slave-API-Key': config.masterApiKey + }, + timeout: 30000 + }); + + if (!response.data.success) { + throw new Error(response.data.message || 'Failed to export config from master'); + } + + // Basic validation: check if response has required structure + if (!response.data.data || !response.data.data.hash || !response.data.data.config) { + throw new Error('Invalid response structure from master'); + } + + const { hash: masterHash, config: masterConfig } = response.data.data; + + // Calculate CURRENT hash of slave's config (to detect data loss) + const slaveCurrentConfigResponse = await axios.get( + `http://localhost:${process.env.PORT || 3001}/api/node-sync/current-hash`, + { + headers: { + 'Authorization': req.headers.authorization || '' + } + } + ); + + const slaveCurrentHash = slaveCurrentConfigResponse.data.data?.hash || null; + + logger.info('Comparing slave current config with master', { + masterHash, + slaveCurrentHash, + lastSyncHash: config.lastSyncHash || 'none' + }); + + // Compare CURRENT slave hash with master hash + if (slaveCurrentHash && slaveCurrentHash === masterHash) { + logger.info('Config identical (hash match), skipping import'); + + // Update lastConnectedAt and lastSyncHash + await prisma.systemConfig.update({ + where: { id: config.id }, + data: { + lastConnectedAt: new Date(), + lastSyncHash: masterHash + } + }); + + return res.json({ + success: true, + message: 'Configuration already synchronized (no changes detected)', + data: { + imported: false, + masterHash, + slaveHash: slaveCurrentHash, + changesApplied: 0, + lastSyncAt: new Date().toISOString() + } + }); + } + + // Hash different → Force sync (data loss or master updated) + logger.info('Config mismatch detected, force syncing...', { + masterHash, + slaveCurrentHash: slaveCurrentHash || 'null', + reason: !slaveCurrentHash ? 'slave_empty' : 'data_mismatch' + }); + + // Extract JWT token from request + const authHeader = req.headers.authorization; + const token = authHeader ? authHeader.substring(7) : ''; // Remove 'Bearer ' + + // Call import API (internal call to ourselves) + const importResponse = await axios.post( + `http://localhost:${process.env.PORT || 3001}/api/node-sync/import`, + { + hash: masterHash, + config: masterConfig + }, + { + headers: { + 'Authorization': `Bearer ${token}` + } + } + ); + + if (!importResponse.data.success) { + throw new Error(importResponse.data.message || 'Import failed'); + } + + const importData = importResponse.data.data; + + // Update lastSyncHash + await prisma.systemConfig.update({ + where: { id: config.id }, + data: { + lastSyncHash: masterHash, + lastConnectedAt: new Date() + } + }); + + logger.info(`Sync completed successfully. ${importData.changes} changes applied.`); + + res.json({ + success: true, + message: 'Configuration synchronized successfully', + data: { + imported: true, + masterHash, + slaveHash: slaveCurrentHash, + changesApplied: importData.changes, + details: importData.details, + lastSyncAt: new Date().toISOString() + } + }); + + } catch (error: any) { + logger.error('Sync with master error:', error); + res.status(500).json({ + success: false, + message: error.response?.data?.message || error.message || 'Sync failed' + }); + } +}; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 66404c1..f19f1c6 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -10,9 +10,11 @@ import logger from './utils/logger'; import { initializeNginxForSSL } from './utils/nginx-setup'; import { initializeModSecurityConfig } from './utils/modsec-setup'; import { startAlertMonitoring, stopAlertMonitoring } from './utils/alert-monitoring.service'; +import { startSlaveNodeStatusCheck, stopSlaveNodeStatusCheck } from './utils/slave-status-checker'; const app: Application = express(); let monitoringTimer: NodeJS.Timeout | null = null; +let slaveStatusTimer: NodeJS.Timeout | null = null; // Security middleware app.use(helmet()); @@ -65,6 +67,9 @@ const server = app.listen(PORT, () => { // Start alert monitoring service (global scan every 10 seconds) // Each rule has its own checkInterval for when to actually check monitoringTimer = startAlertMonitoring(10); + + // Start slave node status checker (check every minute) + slaveStatusTimer = startSlaveNodeStatusCheck(); }); // Graceful shutdown @@ -73,6 +78,9 @@ process.on('SIGTERM', () => { if (monitoringTimer) { stopAlertMonitoring(monitoringTimer); } + if (slaveStatusTimer) { + stopSlaveNodeStatusCheck(slaveStatusTimer); + } server.close(() => { logger.info('HTTP server closed'); process.exit(0); @@ -84,6 +92,9 @@ process.on('SIGINT', () => { if (monitoringTimer) { stopAlertMonitoring(monitoringTimer); } + if (slaveStatusTimer) { + stopSlaveNodeStatusCheck(slaveStatusTimer); + } server.close(() => { logger.info('HTTP server closed'); process.exit(0); diff --git a/apps/api/src/middleware/slaveAuth.ts b/apps/api/src/middleware/slaveAuth.ts new file mode 100644 index 0000000..5d693fd --- /dev/null +++ b/apps/api/src/middleware/slaveAuth.ts @@ -0,0 +1,164 @@ +import { Request, Response, NextFunction } from 'express'; +import prisma from '../config/database'; +import logger from '../utils/logger'; + +export interface SlaveRequest extends Request { + slaveNode?: { + id: string; + name: string; + host: string; + port: number; + }; +} + +/** + * Validate Slave API Key + * Used for slave nodes to authenticate with master + */ +export const validateSlaveApiKey = async ( + req: SlaveRequest, + res: Response, + next: NextFunction +): Promise => { + try { + const apiKey = req.headers['x-api-key'] as string; + + if (!apiKey) { + res.status(401).json({ + success: false, + message: 'API key required' + }); + return; + } + + // Find slave node by API key + const slaveNode = await prisma.slaveNode.findFirst({ + where: { apiKey }, + select: { + id: true, + name: true, + host: true, + port: true, + syncEnabled: true + } + }); + + if (!slaveNode) { + logger.warn('Invalid slave API key attempt', { apiKey: apiKey.substring(0, 8) + '...' }); + res.status(401).json({ + success: false, + message: 'Invalid API key' + }); + return; + } + + if (!slaveNode.syncEnabled) { + res.status(403).json({ + success: false, + message: 'Node sync is disabled' + }); + return; + } + + // Attach slave node info to request + req.slaveNode = slaveNode; + + // Update last seen + await prisma.slaveNode.update({ + where: { id: slaveNode.id }, + data: { lastSeen: new Date() } + }).catch(() => {}); // Don't fail if update fails + + next(); + } catch (error) { + logger.error('Slave API key validation error:', error); + res.status(500).json({ + success: false, + message: 'Authentication failed' + }); + } +}; + +/** + * Validate Master API Key for Node Sync + * Used when slave nodes pull config from master + * Updates slave node status when they connect + */ +export const validateMasterApiKey = async ( + req: SlaveRequest, + res: Response, + next: NextFunction +): Promise => { + try { + const apiKey = req.headers['x-slave-api-key'] as string; + + if (!apiKey) { + res.status(401).json({ + success: false, + message: 'Slave API key required' + }); + return; + } + + // Find slave node by API key + const slaveNode = await prisma.slaveNode.findFirst({ + where: { apiKey }, + select: { + id: true, + name: true, + host: true, + port: true, + syncEnabled: true + } + }); + + if (!slaveNode) { + logger.warn('[NODE-SYNC] Invalid slave API key attempt', { + apiKey: apiKey.substring(0, 8) + '...' + }); + res.status(401).json({ + success: false, + message: 'Invalid API key' + }); + return; + } + + if (!slaveNode.syncEnabled) { + res.status(403).json({ + success: false, + message: 'Node sync is disabled' + }); + return; + } + + // Attach slave node info to request + req.slaveNode = slaveNode; + + // Update last seen and status to online + await prisma.slaveNode.update({ + where: { id: slaveNode.id }, + data: { + lastSeen: new Date(), + status: 'online' + } + }).catch((err) => { + logger.warn('[NODE-SYNC] Failed to update slave node status', { + nodeId: slaveNode.id, + error: err.message + }); + }); + + logger.info('[NODE-SYNC] Slave node authenticated', { + nodeId: slaveNode.id, + nodeName: slaveNode.name + }); + + next(); + } catch (error: any) { + logger.error('[SLAVE-AUTH] Validate master API key error:', error); + res.status(500).json({ + success: false, + message: 'Authentication failed' + }); + } +}; diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 151a9b7..13e9962 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -12,6 +12,9 @@ import performanceRoutes from './performance.routes'; import userRoutes from './user.routes'; import dashboardRoutes from './dashboard.routes'; import backupRoutes from './backup.routes'; +import slaveRoutes from './slave.routes'; +import systemConfigRoutes from './system-config.routes'; +import nodeSyncRoutes from './node-sync.routes'; const router = Router(); @@ -38,5 +41,8 @@ router.use('/performance', performanceRoutes); router.use('/users', userRoutes); router.use('/dashboard', dashboardRoutes); router.use('/backup', backupRoutes); +router.use('/slave', slaveRoutes); +router.use('/system-config', systemConfigRoutes); +router.use('/node-sync', nodeSyncRoutes); export default router; diff --git a/apps/api/src/routes/node-sync.routes.ts b/apps/api/src/routes/node-sync.routes.ts new file mode 100644 index 0000000..4d8dc08 --- /dev/null +++ b/apps/api/src/routes/node-sync.routes.ts @@ -0,0 +1,26 @@ +import express from 'express'; +import { exportForSync, importFromMaster, getCurrentConfigHash } from '../controllers/node-sync.controller'; +import { authenticate } from '../middleware/auth'; +import { validateMasterApiKey } from '../middleware/slaveAuth'; + +const router = express.Router(); + +/** + * Export config for slave node sync (requires slave API key) + * GET /api/node-sync/export + */ +router.get('/export', validateMasterApiKey, exportForSync); + +/** + * Import config from master (requires user auth) + * POST /api/node-sync/import + */ +router.post('/import', authenticate, importFromMaster); + +/** + * Get current config hash (requires user auth) + * GET /api/node-sync/current-hash + */ +router.get('/current-hash', authenticate, getCurrentConfigHash); + +export default router; diff --git a/apps/api/src/routes/slave.routes.ts b/apps/api/src/routes/slave.routes.ts new file mode 100644 index 0000000..c69692a --- /dev/null +++ b/apps/api/src/routes/slave.routes.ts @@ -0,0 +1,61 @@ +import { Router } from 'express'; +import { body } from 'express-validator'; +import { authenticate, authorize } from '../middleware/auth'; +import { validateSlaveApiKey } from '../middleware/slaveAuth'; +import { + registerSlaveNode, + getSlaveNodes, + getSlaveNode, + deleteSlaveNode, + healthCheck +} from '../controllers/slave.controller'; + +const router = Router(); + +/** + * @route POST /api/slave/nodes + * @desc Register new slave node + * @access Private (admin) + */ +router.post( + '/nodes', + authenticate, + authorize('admin'), + [ + body('name').notEmpty().withMessage('Name is required'), + body('host').notEmpty().withMessage('Host is required'), + body('port').optional().isInt({ min: 1, max: 65535 }), + body('syncInterval').optional().isInt({ min: 10 }) + ], + registerSlaveNode +); + +/** + * @route GET /api/slave/nodes + * @desc Get all slave nodes + * @access Private (all roles) + */ +router.get('/nodes', authenticate, getSlaveNodes); + +/** + * @route GET /api/slave/nodes/:id + * @desc Get single slave node + * @access Private (all roles) + */ +router.get('/nodes/:id', authenticate, getSlaveNode); + +/** + * @route DELETE /api/slave/nodes/:id + * @desc Delete slave node + * @access Private (admin) + */ +router.delete('/nodes/:id', authenticate, authorize('admin'), deleteSlaveNode); + +/** + * @route GET /api/slave/health + * @desc Health check endpoint (called by master to verify slave is alive) + * @access Slave API Key + */ +router.get('/health', validateSlaveApiKey, healthCheck); + +export default router; diff --git a/apps/api/src/routes/system-config.routes.ts b/apps/api/src/routes/system-config.routes.ts new file mode 100644 index 0000000..229d656 --- /dev/null +++ b/apps/api/src/routes/system-config.routes.ts @@ -0,0 +1,27 @@ +import { Router } from 'express'; +import { authenticate } from '../middleware/auth'; +import { + getSystemConfig, + updateNodeMode, + connectToMaster, + disconnectFromMaster, + testMasterConnection, + syncWithMaster +} from '../controllers/system-config.controller'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// System configuration routes +router.get('/', getSystemConfig); +router.put('/node-mode', updateNodeMode); + +// Slave mode routes +router.post('/connect-master', connectToMaster); +router.post('/disconnect-master', disconnectFromMaster); +router.post('/test-master-connection', testMasterConnection); +router.post('/sync', syncWithMaster); + +export default router; diff --git a/apps/api/src/utils/slave-status-checker.ts b/apps/api/src/utils/slave-status-checker.ts new file mode 100644 index 0000000..590057f --- /dev/null +++ b/apps/api/src/utils/slave-status-checker.ts @@ -0,0 +1,68 @@ +import prisma from '../config/database'; +import logger from './logger'; + +/** + * Check slave nodes and mark as offline if not seen for 5 minutes + */ +export async function checkSlaveNodeStatus() { + try { + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); + + // Find nodes that haven't been seen in 5 minutes and are currently online + const staleNodes = await prisma.slaveNode.findMany({ + where: { + status: 'online', + lastSeen: { + lt: fiveMinutesAgo + } + }, + select: { + id: true, + name: true, + lastSeen: true + } + }); + + if (staleNodes.length > 0) { + logger.info('[SLAVE-STATUS] Marking stale nodes as offline', { + count: staleNodes.length, + nodes: staleNodes.map(n => n.name) + }); + + // Update to offline + await prisma.slaveNode.updateMany({ + where: { + id: { + in: staleNodes.map(n => n.id) + } + }, + data: { + status: 'offline' + } + }); + } + } catch (error: any) { + logger.error('[SLAVE-STATUS] Check slave status error:', error); + } +} + +/** + * Start background job to check slave node status every 1 minute + */ +export function startSlaveNodeStatusCheck(): NodeJS.Timeout { + logger.info('[SLAVE-STATUS] Starting slave node status checker (interval: 60s)'); + + // Run immediately on start + checkSlaveNodeStatus(); + + // Then run every minute + return setInterval(checkSlaveNodeStatus, 60 * 1000); +} + +/** + * Stop background job + */ +export function stopSlaveNodeStatusCheck(timer: NodeJS.Timeout) { + logger.info('[SLAVE-STATUS] Stopping slave node status checker'); + clearInterval(timer); +} diff --git a/apps/web/src/components/pages/SlaveNodes.tsx b/apps/web/src/components/pages/SlaveNodes.tsx index ea8e0d1..f73da01 100644 --- a/apps/web/src/components/pages/SlaveNodes.tsx +++ b/apps/web/src/components/pages/SlaveNodes.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useTranslation } from "react-i18next"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -7,84 +7,306 @@ import { Input } from "@/components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; -import { Server, RefreshCw, Send, Trash2, CheckCircle2, XCircle, Clock } from "lucide-react"; -import { mockSlaveNodes } from "@/mocks/data"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Server, RefreshCw, Trash2, CheckCircle2, XCircle, Clock, AlertCircle, Loader2, Link as LinkIcon, KeyRound } from "lucide-react"; import { SlaveNode } from "@/types"; import { useToast } from "@/hooks/use-toast"; -import { UnderConstructionBanner } from "@/components/ui/under-construction-banner"; +import { slaveNodesQueryOptions } from "@/queries/slave.query-options"; +import { systemConfigQueryOptions } from "@/queries/system-config.query-options"; +import { slaveNodeService } from "@/services/slave.service"; +import { systemConfigService } from "@/services/system-config.service"; const SlaveNodes = () => { - const { t } = useTranslation(); const { toast } = useToast(); - const [nodes, setNodes] = useState(mockSlaveNodes); + const queryClient = useQueryClient(); const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isMasterDialogOpen, setIsMasterDialogOpen] = useState(false); - const [formData, setFormData] = useState({ + // Form data for Register Slave Node (Master mode) + const [slaveFormData, setSlaveFormData] = useState({ name: "", host: "", - port: 8088 + port: 3001, + syncInterval: 60 }); - const handleAddNode = () => { - const newNode: SlaveNode = { - id: `node${nodes.length + 1}`, - name: formData.name, - host: formData.host, - port: formData.port, - status: 'offline', - lastSeen: new Date().toISOString(), - version: '1.24.0', - syncStatus: { - lastSync: new Date().toISOString(), - configHash: '', - inSync: false - } - }; - setNodes([...nodes, newNode]); - setIsDialogOpen(false); - resetForm(); - toast({ title: "Slave node registered", description: "Node added successfully" }); + // Form data for Connect to Master (Slave mode) + const [masterFormData, setMasterFormData] = useState({ + masterHost: "", + masterPort: 3001, + masterApiKey: "", + syncInterval: 60 + }); + + const [apiKeyDialog, setApiKeyDialog] = useState<{ open: boolean; apiKey: string }>({ + open: false, + apiKey: '' + }); + + // Confirm mode change dialog + const [modeChangeDialog, setModeChangeDialog] = useState<{ open: boolean; newMode: 'master' | 'slave' | null }>({ + open: false, + newMode: null + }); + + // Delete node confirm dialog + const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; nodeId: string | null }>({ + open: false, + nodeId: null + }); + + // Disconnect confirm dialog + const [disconnectDialog, setDisconnectDialog] = useState(false); + + // Fetch system configuration + const { data: systemConfigData, isLoading: isConfigLoading } = useQuery(systemConfigQueryOptions.all); + const systemConfig = systemConfigData?.data; + + // Fetch slave nodes (only in master mode) + const { data: nodes = [], isLoading: isNodesLoading } = useQuery({ + ...slaveNodesQueryOptions.all, + enabled: systemConfig?.nodeMode === 'master', + refetchInterval: 30000 // Refetch every 30 seconds to update status + }); + + // Update node mode mutation + const updateNodeModeMutation = useMutation({ + mutationFn: systemConfigService.updateNodeMode, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['system-config'] }); + queryClient.invalidateQueries({ queryKey: ['slave-nodes'] }); + + toast({ + title: "Node mode changed", + description: `Node is now in ${data.data.nodeMode} mode`, + }); + }, + onError: (error: any) => { + toast({ + title: "Failed to change mode", + description: error.response?.data?.message || "An error occurred", + variant: "destructive" + }); + } + }); + + // Register slave node mutation (Master mode) + const registerMutation = useMutation({ + mutationFn: slaveNodeService.register, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['slave-nodes'] }); + setIsDialogOpen(false); + resetSlaveForm(); + + // Show API key in separate dialog (critical info!) + setApiKeyDialog({ + open: true, + apiKey: data.data.apiKey + }); + + toast({ + title: "Slave node registered successfully", + description: `Node ${data.data.name} has been registered`, + }); + }, + onError: (error: any) => { + toast({ + title: "Registration failed", + description: error.response?.data?.message || "Failed to register node", + variant: "destructive", + duration: 5000 + }); + } + }); + + // Connect to master mutation (Slave mode) + const connectToMasterMutation = useMutation({ + mutationFn: systemConfigService.connectToMaster, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['system-config'] }); + setIsMasterDialogOpen(false); + resetMasterForm(); + + toast({ + title: "Connected to master", + description: `Successfully connected to ${data.data.masterHost}:${data.data.masterPort}`, + }); + }, + onError: (error: any) => { + toast({ + title: "Connection failed", + description: error.response?.data?.message || "Failed to connect to master", + variant: "destructive" + }); + } + }); + + // Disconnect from master mutation + const disconnectMutation = useMutation({ + mutationFn: systemConfigService.disconnectFromMaster, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['system-config'] }); + + toast({ + title: "Disconnected", + description: "Disconnected from master node", + }); + }, + onError: (error: any) => { + toast({ + title: "Disconnect failed", + description: error.response?.data?.message || "Failed to disconnect", + variant: "destructive" + }); + } + }); + + // Test master connection mutation + const testConnectionMutation = useMutation({ + mutationFn: systemConfigService.testMasterConnection, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['system-config'] }); + + toast({ + title: "Connection test successful", + description: `Latency: ${data.data.latency}ms | Master: ${data.data.masterStatus}`, + }); + }, + onError: (error: any) => { + toast({ + title: "Connection test failed", + description: error.response?.data?.message || "Failed to connect", + variant: "destructive" + }); + } + }); + + // Sync from master mutation (slave pulls config) + const syncFromMasterMutation = useMutation({ + mutationFn: systemConfigService.syncWithMaster, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['system-config'] }); + + toast({ + title: "Sync completed", + description: `${data.data.changesApplied} changes applied from master`, + }); + }, + onError: (error: any) => { + toast({ + title: "Sync failed", + description: error.response?.data?.message || "Failed to sync with master", + variant: "destructive" + }); + } + }); + + // Delete mutation + const deleteMutation = useMutation({ + mutationFn: slaveNodeService.delete, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['slave-nodes'] }); + toast({ title: "Node removed successfully" }); + }, + onError: (error: any) => { + toast({ + title: "Delete failed", + description: error.response?.data?.message || "Failed to delete node", + variant: "destructive" + }); + } + }); + + const handleRegisterSlave = () => { + if (!slaveFormData.name || !slaveFormData.host) { + toast({ + title: "Validation error", + description: "Name and host are required", + variant: "destructive" + }); + return; + } + + registerMutation.mutate({ + name: slaveFormData.name, + host: slaveFormData.host, + port: slaveFormData.port, + syncInterval: slaveFormData.syncInterval + }); + }; + + const handleConnectToMaster = () => { + if (!masterFormData.masterHost || !masterFormData.masterApiKey) { + toast({ + title: "Validation error", + description: "Master host and API key are required", + variant: "destructive" + }); + return; + } + + if (masterFormData.syncInterval < 10) { + toast({ + title: "Validation error", + description: "Sync interval must be at least 10 seconds", + variant: "destructive" + }); + return; + } + + connectToMasterMutation.mutate({ + masterHost: masterFormData.masterHost, + masterPort: masterFormData.masterPort, + masterApiKey: masterFormData.masterApiKey, + syncInterval: masterFormData.syncInterval + }); }; - const resetForm = () => { - setFormData({ + const resetSlaveForm = () => { + setSlaveFormData({ name: "", host: "", - port: 8088 + port: 3001, + syncInterval: 60 }); }; - const handlePushConfig = (nodeId: string) => { - const node = nodes.find(n => n.id === nodeId); - toast({ - title: "Configuration pushed", - description: `Config sync initiated to ${node?.name} (mock mode)` + const resetMasterForm = () => { + setMasterFormData({ + masterHost: "", + masterPort: 3001, + masterApiKey: "", + syncInterval: 60 }); }; - const handleSync = (nodeId: string) => { - setNodes(nodes.map(n => - n.id === nodeId - ? { - ...n, - status: 'syncing', - syncStatus: { ...n.syncStatus, lastSync: new Date().toISOString() } - } - : n - )); - setTimeout(() => { - setNodes(nodes.map(n => - n.id === nodeId - ? { ...n, status: 'online', syncStatus: { ...n.syncStatus, inSync: true } } - : n - )); - toast({ title: "Sync completed" }); - }, 2000); + const handleDelete = (id: string) => { + setDeleteDialog({ open: true, nodeId: id }); }; - const handleDelete = (id: string) => { - setNodes(nodes.filter(n => n.id !== id)); - toast({ title: "Node removed" }); + const confirmDelete = () => { + if (deleteDialog.nodeId) { + deleteMutation.mutate(deleteDialog.nodeId); + setDeleteDialog({ open: false, nodeId: null }); + } + }; + + const handleModeChange = (newMode: 'master' | 'slave') => { + if (systemConfig?.nodeMode === newMode) return; + + // Show custom dialog instead of browser confirm + setModeChangeDialog({ + open: true, + newMode + }); + }; + + const confirmModeChange = () => { + if (modeChangeDialog.newMode) { + updateNodeModeMutation.mutate(modeChangeDialog.newMode); + setModeChangeDialog({ open: false, newMode: null }); + } }; const getStatusColor = (status: string) => { @@ -92,6 +314,7 @@ const SlaveNodes = () => { case 'online': return 'default'; case 'offline': return 'destructive'; case 'syncing': return 'secondary'; + case 'error': return 'destructive'; default: return 'secondary'; } }; @@ -101,238 +324,553 @@ const SlaveNodes = () => { case 'online': return ; case 'offline': return ; case 'syncing': return ; + case 'error': return ; default: return ; } }; + if (isConfigLoading || isNodesLoading) { + return ( +
+ +
+ ); + } + + const currentMode = systemConfig?.nodeMode || 'master'; + const isMasterMode = currentMode === 'master'; + const isSlaveMode = currentMode === 'slave'; + return (
- + {/* Header */}
-

Slave Nodes

-

Manage distributed nginx nodes and configuration sync

+

Node Synchronization

+

Manage master-slave node configuration

- - - - - - - Register Slave Node - - Add a new slave node to the cluster - - -
-
- - setFormData({ ...formData, name: e.target.value })} - placeholder="nginx-slave-04" - /> -
-
- - setFormData({ ...formData, host: e.target.value })} - placeholder="10.0.10.14" - /> -
-
- - setFormData({ ...formData, port: Number(e.target.value) })} - placeholder="8088" - /> -
-
- - - - -
-
-
- - - Total Nodes - - - -
{nodes.length}
-

- Registered slave nodes -

-
-
- - - - Online Nodes - - - -
- {nodes.filter(n => n.status === 'online').length} -
-

- Active and healthy -

-
-
- - - - Sync Status - - - -
- {nodes.filter(n => n.syncStatus.inSync).length}/{nodes.length} + {/* Node Mode Status Card */} + + +
+
+ {isMasterMode ? ( + + ) : ( + + )} +
+

+ Current Mode: + {isMasterMode ? 'MASTER' : 'SLAVE'} + +

+

+ {isMasterMode ? 'This node can register and manage slave nodes' : 'This node is connected to a master node'} +

+
-

- Nodes in sync -

- - -
- - - - Registered Nodes ({nodes.length}) - View and manage slave node cluster - - -
- - - - Name - Host:Port - Status - Version - Last Seen - Sync Status - Config Hash - Actions - - - - {nodes.map((node) => ( - - {node.name} - {node.host}:{node.port} - - - {getStatusIcon(node.status)} - {node.status} - - - {node.version} - - {new Date(node.lastSeen).toLocaleString()} - - - {node.syncStatus.inSync ? ( - - - In Sync - - ) : ( - - - Out of Sync - - )} - - - {node.syncStatus.configHash || 'N/A'} - - - - - - - - ))} - -
+ {isSlaveMode && systemConfig?.connected && ( + + + Connected to Master + + )}
- - - Cluster Topology - Visual representation of node cluster - - -
-
-
- + {/* Main Tabs */} + handleModeChange(value as 'master' | 'slave')}> + + + + Master Mode + + + + Slave Mode + + + + {/* MASTER MODE TAB */} + + + + Master Node Configuration + + Register slave nodes and manage distributed configuration sync + + + +
+
+

Registered Slave Nodes

+

+ {nodes.length} slave node(s) registered - Slaves will pull config automatically +

+
+
+ + + + + + + Register Slave Node + + Add a new slave node to receive configuration updates + + +
+
+ + setSlaveFormData({ ...slaveFormData, name: e.target.value })} + placeholder="slave-node-01" + /> +
+
+ + setSlaveFormData({ ...slaveFormData, host: e.target.value })} + placeholder="Enter slave node IP address" + /> +
+
+ + setSlaveFormData({ ...slaveFormData, port: Number(e.target.value) })} + placeholder="3001" + /> +
+
+ + + + +
+
+
-

Master Node

-

Primary

-
-
-
- {nodes.map((node) => ( -
-
- -
-

{node.name}

- - {node.status} - + + {/* Slave Nodes Table */} +
+ + + + Name + Host:Port + Status + Last Seen + Config Hash + Actions + + + + {nodes.length === 0 ? ( + + + No slave nodes registered. Click "Register Slave Node" to add one. + + + ) : ( + nodes.map((node) => ( + + {node.name} + {node.host}:{node.port} + + + {getStatusIcon(node.status)} + {node.status} + + + + {node.lastSeen ? new Date(node.lastSeen).toLocaleString() : 'Never'} + + + {node.configHash?.substring(0, 12) || 'N/A'}... + + + + + + )) + )} + +
+
+ + + + + {/* SLAVE MODE TAB */} + + + + Slave Node Configuration + + Connect to a master node to receive configuration updates + + + + {!systemConfig?.connected ? ( +
+ + + + You are in Slave Mode but not connected to any master node. + Click "Connect to Master" to configure the connection. + + + + + + + + + + Connect to Master Node + + Enter the master node details and API key to establish connection + + +
+
+ + setMasterFormData({ ...masterFormData, masterHost: e.target.value })} + placeholder="Enter master node IP address" + /> +
+
+ + setMasterFormData({ ...masterFormData, masterPort: Number(e.target.value) })} + placeholder="3001" + /> +
+
+ + setMasterFormData({ ...masterFormData, masterApiKey: e.target.value })} + placeholder="Enter API key from master node" + /> +

+ Get this API key from the master node when registering this slave +

+
+
+ + setMasterFormData({ ...masterFormData, syncInterval: Number(e.target.value) })} + placeholder="60" + /> +

+ How often to pull configuration from master (minimum: 10 seconds) +

+
+
+ + + + +
+
+
+ ) : ( +
+ + +
+
+
+ + Connected to Master +
+ + Active + +
+
+
+ Master Host: + {systemConfig.masterHost}:{systemConfig.masterPort} +
+ {systemConfig.lastConnectedAt && ( +
+ Last Connected: + {new Date(systemConfig.lastConnectedAt).toLocaleString()} +
+ )} +
+
+ + + +
+
+
+
- ))} + )} +
+
+
+ + + {/* Mode Change Confirmation Dialog */} + setModeChangeDialog({ ...modeChangeDialog, open })}> + + + + + Confirm Mode Change + + + {modeChangeDialog.newMode === 'slave' + ? "Switching to Slave mode will disable the ability to register slave nodes. You will need to connect to a master node." + : "Switching to Master mode will disconnect from the current master and allow you to register slave nodes."} + + + + + + + + + + {/* API Key Dialog */} + setApiKeyDialog({ ...apiKeyDialog, open })}> + + + + + Slave Node API Key + + + Save this API key! You'll need it to connect the slave node to this master. + + +
+ + + + This API key will only be shown once. Copy it now and store it securely. + + + +
+ +
+ + +
+
+ +
+

Next Steps:

+
    +
  1. Go to the slave node web interface
  2. +
  3. Switch to Slave Mode
  4. +
  5. Click "Connect to Master Node"
  6. +
  7. Enter this API key along with master host/port
  8. +
  9. Click "Connect" to establish synchronization
  10. +
- - + + + +
+
+ + {/* Delete Node Confirmation Dialog */} + setDeleteDialog({ ...deleteDialog, open })}> + + + + + Confirm Deletion + + + Are you sure you want to remove this slave node? This action cannot be undone. + + + + + + + + + + {/* Disconnect Confirmation Dialog */} + + + + + + Confirm Disconnect + + + Are you sure you want to disconnect from the master node? You will need to reconnect manually. + + + + + + + +
); }; diff --git a/apps/web/src/mocks/data.ts b/apps/web/src/mocks/data.ts index 37cc44c..b87e96e 100644 --- a/apps/web/src/mocks/data.ts +++ b/apps/web/src/mocks/data.ts @@ -385,6 +385,16 @@ export const mockSlaveNodes: SlaveNode[] = [ status: 'online', lastSeen: '2025-03-29T14:35:00Z', version: '1.24.0', + syncEnabled: true, + syncInterval: 60, + configHash: 'a1b2c3d4e5f6', + lastSyncAt: '2025-03-29T14:30:00Z', + latency: 15, + cpuUsage: 25.5, + memoryUsage: 45.2, + diskUsage: 60.1, + createdAt: '2025-01-15T10:00:00Z', + updatedAt: '2025-03-29T14:35:00Z', syncStatus: { lastSync: '2025-03-29T14:30:00Z', configHash: 'a1b2c3d4e5f6', @@ -399,6 +409,16 @@ export const mockSlaveNodes: SlaveNode[] = [ status: 'online', lastSeen: '2025-03-29T14:34:55Z', version: '1.24.0', + syncEnabled: true, + syncInterval: 60, + configHash: 'a1b2c3d4e5f5', + lastSyncAt: '2025-03-29T14:00:00Z', + latency: 22, + cpuUsage: 35.8, + memoryUsage: 52.3, + diskUsage: 55.7, + createdAt: '2025-01-20T11:30:00Z', + updatedAt: '2025-03-29T14:34:55Z', syncStatus: { lastSync: '2025-03-29T14:00:00Z', configHash: 'a1b2c3d4e5f5', @@ -413,6 +433,12 @@ export const mockSlaveNodes: SlaveNode[] = [ status: 'offline', lastSeen: '2025-03-28T22:15:00Z', version: '1.23.4', + syncEnabled: false, + syncInterval: 120, + configHash: 'x9y8z7w6v5u4', + lastSyncAt: '2025-03-28T20:00:00Z', + createdAt: '2025-02-01T09:00:00Z', + updatedAt: '2025-03-28T22:15:00Z', syncStatus: { lastSync: '2025-03-28T20:00:00Z', configHash: 'x9y8z7w6v5u4', @@ -423,7 +449,7 @@ export const mockSlaveNodes: SlaveNode[] = [ export const mockPerformanceMetrics: PerformanceMetric[] = Array.from({ length: 20 }, (_, i) => ({ id: `perf${i + 1}`, - domain: ['api.example.com', 'app.production.com', 'cdn.assets.com'][i % 3], + domain: ['api.example.com', 'app.production.com', 'cdn.assets.com'][i % 3] || 'api.example.com', timestamp: new Date(Date.now() - (19 - i) * 300000).toISOString(), responseTime: Math.random() * 200 + 50, throughput: Math.random() * 1000 + 500, diff --git a/apps/web/src/queries/slave.query-options.ts b/apps/web/src/queries/slave.query-options.ts new file mode 100644 index 0000000..6369693 --- /dev/null +++ b/apps/web/src/queries/slave.query-options.ts @@ -0,0 +1,31 @@ +import { queryOptions } from '@tanstack/react-query'; +import { slaveNodeService } from '@/services/slave.service'; + +export const slaveNodesQueryOptions = { + all: queryOptions({ + queryKey: ['slave-nodes', 'list'], + queryFn: () => slaveNodeService.getAll(), + staleTime: 30 * 1000, // 30 seconds + }), + + detail: (id: string) => + queryOptions({ + queryKey: ['slave-nodes', 'detail', id], + queryFn: () => slaveNodeService.getById(id), + staleTime: 30 * 1000, + }), + + status: (id: string) => + queryOptions({ + queryKey: ['slave-nodes', 'status', id], + queryFn: () => slaveNodeService.getStatus(id), + staleTime: 10 * 1000, // 10 seconds + }), + + syncHistory: (id: string, limit: number = 50) => + queryOptions({ + queryKey: ['slave-nodes', 'sync-history', id, limit], + queryFn: () => slaveNodeService.getSyncHistory(id, limit), + staleTime: 30 * 1000, + }), +}; diff --git a/apps/web/src/queries/system-config.query-options.ts b/apps/web/src/queries/system-config.query-options.ts new file mode 100644 index 0000000..6c6a651 --- /dev/null +++ b/apps/web/src/queries/system-config.query-options.ts @@ -0,0 +1,10 @@ +import { queryOptions } from '@tanstack/react-query'; +import { systemConfigService } from '@/services/system-config.service'; + +export const systemConfigQueryOptions = { + all: queryOptions({ + queryKey: ['system-config'], + queryFn: systemConfigService.getConfig, + refetchInterval: 30000, // Refetch every 30s + }), +}; diff --git a/apps/web/src/services/slave.service.ts b/apps/web/src/services/slave.service.ts new file mode 100644 index 0000000..b58a444 --- /dev/null +++ b/apps/web/src/services/slave.service.ts @@ -0,0 +1,134 @@ +import axios from 'axios'; +import { SlaveNode } from '@/types'; + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'; + +export interface RegisterSlaveNodeRequest { + name: string; + host: string; + port?: number; + syncInterval?: number; +} + +export interface UpdateSlaveNodeRequest { + name?: string; + host?: string; + port?: number; + syncEnabled?: boolean; + syncInterval?: number; +} + +export interface SyncConfigRequest { + force?: boolean; +} + +export interface SyncLog { + id: string; + nodeId: string; + type: 'full_sync' | 'incremental_sync' | 'health_check'; + status: 'success' | 'failed' | 'partial' | 'running'; + configHash?: string; + changesCount?: number; + errorMessage?: string; + startedAt: string; + completedAt?: string; + duration?: number; +} + +export interface SlaveNodeWithLogs extends SlaveNode { + syncLogs?: SyncLog[]; +} + +// Helper function to get headers +const getHeaders = () => { + const token = localStorage.getItem('accessToken'); + return { + 'Content-Type': 'application/json', + Authorization: token ? `Bearer ${token}` : '', + }; +}; + +class SlaveNodeService { + async getAll(): Promise { + const response = await axios.get(`${API_URL}/slave/nodes`, { + headers: getHeaders(), + }); + return response.data.data; + } + + async getById(id: string): Promise { + const response = await axios.get(`${API_URL}/slave/nodes/${id}`, { + headers: getHeaders(), + }); + return response.data.data; + } + + async register(data: RegisterSlaveNodeRequest) { + console.log('SlaveNodeService.register called with:', data); + console.log('API_URL:', API_URL); + console.log('Headers:', getHeaders()); + + try { + const response = await axios.post(`${API_URL}/slave/nodes`, data, { + headers: getHeaders(), + }); + console.log('Register response:', response.data); + return response.data; + } catch (error: any) { + console.error('Register error:', error.response?.data || error.message); + throw error; + } + } + + async update(id: string, data: UpdateSlaveNodeRequest) { + const response = await axios.put(`${API_URL}/slave/nodes/${id}`, data, { + headers: getHeaders(), + }); + return response.data; + } + + async delete(id: string) { + const response = await axios.delete(`${API_URL}/slave/nodes/${id}`, { + headers: getHeaders(), + }); + return response.data; + } + + async syncToNode(id: string, data: SyncConfigRequest = {}) { + const response = await axios.post(`${API_URL}/slave/nodes/${id}/sync`, data, { + headers: getHeaders(), + }); + return response.data; + } + + async syncToAll() { + const response = await axios.post(`${API_URL}/slave/nodes/sync-all`, {}, { + headers: getHeaders(), + }); + return response.data; + } + + async getStatus(id: string) { + const response = await axios.get(`${API_URL}/slave/nodes/${id}/status`, { + headers: getHeaders(), + }); + return response.data; + } + + async getSyncHistory(id: string, limit: number = 50) { + const response = await axios.get(`${API_URL}/slave/nodes/${id}/sync-history`, { + headers: getHeaders(), + params: { limit }, + }); + return response.data.data; + } + + async regenerateApiKey(id: string) { + const response = await axios.post(`${API_URL}/slave/nodes/${id}/regenerate-key`, {}, { + headers: getHeaders(), + }); + return response.data; + } +} + +export const slaveNodeService = new SlaveNodeService(); diff --git a/apps/web/src/services/system-config.service.ts b/apps/web/src/services/system-config.service.ts new file mode 100644 index 0000000..3ef8810 --- /dev/null +++ b/apps/web/src/services/system-config.service.ts @@ -0,0 +1,106 @@ +import axios from 'axios'; +import { SystemConfig, ApiResponse } from '@/types'; + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'; + +const getHeaders = () => { + const token = localStorage.getItem('accessToken'); + return { + 'Content-Type': 'application/json', + Authorization: token ? `Bearer ${token}` : '', + }; +}; + +export const systemConfigService = { + /** + * Get system configuration + */ + getConfig: async (): Promise> => { + const response = await axios.get(`${API_URL}/system-config`, { + headers: getHeaders(), + }); + return response.data; + }, + + /** + * Update node mode (master or slave) + */ + updateNodeMode: async (nodeMode: 'master' | 'slave'): Promise> => { + const response = await axios.put( + `${API_URL}/system-config/node-mode`, + { nodeMode }, + { + headers: getHeaders(), + } + ); + return response.data; + }, + + /** + * Connect to master node (for slave mode) + */ + connectToMaster: async (params: { + masterHost: string; + masterPort: number; + masterApiKey: string; + syncInterval?: number; + }): Promise> => { + const response = await axios.post( + `${API_URL}/system-config/connect-master`, + params, + { + headers: getHeaders(), + } + ); + return response.data; + }, + + /** + * Disconnect from master node + */ + disconnectFromMaster: async (): Promise> => { + const response = await axios.post( + `${API_URL}/system-config/disconnect-master`, + {}, + { + headers: getHeaders(), + } + ); + return response.data; + }, + + /** + * Test connection to master + */ + testMasterConnection: async (): Promise> => { + const response = await axios.post( + `${API_URL}/system-config/test-master-connection`, + {}, + { + headers: getHeaders(), + } + ); + return response.data; + }, + + /** + * Sync configuration from master (slave pulls config) + */ + syncWithMaster: async (): Promise> => { + const response = await axios.post( + `${API_URL}/system-config/sync`, + {}, + { + headers: getHeaders(), + } + ); + return response.data; + }, +}; diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts index 9ac70c4..f25c029 100644 --- a/apps/web/src/types/index.ts +++ b/apps/web/src/types/index.ts @@ -186,10 +186,27 @@ export interface SlaveNode { name: string; host: string; port: number; - status: 'online' | 'offline' | 'syncing'; - lastSeen: string; - version: string; - syncStatus: { + status: 'online' | 'offline' | 'syncing' | 'error'; + lastSeen?: string; + version?: string; + + // Sync configuration + syncEnabled: boolean; + syncInterval: number; + configHash?: string; + lastSyncAt?: string; + + // Metrics + latency?: number; + cpuUsage?: number; + memoryUsage?: number; + diskUsage?: number; + + createdAt: string; + updatedAt: string; + + // Legacy support for old mock data + syncStatus?: { lastSync: string; configHash: string; inSync: boolean; @@ -261,3 +278,26 @@ export interface ApiResponse { message?: string; pagination?: Pagination; } + +export interface SystemConfig { + id: string; + nodeMode: 'master' | 'slave'; + + // Master mode settings + masterApiEnabled: boolean; + + // Slave mode settings + slaveApiEnabled: boolean; + masterHost?: string | null; + masterPort?: number | null; + masterApiKey?: string | null; + syncInterval: number; // Sync interval in seconds + + // Connection status (for slave mode) + connected: boolean; + lastConnectedAt?: string | null; + connectionError?: string | null; + + createdAt: string; + updatedAt: string; +}