diff --git a/.gitignore b/.gitignore index b5a05a3..a31a833 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ dist **/dist .vagrant .env -**/meta **/.vite .turbo apps/backend/test/dns diff --git a/apps/backend/drizzle/meta/0000_snapshot.json b/apps/backend/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..bd5b810 --- /dev/null +++ b/apps/backend/drizzle/meta/0000_snapshot.json @@ -0,0 +1,79 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6c9c550f-7d6b-42e6-b545-014bcb5d2fb8", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json new file mode 100644 index 0000000..47c631d --- /dev/null +++ b/apps/backend/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1748690684367, + "tag": "0000_strong_ronan", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index e703f6c..3d91fb3 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -8,6 +8,7 @@ import { corsMiddleware } from './middlewares/cors'; import morganMiddleware from './middlewares/morgan'; import dnsRoutes from './routes/dnsRoutes'; import httpRoutes from './routes/httpRoutes'; +import dhcpRoutes from './routes/dhcpRoutes'; import systemMetricsRoutes from './routes/systemMetricsRoutes'; import logger from './lib/logger'; @@ -34,6 +35,7 @@ app.use('/api/services', serviceRoutes); app.use('/api/users', userRoutes); app.use('/api/dns', dnsRoutes); app.use('/api/http', httpRoutes); +app.use('/api/dhcp', dhcpRoutes); app.use('/api/system-metrics', systemMetricsRoutes); // Error handling diff --git a/apps/backend/src/controllers/dhcpController.ts b/apps/backend/src/controllers/dhcpController.ts new file mode 100644 index 0000000..fae8de3 --- /dev/null +++ b/apps/backend/src/controllers/dhcpController.ts @@ -0,0 +1,563 @@ +import type { Response } from 'express'; +import type { AuthRequest } from '../middlewares/authMiddleware'; +import { dhcpConfigSchema, transformDhcpFormToApi, type DhcpConfigFormValues } from '@server-manager/shared/validators'; +import type { DhcpConfiguration, DhcpServiceResponse } from '@server-manager/shared'; +import { ZodError } from 'zod'; +import { writeFile, readFile, mkdir } from 'fs/promises'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import { existsSync } from 'fs'; +import crypto from 'crypto'; +import fs from 'fs'; +import logger from '../lib/logger.js'; +import { ServiceManager } from '../lib/ServiceManager.js'; +import { MockServiceManager } from '../lib/MockServiceManager.js'; +import config from '../config/config.js'; + +const execAsync = promisify(exec); +const serviceManager = config.useMockServices ? new MockServiceManager() : new ServiceManager(); + +// Configuration paths +const isProd = process.env.NODE_ENV === 'production'; +const DHCPD_CONF_DIR = isProd ? '/etc/dhcp' : './test/dhcp/config'; +const DHCPD_CONF_PATH = isProd ? '/etc/dhcp/dhcpd.conf' : './test/dhcp/config/dhcpd.conf'; +const DHCPD_BACKUP_DIR = isProd ? '/etc/dhcp/backups' : './test/dhcp/backups'; +const DHCPD_LEASES_PATH = isProd ? '/var/lib/dhcpd/dhcpd.leases' : './test/dhcp/dhcpd.leases'; + +// Ensure directory exists +const ensureDirectoryExists = async (dir: string): Promise => { + try { + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + logger.info(`Created directory: ${dir}`); + } + } catch (error) { + logger.error(`Failed to create directory ${dir}:`, error); + throw new Error(`Failed to create directory: ${dir}`); + } +}; + +// Check write permissions +const checkWritePermission = async (filePath: string): Promise => { + try { + const dir = path.dirname(filePath); + await fs.promises.access(dir, fs.constants.W_OK); + return true; + } catch (error) { + logger.warn(`No write permission for ${filePath}`); + return false; + } +}; + +// Write file with backup functionality +const writeFileWithBackup = async ( + filePath: string, + content: string, + options?: { + writeJsonVersion?: boolean; + jsonContent?: string; + jsonGenerator?: () => string; + } +): Promise => { + const dir = path.dirname(filePath); + const filename = path.basename(filePath); + const backupDir = path.join(dir, 'backups'); + + // Ensure directories exist + await ensureDirectoryExists(dir); + await ensureDirectoryExists(backupDir); + + // Check write permissions + const canWrite = await checkWritePermission(filePath); + if (!canWrite) { + throw new Error(`No write permission for ${filePath}`); + } + + // Create backup if file exists + if (existsSync(filePath)) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupFilePath = path.join(backupDir, `${filename}.${timestamp}.backup`); + + try { + const existingContent = await readFile(filePath, 'utf8'); + await writeFile(backupFilePath, existingContent, 'utf8'); + logger.info(`Backup created: ${backupFilePath}`); + } catch (error) { + logger.warn(`Failed to create backup for ${filePath}:`, error); + } + } + + // Write the main file + await writeFile(filePath, content, 'utf8'); + logger.info(`Configuration written to: ${filePath}`); + + // Write JSON version if requested + if (options?.writeJsonVersion) { + const jsonPath = `${filePath}.json`; + let jsonContent = options.jsonContent; + + if (!jsonContent && options.jsonGenerator) { + jsonContent = options.jsonGenerator(); + } + + if (jsonContent) { + await writeFile(jsonPath, jsonContent, 'utf8'); + logger.info(`JSON configuration written to: ${jsonPath}`); + } + } +}; + +// Generate DHCP configuration file content +export const generateDhcpdConf = (config: DhcpConfiguration): string => { + let conf = `# DHCP Server Configuration +# Generated by Server Manager on ${new Date().toISOString()} +# Configuration version: 1.0 + +`; + + // Global options + if (config.domainName) { + conf += `option domain-name "${config.domainName}";\n`; + } + + if (config.domainNameServers?.length) { + conf += `option domain-name-servers ${config.domainNameServers.join(', ')};\n`; + } + + conf += `default-lease-time ${config.defaultLeaseTime || 86400};\n`; + conf += `max-lease-time ${config.maxLeaseTime || 604800};\n`; + + if (config.authoritative) { + conf += `authoritative;\n`; + } + + if (config.ddnsUpdateStyle && config.ddnsUpdateStyle !== 'none') { + conf += `ddns-update-style ${config.ddnsUpdateStyle};\n`; + } else { + conf += `ddns-update-style none;\n`; + } + + if (config.logFacility) { + conf += `log-facility ${config.logFacility};\n`; + } + + conf += '\n'; + + // Global custom options + if (config.globalOptions?.length) { + conf += `# Global Options\n`; + config.globalOptions.forEach(option => { + conf += `option ${option.name} ${option.value};\n`; + }); + conf += '\n'; + } + + // Subnets + config.subnets.forEach((subnet, index) => { + conf += `# Subnet ${index + 1}: ${subnet.network}/${subnet.netmask}\n`; + conf += `subnet ${subnet.network} netmask ${subnet.netmask} {\n`; + + if (subnet.range) { + conf += ` range ${subnet.range.start} ${subnet.range.end};\n`; + } + + if (subnet.defaultGateway) { + conf += ` option routers ${subnet.defaultGateway};\n`; + } + + if (subnet.domainNameServers?.length) { + conf += ` option domain-name-servers ${subnet.domainNameServers.join(', ')};\n`; + } + + if (subnet.broadcastAddress) { + conf += ` option broadcast-address ${subnet.broadcastAddress};\n`; + } + + if (subnet.subnetMask) { + conf += ` option subnet-mask ${subnet.subnetMask};\n`; + } + + // Subnet-specific pools + if (subnet.pools?.length) { + subnet.pools.forEach((pool, poolIndex) => { + conf += ` \n # Pool ${poolIndex + 1}\n`; + conf += ` pool {\n`; + conf += ` range ${pool.range.start} ${pool.range.end};\n`; + + if (pool.allowMembers?.length) { + pool.allowMembers.forEach(member => { + conf += ` allow members of "${member}";\n`; + }); + } + + if (pool.denyMembers?.length) { + pool.denyMembers.forEach(member => { + conf += ` deny members of "${member}";\n`; + }); + } + + conf += ` }\n`; + }); + } + + // Subnet-specific options + if (subnet.options?.length) { + subnet.options.forEach(option => { + conf += ` option ${option.name} ${option.value};\n`; + }); + } + + conf += `}\n\n`; + }); + + // Host reservations + if (config.hostReservations?.length) { + conf += `# Static Host Reservations\n`; + config.hostReservations.forEach(host => { + conf += `host ${host.hostname} {\n`; + conf += ` hardware ethernet ${host.macAddress};\n`; + conf += ` fixed-address ${host.fixedAddress};\n`; + + if (host.options?.length) { + host.options.forEach(option => { + conf += ` option ${option.name} ${option.value};\n`; + }); + } + + conf += `}\n\n`; + }); + } + + return conf; +}; + +// Validate DHCP configuration syntax +const validateDhcpConfigSyntax = async (): Promise => { + if (!isProd) { + logger.info('Skipping DHCP configuration validation in development mode'); + return; + } + + try { + await execAsync(`dhcpd -t -cf ${DHCPD_CONF_PATH}`); + logger.info('DHCP configuration validation successful'); + } catch (error) { + logger.error('DHCP configuration validation failed:', error); + throw new Error(`DHCP configuration validation failed: ${(error as Error).message}`); + } +}; + +// Reload DHCP service +const reloadDhcpService = async (): Promise => { + try { + await serviceManager.restart('dhcpd'); + logger.info('DHCP service reloaded successfully'); + } catch (error) { + logger.error('Failed to reload DHCP service:', error); + throw new Error(`Failed to reload DHCP service: ${(error as Error).message}`); + } +}; + +// Update DHCP configuration +export const updateDhcpConfiguration = async (req: AuthRequest, res: Response) => { + try { + logger.info('Received DHCP configuration update request'); + + // Validate request body + const validatedFormData: DhcpConfigFormValues = dhcpConfigSchema.parse(req.body); + const validatedConfig: DhcpConfiguration = transformDhcpFormToApi(validatedFormData); + + logger.info('DHCP Configuration validated successfully', { + subnets: validatedConfig.subnets.length, + hostReservations: validatedConfig.hostReservations.length, + globalOptions: validatedConfig.globalOptions?.length || 0 + }); + + // Ensure directories exist + await ensureDirectoryExists(DHCPD_CONF_DIR); + await ensureDirectoryExists(DHCPD_BACKUP_DIR); + + // Generate configuration content + const dhcpdConf = generateDhcpdConf(validatedConfig); + + // Write configuration with backup + await writeFileWithBackup(DHCPD_CONF_PATH, dhcpdConf, { + writeJsonVersion: true, + jsonGenerator: () => JSON.stringify(validatedConfig, null, 2) + }); + + // Validate configuration syntax + await validateDhcpConfigSyntax(); + + // Reload service if enabled + if (validatedConfig.dhcpServerStatus) { + try { + await reloadDhcpService(); + } catch (error) { + logger.warn('Service reload failed, but configuration was saved'); + return res.status(207).json({ + success: true, + message: 'DHCP configuration updated successfully, but service reload failed', + data: validatedConfig, + warning: (error as Error).message + }); + } + } + + logger.info('DHCP configuration update completed successfully'); + res.status(200).json({ + success: true, + message: 'DHCP configuration updated successfully', + data: validatedConfig + }); + + } catch (error) { + if (error instanceof ZodError) { + logger.warn('DHCP configuration validation error:', error.errors); + return res.status(400).json({ + success: false, + message: 'Validation Error', + errors: error.errors + }); + } + + logger.error('Error updating DHCP configuration:', error); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : 'Failed to update DHCP configuration' + }); + } +}; + +// Get current DHCP configuration +export const getCurrentDhcpConfiguration = async (req: AuthRequest, res: Response) => { + try { + logger.info('Fetching current DHCP configuration'); + + const confJsonPath = `${DHCPD_CONF_PATH}.json`; + + // Check service status + let serviceRunning = false; + try { + serviceRunning = await serviceManager.status('dhcpd'); + } catch (error) { + logger.warn('Failed to check DHCP service status:', error); + } + + // Try to read existing configuration + try { + const configData = await readFile(confJsonPath, 'utf8'); + const config: DhcpConfiguration = JSON.parse(configData); + config.dhcpServerStatus = serviceRunning; + + logger.info('DHCP configuration loaded from file'); + res.status(200).json({ + success: true, + message: 'Current DHCP configuration loaded successfully', + data: config + }); + } catch (error) { + logger.info('No existing DHCP configuration found, returning default'); + + // Return default configuration if none exists + const defaultConfig: DhcpConfiguration = { + dhcpServerStatus: serviceRunning, + domainName: 'local', + domainNameServers: ['8.8.8.8', '8.8.4.4'], + defaultLeaseTime: 86400, + maxLeaseTime: 604800, + authoritative: true, + ddnsUpdateStyle: 'none', + subnets: [{ + id: crypto.randomUUID(), + network: '192.168.1.0', + netmask: '255.255.255.0', + range: { + start: '192.168.1.100', + end: '192.168.1.200' + }, + defaultGateway: '192.168.1.1', + domainNameServers: ['8.8.8.8', '8.8.4.4'] + }], + hostReservations: [], + globalOptions: [] + }; + + res.status(200).json({ + success: true, + message: 'Default DHCP configuration returned', + data: defaultConfig + }); + } + } catch (error) { + logger.error('Error getting DHCP configuration:', error); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : 'Failed to get DHCP configuration' + }); + } +}; + +// Validate DHCP configuration without saving +export const validateDhcpConfiguration = async (req: AuthRequest, res: Response) => { + try { + logger.info('Validating DHCP configuration'); + + // Validate request body + const validatedFormData: DhcpConfigFormValues = dhcpConfigSchema.parse(req.body); + const validatedConfig: DhcpConfiguration = transformDhcpFormToApi(validatedFormData); + + // Generate configuration content for validation + const dhcpdConf = generateDhcpdConf(validatedConfig); + + // In production, test the configuration syntax + if (isProd) { + const tempFile = `/tmp/dhcpd-test-${Date.now()}.conf`; + try { + await writeFile(tempFile, dhcpdConf, 'utf8'); + await execAsync(`dhcpd -t -cf ${tempFile}`); + await fs.promises.unlink(tempFile); + } catch (error) { + try { + await fs.promises.unlink(tempFile); + } catch {} + throw new Error(`Configuration syntax error: ${(error as Error).message}`); + } + } + + logger.info('DHCP configuration validation successful'); + res.status(200).json({ + success: true, + message: 'DHCP configuration is valid', + valid: true, + data: validatedConfig + }); + + } catch (error) { + if (error instanceof ZodError) { + logger.warn('DHCP configuration validation error:', error.errors); + return res.status(400).json({ + success: false, + message: 'Validation Error', + valid: false, + errors: error.errors + }); + } + + logger.error('Error validating DHCP configuration:', error); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : 'Failed to validate DHCP configuration', + valid: false + }); + } +}; + +// Get DHCP service status +export const getDhcpServiceStatus = async (req: AuthRequest, res: Response) => { + try { + logger.info('Checking DHCP service status'); + + const isRunning = await serviceManager.status('dhcpd'); + const detailedStatus = await serviceManager.getDetailedStatus('dhcpd'); + + const status: DhcpServiceResponse = { + service: 'dhcpd', + status: isRunning ? 'running' : 'stopped', + message: detailedStatus + }; + + logger.info(`DHCP service status: ${status.status}`); + res.status(200).json({ + success: true, + data: status, + message: `DHCP service is ${status.status}` + }); + + } catch (error) { + logger.error('Error checking DHCP service status:', error); + + const status: DhcpServiceResponse = { + service: 'dhcpd', + status: 'unknown', + message: error instanceof Error ? error.message : 'Failed to check service status' + }; + + res.status(500).json({ + success: false, + data: status, + message: 'Failed to check DHCP service status' + }); + } +}; + +// Control DHCP service (start/stop/restart) +export const controlDhcpService = async (req: AuthRequest, res: Response) => { + try { + const { action } = req.params; + const validActions = ['start', 'stop', 'restart', 'reload', 'status']; + + if (!validActions.includes(action)) { + return res.status(400).json({ + success: false, + message: `Invalid action: ${action}. Valid actions are: ${validActions.join(', ')}` + }); + } + + logger.info(`Executing DHCP service action: ${action}`); + + let result: string; + let isRunning: boolean; + + switch (action) { + case 'start': + result = await serviceManager.start('dhcpd'); + isRunning = await serviceManager.status('dhcpd'); + break; + case 'stop': + result = await serviceManager.stop('dhcpd'); + isRunning = await serviceManager.status('dhcpd'); + break; + case 'restart': + case 'reload': + result = await serviceManager.restart('dhcpd'); + isRunning = await serviceManager.status('dhcpd'); + break; + case 'status': + result = await serviceManager.getDetailedStatus('dhcpd'); + isRunning = await serviceManager.status('dhcpd'); + break; + default: + throw new Error(`Unsupported action: ${action}`); + } + + const status: DhcpServiceResponse = { + service: 'dhcpd', + status: isRunning ? 'running' : 'stopped', + message: result + }; + + logger.info(`DHCP service ${action} completed: ${status.status}`); + res.status(200).json({ + success: true, + data: status, + message: `DHCP service ${action} executed successfully` + }); + + } catch (error) { + logger.error(`Error executing DHCP service action ${req.params.action}:`, error); + + const status: DhcpServiceResponse = { + service: 'dhcpd', + status: 'failed', + message: error instanceof Error ? error.message : `Failed to ${req.params.action} DHCP service` + }; + + res.status(500).json({ + success: false, + data: status, + message: `Failed to ${req.params.action} DHCP service` + }); + } +}; \ No newline at end of file diff --git a/apps/backend/src/routes/dhcpRoutes.ts b/apps/backend/src/routes/dhcpRoutes.ts new file mode 100644 index 0000000..3f78815 --- /dev/null +++ b/apps/backend/src/routes/dhcpRoutes.ts @@ -0,0 +1,28 @@ +import express, { Router } from 'express'; +import { protect } from '../middlewares/authMiddleware'; +import { + getCurrentDhcpConfiguration, + updateDhcpConfiguration, + validateDhcpConfiguration, + getDhcpServiceStatus, + controlDhcpService +} from '../controllers/dhcpController'; + +const router: Router = express.Router(); + +// Get current DHCP configuration +router.get('/config', getCurrentDhcpConfiguration); + +// Update DHCP configuration (protected) +router.put('/config', protect, updateDhcpConfiguration); + +// Validate DHCP configuration without saving (protected) +router.post('/validate', protect, validateDhcpConfiguration); + +// Get DHCP service status +router.get('/status', getDhcpServiceStatus); + +// Control DHCP service (start/stop/restart/reload) (protected) +router.post('/service/:action', protect, controlDhcpService); + +export default router; \ No newline at end of file diff --git a/apps/ui/src/features/configuration/dhcp/DHCPConfig.tsx b/apps/ui/src/features/configuration/dhcp/DHCPConfig.tsx index de80baf..d21bdb7 100644 --- a/apps/ui/src/features/configuration/dhcp/DHCPConfig.tsx +++ b/apps/ui/src/features/configuration/dhcp/DHCPConfig.tsx @@ -1,62 +1,675 @@ +import * as React from 'react'; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useForm, useFieldArray } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from '@/components/ui/form'; +import { toast } from '@/hooks/use-toast'; +import { PlusCircle, Trash2, Network, Server, Users, Settings } from 'lucide-react'; +import { dhcpConfigSchema, transformDhcpApiToForm, type DhcpConfigFormValues } from '@server-manager/shared/validators'; +import { getDhcpConfigurationAPI, updateDhcpConfigurationAPI, getDhcpServiceStatusAPI, controlDhcpServiceAPI } from "@/lib/api/dhcp"; +import { v4 as uuidv4 } from 'uuid'; export function DHCPConfig() { - return ( -
-
-
-

DHCP Server Status

-

Enable or disable the DHCP server

+ const [isLoading, setIsLoading] = React.useState(true); + const [isSaving, setIsSaving] = React.useState(false); + const [serviceStatus, setServiceStatus] = React.useState<'running' | 'stopped' | 'unknown'>('unknown'); + + const form = useForm({ + resolver: zodResolver(dhcpConfigSchema), + defaultValues: { + dhcpServerStatus: false, + domainName: 'local', + domainNameServers: '8.8.8.8, 8.8.4.4', + defaultLeaseTime: '86400', + maxLeaseTime: '604800', + authoritative: true, + ddnsUpdateStyle: 'none' as const, + subnets: [], + hostReservations: [], + globalOptions: [] + } + }); + + const { fields: subnetFields, append: appendSubnet, remove: removeSubnet } = useFieldArray({ + control: form.control, + name: 'subnets' + }); + + const { fields: hostFields, append: appendHost, remove: removeHost } = useFieldArray({ + control: form.control, + name: 'hostReservations' + }); + + const { fields: optionFields, append: appendOption, remove: removeOption } = useFieldArray({ + control: form.control, + name: 'globalOptions' + }); + + // Load existing configuration and service status + React.useEffect(() => { + const loadConfiguration = async () => { + try { + const [configResponse, statusResponse] = await Promise.all([ + getDhcpConfigurationAPI(), + getDhcpServiceStatusAPI().catch(() => ({ data: { status: 'unknown' } })) + ]); + + const formData = transformDhcpApiToForm(configResponse.data); + form.reset(formData as any); // Type coercion for form reset + setServiceStatus(statusResponse.data.status as any); + } catch (error) { + console.error('Failed to load DHCP configuration:', error); + toast({ + title: "Error", + description: "Failed to load DHCP configuration", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + loadConfiguration(); + }, [form]); + + // Submit handler + const onSubmit = async (data: DhcpConfigFormValues) => { + setIsSaving(true); + try { + await updateDhcpConfigurationAPI(data); + toast({ + title: "Success", + description: "DHCP configuration updated successfully", + }); + + // Refresh service status + try { + const statusResponse = await getDhcpServiceStatusAPI(); + setServiceStatus(statusResponse.data.status as any); + } catch (error) { + console.warn('Failed to refresh service status:', error); + } + } catch (error: any) { + toast({ + title: "Error", + description: error?.data?.message || "Failed to update DHCP configuration", + variant: "destructive", + }); + } finally { + setIsSaving(false); + } + }; + + // Add new subnet + const addSubnet = () => { + appendSubnet({ + id: uuidv4(), + network: '192.168.1.0', + netmask: '255.255.255.0', + rangeStart: '192.168.1.100', + rangeEnd: '192.168.1.200', + defaultGateway: '192.168.1.1', + domainNameServers: '8.8.8.8, 8.8.4.4', + broadcastAddress: '192.168.1.255', + subnetMask: '255.255.255.0' + }); + }; + + // Add new host reservation + const addHostReservation = () => { + appendHost({ + id: uuidv4(), + hostname: '', + macAddress: '', + fixedAddress: '' + }); + }; + + // Add new global option + const addGlobalOption = () => { + appendOption({ + id: uuidv4(), + name: '', + value: '' + }); + }; + + // Control service + const handleServiceAction = async (action: string) => { + try { + await controlDhcpServiceAPI(action); + const statusResponse = await getDhcpServiceStatusAPI(); + setServiceStatus(statusResponse.data.status as any); + toast({ + title: "Success", + description: `DHCP service ${action} completed successfully`, + }); + } catch (error: any) { + toast({ + title: "Error", + description: error?.data?.message || `Failed to ${action} DHCP service`, + variant: "destructive", + }); + } + }; + + if (isLoading) { + return ( +
+
+
+

Loading DHCP configuration...

-
- -
-
-
- - -
-
- - -
-
-
-
- - + ); + } + + return ( +
+ +
+
+

DHCP Configuration

+

Configure your DHCP server settings

-
- - +
+
+ Service: {serviceStatus} +
+ {serviceStatus === 'stopped' && ( + + )} + {serviceStatus === 'running' && ( + + )}
-
- - -
-
- - -
- {Array.from({ length: 2 }).map((_, i) => ( -
- - - + + + + General + Subnets + Host Reservations + Global Options + + + + + + + + DHCP Server Settings + + + + ( + +
+ DHCP Server Status + + Enable or disable the DHCP server + +
+ + + +
+ )} + /> + +
+ ( + + Domain Name + + + + + + )} + /> + + ( + + DNS Servers + + + + Comma-separated list of DNS servers + + + )} + />
- ))} + +
+ ( + + Default Lease Time (seconds) + + + + Default: 86400 (24 hours) + + + )} + /> + + ( + + Max Lease Time (seconds) + + + + Default: 604800 (7 days) + + + )} + /> +
+ +
+ ( + +
+ Authoritative Server + + Act as the authoritative DHCP server for this network + +
+ + + +
+ )} + /> + + ( + + DDNS Update Style + + + + )} + /> +
+
+
+
+ + +
+

Network Subnets

+
- + + {subnetFields.map((subnet, index) => ( + + +
+ + + Subnet {index + 1} + + +
+
+ +
+ ( + + Network Address + + + + + + )} + /> + + ( + + Subnet Mask + + + + + + )} + /> +
+ +
+ ( + + Range Start + + + + + + )} + /> + + ( + + Range End + + + + + + )} + /> +
+ +
+ ( + + Default Gateway + + + + + + )} + /> + + ( + + DNS Servers + + + + + + )} + /> +
+
+
+ ))} + + {subnetFields.length === 0 && ( + + + +

No subnets configured

+ +
+
+ )} +
+ + +
+

Static Host Reservations

+ +
+ + {hostFields.map((host, index) => ( + + +
+ + + Host Reservation {index + 1} + + +
+
+ +
+ ( + + Hostname + + + + + + )} + /> + + ( + + MAC Address + + + + + + )} + /> + + ( + + Fixed IP Address + + + + + + )} + /> +
+
+
+ ))} + + {hostFields.length === 0 && ( + + + +

No host reservations configured

+ +
+
+ )} +
+ + +
+

Global DHCP Options

+ +
+ + {optionFields.map((option, index) => ( + + +
+ + + Option {index + 1} + + +
+
+ +
+ ( + + Option Name + + + + + + )} + /> + + ( + + Option Value + + + + + + )} + /> +
+
+
+ ))} + + {optionFields.length === 0 && ( + + + +

No global options configured

+ +
+
+ )} +
+
+ +
+ +
-
- -
+ + ); } \ No newline at end of file diff --git a/apps/ui/src/lib/api/dhcp.ts b/apps/ui/src/lib/api/dhcp.ts new file mode 100644 index 0000000..611df61 --- /dev/null +++ b/apps/ui/src/lib/api/dhcp.ts @@ -0,0 +1,137 @@ +import type { DhcpConfiguration, DhcpConfigFormValues, DhcpUpdateResponse, DhcpServiceResponse } from '@server-manager/shared'; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api'; + +// Interface for the API response when fetching current configuration +export interface DhcpConfigResponse { + message: string; + data: DhcpConfiguration; + success: boolean; +} + +// Helper function for auth headers +function getAuthHeaders() { + const token = localStorage.getItem('authToken'); + return { + 'Content-Type': 'application/json', + ...(token && { 'Authorization': `Bearer ${token}` }) + }; +} + +// Handle API errors consistently +const handleApiError = (error: any, operation: string): never => { + console.error(`Failed to ${operation}:`, error); + + if (error.status && error.data) { + throw error; + } + + throw { + status: null, + data: { + message: `Network error or failed to parse response while trying to ${operation}.`, + errors: [] + } + }; +}; + +// Get current DHCP configuration +export const getDhcpConfigurationAPI = async (): Promise => { + try { + const response = await fetch(`${API_BASE_URL}/dhcp/config`, { + method: 'GET', + headers: getAuthHeaders(), + }); + + const responseData: DhcpConfigResponse = await response.json(); + + if (!response.ok) { + console.error('API Error:', responseData); + throw { status: response.status, data: responseData }; + } + + return responseData; + } catch (error: any) { + throw handleApiError(error, 'get DHCP configuration'); + } +}; + +// Update DHCP configuration +export const updateDhcpConfigurationAPI = async (formData: DhcpConfigFormValues): Promise => { + try { + const response = await fetch(`${API_BASE_URL}/dhcp/config`, { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify(formData), + }); + + const responseData: DhcpUpdateResponse = await response.json(); + + if (!response.ok) { + console.error('API Error:', responseData); + throw { status: response.status, data: responseData }; + } + + return responseData; + } catch (error: any) { + throw handleApiError(error, 'update DHCP configuration'); + } +}; + +// Validate DHCP configuration without saving +export const validateDhcpConfigurationAPI = async (formData: DhcpConfigFormValues): Promise => { + try { + const response = await fetch(`${API_BASE_URL}/dhcp/validate`, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify(formData), + }); + + const responseData: DhcpUpdateResponse = await response.json(); + + if (!response.ok) { + console.error('API Error:', responseData); + throw { status: response.status, data: responseData }; + } + + return responseData; + } catch (error: any) { + throw handleApiError(error, 'validate DHCP configuration'); + } +}; + +// Get DHCP service status +export const getDhcpServiceStatusAPI = async (): Promise<{ data: DhcpServiceResponse }> => { + try { + const response = await fetch(`${API_BASE_URL}/dhcp/status`, { + method: 'GET', + headers: getAuthHeaders(), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch DHCP service status: ${response.statusText}`); + } + + return response.json(); + } catch (error: any) { + throw handleApiError(error, 'get DHCP service status'); + } +}; + +// Control DHCP service (start/stop/restart) +export const controlDhcpServiceAPI = async (action: string): Promise<{ data: DhcpServiceResponse }> => { + try { + const response = await fetch(`${API_BASE_URL}/dhcp/service/${action}`, { + method: 'POST', + headers: getAuthHeaders(), + }); + + if (!response.ok) { + throw new Error(`Failed to ${action} DHCP service: ${response.statusText}`); + } + + return response.json(); + } catch (error: any) { + throw handleApiError(error, `${action} DHCP service`); + } +}; \ No newline at end of file diff --git a/apps/ui/src/lib/api/index.ts b/apps/ui/src/lib/api/index.ts index 2313168..6c6c718 100644 --- a/apps/ui/src/lib/api/index.ts +++ b/apps/ui/src/lib/api/index.ts @@ -24,5 +24,6 @@ export const api: { export * from './services'; export * from './users'; export * from './systemMetrics'; +export * from './dhcp'; export default api; \ No newline at end of file diff --git a/docs/implementation-plan/dhcp-config-implementation-plan.md b/docs/implementation-plan/dhcp-config-implementation-plan.md new file mode 100644 index 0000000..b685780 --- /dev/null +++ b/docs/implementation-plan/dhcp-config-implementation-plan.md @@ -0,0 +1,1214 @@ +# DHCP Configuration Implementation Plan + +Based on your existing DNS and HTTP configuration patterns, here's a comprehensive plan to implement DHCP configuration management following the same architecture and principles. + +## ๐Ÿ“‹ Overview + +The DHCP implementation will follow the exact same pattern as your existing DNS and HTTP configurations: +- **Shared types and validators** in `packages/shared/` +- **Backend controller and routes** in `apps/backend/` +- **Frontend UI components** in `apps/ui/` +- **DHCP service integration** with existing service management + +## ๐Ÿ—๏ธ Implementation Structure + +### 1. **Shared Package Extensions** (`packages/shared/`) + +#### A. Type Definitions (`packages/shared/src/types/dhcp.ts`) + +```typescript +// DHCP Configuration Types +export interface DhcpServerConfig { + dhcpServerStatus: boolean; + domainName?: string; + domainNameServers?: string[]; + defaultLeaseTime?: number; + maxLeaseTime?: number; + authoritative?: boolean; + ddnsUpdateStyle?: 'interim' | 'standard' | 'none'; + logFacility?: string; +} + +export interface DhcpSubnet { + id: string; + network: string; + netmask: string; + range?: { + start: string; + end: string; + }; + defaultGateway?: string; + domainNameServers?: string[]; + broadcastAddress?: string; + subnetMask?: string; + pools?: DhcpPool[]; + options?: DhcpOption[]; +} + +export interface DhcpPool { + id: string; + range: { + start: string; + end: string; + }; + allowMembers?: string[]; + denyMembers?: string[]; + options?: DhcpOption[]; +} + +export interface DhcpHostReservation { + id: string; + hostname: string; + macAddress: string; + fixedAddress: string; + options?: DhcpOption[]; +} + +export interface DhcpOption { + id: string; + name: string; + value: string; + code?: number; +} + +export interface DhcpConfiguration extends DhcpServerConfig { + subnets: DhcpSubnet[]; + hostReservations: DhcpHostReservation[]; + globalOptions?: DhcpOption[]; +} + +// Form/UI Types +export interface DhcpConfigFormValues { + dhcpServerStatus: boolean; + domainName: string; + domainNameServers: string; // comma-separated + defaultLeaseTime: string; + maxLeaseTime: string; + authoritative: boolean; + ddnsUpdateStyle: string; + + subnets: Array<{ + id: string; + network: string; + netmask: string; + rangeStart: string; + rangeEnd: string; + defaultGateway: string; + domainNameServers: string; + broadcastAddress: string; + subnetMask: string; + }>; + + hostReservations: Array<{ + id: string; + hostname: string; + macAddress: string; + fixedAddress: string; + }>; + + globalOptions: Array<{ + id: string; + name: string; + value: string; + }>; +} + +// API Response Types +export interface DhcpUpdateResponse { + success: boolean; + message: string; + data?: DhcpConfiguration; + errors?: Array<{ + path: (string | number)[]; + message: string; + }>; +} + +export interface DhcpServiceResponse { + service: 'dhcpd'; + status: 'running' | 'stopped' | 'failed' | 'unknown'; + message: string; + configTest?: { + valid: boolean; + errors?: string[]; + }; +} +``` + +#### B. Validators (`packages/shared/src/validators/dhcpFormValidator.ts`) + +```typescript +import { z } from 'zod'; + +// Validation helpers +export const isValidIpAddress = (ip: string): boolean => { + const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + if (!ipv4Regex.test(ip)) return false; + + const octets = ip.split('.'); + return octets.every(octet => { + const num = parseInt(octet, 10); + return num >= 0 && num <= 255; + }); +}; + +export const isValidMacAddress = (mac: string): boolean => { + const macRegex = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/; + return macRegex.test(mac); +}; + +export const isValidNetmask = (netmask: string): boolean => { + if (!isValidIpAddress(netmask)) return false; + + // Convert to binary and check if it's a valid subnet mask + const octets = netmask.split('.').map(octet => parseInt(octet, 10)); + const binary = octets.map(octet => octet.toString(2).padStart(8, '0')).join(''); + + // Valid subnet mask should have consecutive 1s followed by consecutive 0s + return /^1*0*$/.test(binary); +}; + +// Schema for DHCP options +export const dhcpOptionSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1, "Option name is required"), + value: z.string().min(1, "Option value is required"), + code: z.number().int().min(1).max(254).optional(), +}); + +// Schema for host reservations +export const hostReservationSchema = z.object({ + id: z.string().uuid(), + hostname: z.string().min(1, "Hostname is required"), + macAddress: z.string().refine(isValidMacAddress, { + message: "Invalid MAC address format" + }), + fixedAddress: z.string().refine(isValidIpAddress, { + message: "Invalid IP address" + }), +}); + +// Schema for subnets +export const subnetSchema = z.object({ + id: z.string().uuid(), + network: z.string().refine(isValidIpAddress, { + message: "Invalid network address" + }), + netmask: z.string().refine(isValidNetmask, { + message: "Invalid subnet mask" + }), + rangeStart: z.string().refine(isValidIpAddress, { + message: "Invalid start IP address" + }), + rangeEnd: z.string().refine(isValidIpAddress, { + message: "Invalid end IP address" + }), + defaultGateway: z.string().refine(isValidIpAddress, { + message: "Invalid gateway IP address" + }), + domainNameServers: z.string(), + broadcastAddress: z.string().refine(isValidIpAddress, { + message: "Invalid broadcast address" + }).optional(), + subnetMask: z.string().refine(isValidNetmask, { + message: "Invalid subnet mask" + }).optional(), +}).superRefine((data, ctx) => { + // Validate IP range + const startOctets = data.rangeStart.split('.').map(Number); + const endOctets = data.rangeEnd.split('.').map(Number); + + for (let i = 0; i < 4; i++) { + if (startOctets[i] > endOctets[i]) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['rangeEnd'], + message: 'End IP must be greater than start IP' + }); + break; + } else if (startOctets[i] < endOctets[i]) { + break; + } + } +}); + +// Main DHCP configuration schema +export const dhcpConfigSchema = z.object({ + dhcpServerStatus: z.boolean(), + domainName: z.string().min(1, "Domain name is required"), + domainNameServers: z.string().min(1, "At least one DNS server is required"), + defaultLeaseTime: z.string().regex(/^\d+$/, { + message: "Default lease time must be a number" + }), + maxLeaseTime: z.string().regex(/^\d+$/, { + message: "Max lease time must be a number" + }), + authoritative: z.boolean().default(true), + ddnsUpdateStyle: z.enum(['interim', 'standard', 'none']).default('none'), + + subnets: z.array(subnetSchema).min(1, "At least one subnet is required"), + hostReservations: z.array(hostReservationSchema).default([]), + globalOptions: z.array(dhcpOptionSchema).default([]), +}); + +export type DhcpConfigFormValues = z.infer; +``` + +#### C. Transformers (`packages/shared/src/validators/dhcpTransformers.ts`) + +```typescript +import type { DhcpConfiguration, DhcpConfigFormValues } from '../types/dhcp'; + +// Transform UI form data to API format +export const transformDhcpFormToApi = (formData: DhcpConfigFormValues): DhcpConfiguration => { + return { + dhcpServerStatus: formData.dhcpServerStatus, + domainName: formData.domainName, + domainNameServers: formData.domainNameServers.split(',').map(s => s.trim()), + defaultLeaseTime: parseInt(formData.defaultLeaseTime), + maxLeaseTime: parseInt(formData.maxLeaseTime), + authoritative: formData.authoritative, + ddnsUpdateStyle: formData.ddnsUpdateStyle as any, + + subnets: formData.subnets.map(subnet => ({ + id: subnet.id, + network: subnet.network, + netmask: subnet.netmask, + range: { + start: subnet.rangeStart, + end: subnet.rangeEnd + }, + defaultGateway: subnet.defaultGateway, + domainNameServers: subnet.domainNameServers.split(',').map(s => s.trim()), + broadcastAddress: subnet.broadcastAddress, + subnetMask: subnet.subnetMask, + pools: [], + options: [] + })), + + hostReservations: formData.hostReservations.map(host => ({ + id: host.id, + hostname: host.hostname, + macAddress: host.macAddress, + fixedAddress: host.fixedAddress, + options: [] + })), + + globalOptions: formData.globalOptions.map(option => ({ + id: option.id, + name: option.name, + value: option.value + })) + }; +}; + +// Transform API data to UI form format +export const transformDhcpApiToForm = (apiData: DhcpConfiguration): DhcpConfigFormValues => { + return { + dhcpServerStatus: apiData.dhcpServerStatus, + domainName: apiData.domainName || '', + domainNameServers: (apiData.domainNameServers || []).join(', '), + defaultLeaseTime: (apiData.defaultLeaseTime || 86400).toString(), + maxLeaseTime: (apiData.maxLeaseTime || 604800).toString(), + authoritative: apiData.authoritative !== false, + ddnsUpdateStyle: apiData.ddnsUpdateStyle || 'none', + + subnets: apiData.subnets.map(subnet => ({ + id: subnet.id, + network: subnet.network, + netmask: subnet.netmask, + rangeStart: subnet.range?.start || '', + rangeEnd: subnet.range?.end || '', + defaultGateway: subnet.defaultGateway || '', + domainNameServers: (subnet.domainNameServers || []).join(', '), + broadcastAddress: subnet.broadcastAddress || '', + subnetMask: subnet.subnetMask || '' + })), + + hostReservations: apiData.hostReservations.map(host => ({ + id: host.id, + hostname: host.hostname, + macAddress: host.macAddress, + fixedAddress: host.fixedAddress + })), + + globalOptions: (apiData.globalOptions || []).map(option => ({ + id: option.id, + name: option.name, + value: option.value + })) + }; +}; +``` + +### 2. **Backend Implementation** (`apps/backend/`) + +#### A. DHCP Controller (`apps/backend/src/controllers/dhcpController.ts`) + +```typescript +import type { Response } from 'express'; +import type { AuthRequest } from '../middlewares/authMiddleware'; +import { dhcpConfigSchema, transformDhcpFormToApi, type DhcpConfigFormValues } from '@server-manager/shared/validators'; +import type { DhcpConfiguration, DhcpServiceResponse } from '@server-manager/shared'; +import { ZodError } from 'zod'; +import { writeFile, readFile, mkdir } from 'fs/promises'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import { existsSync } from 'fs'; +import crypto from 'crypto'; +import fs from 'fs'; +import logger from '../lib/logger.js'; +import { ServiceManager } from '../lib/ServiceManager.js'; + +const execAsync = promisify(exec); +const serviceManager = new ServiceManager(); + +// Configuration paths +const isProd = process.env.NODE_ENV === 'production'; +const DHCPD_CONF_DIR = isProd ? '/etc/dhcp' : './test/dhcp/config'; +const DHCPD_CONF_PATH = isProd ? '/etc/dhcp/dhcpd.conf' : './test/dhcp/config/dhcpd.conf'; +const DHCPD_BACKUP_DIR = isProd ? '/etc/dhcp/backups' : './test/dhcp/backups'; +const DHCPD_LEASES_PATH = isProd ? '/var/lib/dhcpd/dhcpd.leases' : './test/dhcp/dhcpd.leases'; + +// Generate DHCP configuration +const generateDhcpdConf = (config: DhcpConfiguration): string => { + let conf = `# DHCP Server Configuration +# Generated by Server Manager on ${new Date().toISOString()} + +`; + + // Global options + if (config.domainName) { + conf += `option domain-name "${config.domainName}";\n`; + } + + if (config.domainNameServers?.length) { + conf += `option domain-name-servers ${config.domainNameServers.join(', ')};\n`; + } + + conf += `default-lease-time ${config.defaultLeaseTime || 86400};\n`; + conf += `max-lease-time ${config.maxLeaseTime || 604800};\n`; + + if (config.authoritative) { + conf += `authoritative;\n`; + } + + if (config.ddnsUpdateStyle && config.ddnsUpdateStyle !== 'none') { + conf += `ddns-update-style ${config.ddnsUpdateStyle};\n`; + } + + conf += '\n'; + + // Global custom options + if (config.globalOptions?.length) { + config.globalOptions.forEach(option => { + conf += `option ${option.name} ${option.value};\n`; + }); + conf += '\n'; + } + + // Subnets + config.subnets.forEach(subnet => { + conf += `subnet ${subnet.network} netmask ${subnet.netmask} {\n`; + + if (subnet.range) { + conf += ` range ${subnet.range.start} ${subnet.range.end};\n`; + } + + if (subnet.defaultGateway) { + conf += ` option routers ${subnet.defaultGateway};\n`; + } + + if (subnet.domainNameServers?.length) { + conf += ` option domain-name-servers ${subnet.domainNameServers.join(', ')};\n`; + } + + if (subnet.broadcastAddress) { + conf += ` option broadcast-address ${subnet.broadcastAddress};\n`; + } + + conf += `}\n\n`; + }); + + // Host reservations + config.hostReservations.forEach(host => { + conf += `host ${host.hostname} {\n`; + conf += ` hardware ethernet ${host.macAddress};\n`; + conf += ` fixed-address ${host.fixedAddress};\n`; + conf += `}\n\n`; + }); + + return conf; +}; + +// Update DHCP configuration +export const updateDhcpConfiguration = async (req: AuthRequest, res: Response) => { + try { + const validatedFormData: DhcpConfigFormValues = dhcpConfigSchema.parse(req.body); + const validatedConfig: DhcpConfiguration = transformDhcpFormToApi(validatedFormData); + + logger.info('Received DHCP Configuration:', { config: JSON.stringify(validatedConfig) }); + + // Ensure directories exist + await ensureDirectoryExists(DHCPD_CONF_DIR); + await ensureDirectoryExists(DHCPD_BACKUP_DIR); + + // Generate configuration content + const dhcpdConf = generateDhcpdConf(validatedConfig); + + // Write configuration with backup + await writeFileWithBackup(DHCPD_CONF_PATH, dhcpdConf, { + writeJsonVersion: true, + jsonGenerator: () => validatedConfig + }); + + // Validate configuration + if (isProd) { + try { + await execAsync(`dhcpd -t -cf ${DHCPD_CONF_PATH}`); + } catch (error) { + throw new Error(`DHCP configuration validation failed: ${(error as Error).message}`); + } + } + + // Reload service if enabled + if (validatedConfig.dhcpServerStatus) { + try { + await serviceManager.restart('dhcpd'); + } catch (error) { + return res.status(500).json({ + message: 'Failed to reload DHCP server', + error: (error as Error).message, + note: 'Configuration files were updated but service reload failed' + }); + } + } + + res.status(200).json({ + message: 'DHCP configuration updated successfully', + data: validatedConfig + }); + + } catch (error) { + if (error instanceof ZodError) { + return res.status(400).json({ + message: 'Validation Error', + errors: error.errors + }); + } + + logger.error('Error updating DHCP configuration:', error); + res.status(500).json({ + message: error instanceof Error ? error.message : 'Failed to update DHCP configuration' + }); + } +}; + +// Get current DHCP configuration +export const getCurrentDhcpConfiguration = async (req: AuthRequest, res: Response) => { + try { + const confJsonPath = `${DHCPD_CONF_PATH}.json`; + + // Check service status + let serviceRunning = false; + if (isProd) { + try { + const { stdout } = await execAsync('systemctl is-active dhcpd'); + serviceRunning = stdout.trim() === 'active'; + } catch (error) { + logger.info('DHCP service is not running'); + } + } + + // Try to read existing configuration + try { + const configData = await readFile(confJsonPath, 'utf8'); + const config = JSON.parse(configData); + config.dhcpServerStatus = serviceRunning; + + res.status(200).json({ + message: 'Current DHCP configuration loaded successfully', + data: config + }); + } catch (error) { + // Return default configuration if none exists + const defaultConfig: DhcpConfiguration = { + dhcpServerStatus: serviceRunning, + domainName: 'local', + domainNameServers: ['8.8.8.8', '8.8.4.4'], + defaultLeaseTime: 86400, + maxLeaseTime: 604800, + authoritative: true, + ddnsUpdateStyle: 'none', + subnets: [{ + id: crypto.randomUUID(), + network: '192.168.1.0', + netmask: '255.255.255.0', + range: { + start: '192.168.1.100', + end: '192.168.1.200' + }, + defaultGateway: '192.168.1.1', + domainNameServers: ['8.8.8.8', '8.8.4.4'] + }], + hostReservations: [], + globalOptions: [] + }; + + res.status(200).json({ + message: 'Default DHCP configuration returned', + data: defaultConfig + }); + } + } catch (error) { + logger.error('Error getting DHCP configuration:', error); + res.status(500).json({ + message: error instanceof Error ? error.message : 'Failed to get DHCP configuration' + }); + } +}; + +// ... (additional helper functions similar to DNS/HTTP controllers) +``` + +#### B. DHCP Routes (`apps/backend/src/routes/dhcpRoutes.ts`) + +```typescript +import express from 'express'; +import { protect } from '../middlewares/authMiddleware'; +import { + getCurrentDhcpConfiguration, + updateDhcpConfiguration, + validateDhcpConfiguration, + getDhcpServiceStatus, + controlDhcpService +} from '../controllers/dhcpController'; + +const router = express.Router(); + +// Get current DHCP configuration +router.get('/config', getCurrentDhcpConfiguration); + +// Update DHCP configuration (protected) +router.put('/config', protect, updateDhcpConfiguration); + +// Validate DHCP configuration +router.post('/validate', protect, validateDhcpConfiguration); + +// Get DHCP service status +router.get('/status', getDhcpServiceStatus); + +// Control DHCP service (start/stop/restart) +router.post('/service/:action', protect, controlDhcpService); + +export default router; +``` + +### 3. **Frontend Implementation** (`apps/ui/`) + +#### A. API Client (`apps/ui/src/lib/api/dhcp.ts`) + +```typescript +import type { DhcpConfiguration, DhcpConfigFormValues, DhcpUpdateResponse, DhcpServiceResponse } from '@server-manager/shared'; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001/api'; + +// Get current DHCP configuration +export const getDhcpConfigurationAPI = async (): Promise<{ data: DhcpConfiguration }> => { + const response = await fetch(`${API_BASE_URL}/dhcp/config`, { + method: 'GET', + headers: getAuthHeaders(), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch DHCP configuration: ${response.statusText}`); + } + + return response.json(); +}; + +// Update DHCP configuration +export const updateDhcpConfigurationAPI = async (formData: DhcpConfigFormValues): Promise => { + const response = await fetch(`${API_BASE_URL}/dhcp/config`, { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify(formData), + }); + + const responseData: DhcpUpdateResponse = await response.json(); + + if (!response.ok) { + throw { status: response.status, data: responseData }; + } + + return responseData; +}; + +// Control DHCP service +export const controlDhcpServiceAPI = async (action: string): Promise<{ data: DhcpServiceResponse }> => { + const response = await fetch(`${API_BASE_URL}/dhcp/service/${action}`, { + method: 'POST', + headers: getAuthHeaders(), + }); + + if (!response.ok) { + throw new Error(`Failed to ${action} DHCP service: ${response.statusText}`); + } + + return response.json(); +}; + +// Helper function for auth headers +function getAuthHeaders() { + const token = localStorage.getItem('authToken'); + return { + 'Content-Type': 'application/json', + ...(token && { 'Authorization': `Bearer ${token}` }) + }; +} +``` + +#### B. Main DHCP Component (`apps/ui/src/features/configuration/dhcp/DHCPConfig.tsx`) + +```typescript +import * as React from 'react'; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useForm, useFieldArray } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { toast } from '@/hooks/use-toast'; +import { PlusCircle, Trash2, Network } from 'lucide-react'; +import { dhcpConfigSchema, transformDhcpApiToForm, type DhcpConfigFormValues } from '@server-manager/shared/validators'; +import { getDhcpConfigurationAPI, updateDhcpConfigurationAPI } from "@/lib/api/dhcp"; +import { v4 as uuidv4 } from 'uuid'; + +export function DHCPConfig() { + const [isLoading, setIsLoading] = React.useState(true); + const [isSaving, setIsSaving] = React.useState(false); + + const form = useForm({ + resolver: zodResolver(dhcpConfigSchema), + defaultValues: { + dhcpServerStatus: false, + domainName: 'local', + domainNameServers: '8.8.8.8, 8.8.4.4', + defaultLeaseTime: '86400', + maxLeaseTime: '604800', + authoritative: true, + ddnsUpdateStyle: 'none', + subnets: [], + hostReservations: [], + globalOptions: [] + } + }); + + const { fields: subnetFields, append: appendSubnet, remove: removeSubnet } = useFieldArray({ + control: form.control, + name: 'subnets' + }); + + const { fields: hostFields, append: appendHost, remove: removeHost } = useFieldArray({ + control: form.control, + name: 'hostReservations' + }); + + // Load existing configuration + React.useEffect(() => { + const loadConfiguration = async () => { + try { + const { data } = await getDhcpConfigurationAPI(); + const formData = transformDhcpApiToForm(data); + form.reset(formData); + } catch (error) { + console.error('Failed to load DHCP configuration:', error); + toast({ + title: "Error", + description: "Failed to load DHCP configuration", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + loadConfiguration(); + }, [form]); + + // Submit handler + const onSubmit = async (data: DhcpConfigFormValues) => { + setIsSaving(true); + try { + await updateDhcpConfigurationAPI(data); + toast({ + title: "Success", + description: "DHCP configuration updated successfully", + }); + } catch (error: any) { + toast({ + title: "Error", + description: error?.data?.message || "Failed to update DHCP configuration", + variant: "destructive", + }); + } finally { + setIsSaving(false); + } + }; + + // Add new subnet + const addSubnet = () => { + appendSubnet({ + id: uuidv4(), + network: '192.168.1.0', + netmask: '255.255.255.0', + rangeStart: '192.168.1.100', + rangeEnd: '192.168.1.200', + defaultGateway: '192.168.1.1', + domainNameServers: '8.8.8.8, 8.8.4.4', + broadcastAddress: '192.168.1.255', + subnetMask: '255.255.255.0' + }); + }; + + // Add new host reservation + const addHostReservation = () => { + appendHost({ + id: uuidv4(), + hostname: '', + macAddress: '', + fixedAddress: '' + }); + }; + + if (isLoading) { + return
Loading DHCP configuration...
; + } + + return ( +
+ + + + General Settings + Subnets + Host Reservations + + + + + + + + DHCP Server Settings + + + + ( + +
+ DHCP Server Status +

+ Enable or disable the DHCP server +

+
+ + + +
+ )} + /> + +
+ ( + + Domain Name + + + + + + )} + /> + + ( + + DNS Servers + + + + + + )} + /> +
+ +
+ ( + + Default Lease Time (seconds) + + + + + + )} + /> + + ( + + Max Lease Time (seconds) + + + + + + )} + /> +
+ + ( + +
+ Authoritative Server +

+ Act as the authoritative DHCP server for this network +

+
+ + + +
+ )} + /> +
+
+
+ + +
+

Network Subnets

+ +
+ + {subnetFields.map((subnet, index) => ( + + +
+ Subnet {index + 1} + +
+
+ +
+ ( + + Network Address + + + + + + )} + /> + + ( + + Subnet Mask + + + + + + )} + /> +
+ +
+ ( + + Range Start + + + + + + )} + /> + + ( + + Range End + + + + + + )} + /> +
+ + ( + + Default Gateway + + + + + + )} + /> +
+
+ ))} +
+ + +
+

Static Host Reservations

+ +
+ + {hostFields.map((host, index) => ( + + +
+ Host Reservation {index + 1} + +
+
+ +
+ ( + + Hostname + + + + + + )} + /> + + ( + + MAC Address + + + + + + )} + /> + + ( + + Fixed IP Address + + + + + + )} + /> +
+
+
+ ))} +
+
+ +
+ +
+
+ + ); +} +``` + +## ๐Ÿ”„ Integration Steps + +### 1. **Update Shared Package Exports** +```typescript +// packages/shared/src/index.ts +export { + // ... existing exports + dhcpConfigSchema, + transformDhcpFormToApi, + transformDhcpApiToForm, + isValidIpAddress, + isValidMacAddress, + isValidNetmask +} from './validators'; + +export type { + // ... existing types + DhcpConfiguration, + DhcpConfigFormValues, + DhcpUpdateResponse, + DhcpServiceResponse +} from './types'; +``` + +### 2. **Add DHCP Routes to Backend** +```typescript +// apps/backend/src/app.ts +import dhcpRoutes from './routes/dhcpRoutes'; + +// Add after existing routes +app.use('/api/dhcp', dhcpRoutes); +``` + +### 3. **Update UI Routing** +The DHCP configuration is already accessible via the existing `DHCPConfigView.tsx` page, but you'll need to replace the current placeholder component with the new implementation. + +## ๐Ÿงช Testing Strategy + +### 1. **Unit Tests** +- Validator tests for DHCP configuration schemas +- Transformer tests for form โ†” API data conversion +- IP address and MAC address validation tests + +### 2. **Integration Tests** +- Backend API endpoint tests +- DHCP configuration file generation tests +- Service management integration tests + +### 3. **End-to-End Tests** +- Complete DHCP configuration workflow +- Form validation and error handling +- Service start/stop/restart functionality + +## ๐Ÿ“ฆ Implementation Order + +1. **Phase 1: Shared Package** (Types, Validators, Transformers) +2. **Phase 2: Backend** (Controller, Routes, Service Integration) +3. **Phase 3: Frontend** (API Client, UI Components) +4. **Phase 4: Integration** (Route Registration, Testing) +5. **Phase 5: Documentation** (API docs, User guides) + +## ๐Ÿ”ง Key Implementation Notes + +### DHCP Configuration Features + +The implementation will support the following DHCP server features based on ISC DHCP: + +1. **Global Options** + - Domain name and DNS servers + - Default and maximum lease times + - Authoritative server declaration + - Dynamic DNS update style + +2. **Subnet Declarations** + - Network address and subnet mask + - IP address ranges for dynamic allocation + - Default gateway (routers option) + - Subnet-specific DNS servers + - Broadcast address + +3. **Host Reservations** + - Static IP assignments based on MAC addresses + - Hostname assignments + - Host-specific options + +4. **Advanced Features** (Future Extensions) + - Client classes and conditional assignments + - Pools with allow/deny members + - Custom DHCP options + - Failover configuration + - DHCP relay agent information + +### Configuration File Generation + +The DHCP controller will generate ISC DHCP-compatible configuration files with: + +- Proper syntax validation using `dhcpd -t` +- Automatic backup of existing configurations +- JSON metadata files for easy configuration retrieval +- Development/production mode handling + +### Service Management Integration + +The DHCP implementation integrates seamlessly with the existing service management system: + +- Service status monitoring +- Start/stop/restart operations +- Configuration validation before service reload +- Mock service support for development + +This plan follows the exact same patterns as your existing DNS and HTTP implementations, ensuring consistency and maintainability across your codebase. The DHCP configuration will integrate seamlessly with your existing service management and authentication systems. \ No newline at end of file diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 25e73d4..dcb75f9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -25,5 +25,27 @@ export { transformApiVirtualHostToUi, parsePortString, transformHttpFormToApi, - transformHttpApiToForm + transformHttpApiToForm, + // DHCP validators and transformers + dhcpIsValidIpAddress, + isValidMacAddress, + isValidNetmask, + isValidHostname, + isValidDomainName, + isValidLeaseTime, + isIpInNetwork, + isValidIpRange, + dhcpOptionSchema, + hostReservationSchema, + subnetSchema, + dhcpConfigSchema, + transformDhcpFormToApi, + transformDhcpApiToForm, + dhcpParseStringToArray, + arrayToCommaString, + validateDhcpFormData, + generateDefaultDhcpConfig, + calculateBroadcastAddress, + calculateNetworkAddress, + suggestIpRange } from './validators'; \ No newline at end of file diff --git a/packages/shared/src/types/dhcp.ts b/packages/shared/src/types/dhcp.ts new file mode 100644 index 0000000..1e3adf2 --- /dev/null +++ b/packages/shared/src/types/dhcp.ts @@ -0,0 +1,126 @@ +// DHCP Configuration Types +export interface DhcpServerConfig { + dhcpServerStatus: boolean; + domainName?: string; + domainNameServers?: string[]; + defaultLeaseTime?: number; + maxLeaseTime?: number; + authoritative?: boolean; + ddnsUpdateStyle?: 'interim' | 'standard' | 'none'; + logFacility?: string; +} + +export interface DhcpSubnet { + id: string; + network: string; + netmask: string; + range?: { + start: string; + end: string; + }; + defaultGateway?: string; + domainNameServers?: string[]; + broadcastAddress?: string; + subnetMask?: string; + pools?: DhcpPool[]; + options?: DhcpOption[]; +} + +export interface DhcpPool { + id: string; + range: { + start: string; + end: string; + }; + allowMembers?: string[]; + denyMembers?: string[]; + options?: DhcpOption[]; +} + +export interface DhcpHostReservation { + id: string; + hostname: string; + macAddress: string; + fixedAddress: string; + options?: DhcpOption[]; +} + +export interface DhcpOption { + id: string; + name: string; + value: string; + code?: number; +} + +export interface DhcpConfiguration extends DhcpServerConfig { + subnets: DhcpSubnet[]; + hostReservations: DhcpHostReservation[]; + globalOptions?: DhcpOption[]; +} + +// Form/UI Types +export interface DhcpConfigFormValues { + dhcpServerStatus: boolean; + domainName: string; + domainNameServers: string; // comma-separated + defaultLeaseTime: string; + maxLeaseTime: string; + authoritative: boolean; + ddnsUpdateStyle: string; + + subnets: Array<{ + id: string; + network: string; + netmask: string; + rangeStart: string; + rangeEnd: string; + defaultGateway: string; + domainNameServers: string; + broadcastAddress: string; + subnetMask: string; + }>; + + hostReservations: Array<{ + id: string; + hostname: string; + macAddress: string; + fixedAddress: string; + }>; + + globalOptions: Array<{ + id: string; + name: string; + value: string; + }>; +} + +// API Response Types +export interface DhcpUpdateResponse { + success: boolean; + message: string; + data?: DhcpConfiguration; + errors?: Array<{ + path: (string | number)[]; + message: string; + }>; +} + +export interface DhcpServiceResponse { + service: 'dhcpd'; + status: 'running' | 'stopped' | 'failed' | 'unknown'; + message: string; + configTest?: { + valid: boolean; + errors?: string[]; + }; +} + +export type DhcpServerStatus = 'running' | 'stopped' | 'failed' | 'unknown'; + +export interface DhcpConfigResponse { + success: boolean; + message: string; + data: DhcpConfiguration; +} + +export type DhcpServiceAction = 'start' | 'stop' | 'restart' | 'reload' | 'status'; \ No newline at end of file diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 4cf0205..09e6d8b 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,5 +1,6 @@ // Export all types export * from './api'; +export * from './dhcp'; export * from './dns'; export * from './errors'; export * from './http'; diff --git a/packages/shared/src/validators/__tests__/dhcpValidators.test.ts b/packages/shared/src/validators/__tests__/dhcpValidators.test.ts new file mode 100644 index 0000000..b9a1e3b --- /dev/null +++ b/packages/shared/src/validators/__tests__/dhcpValidators.test.ts @@ -0,0 +1,252 @@ +import { + isValidIpAddress, + isValidMacAddress, + isValidNetmask, + isValidHostname, + isValidDomainName, + isValidLeaseTime, + isIpInNetwork, + isValidIpRange, + dhcpConfigSchema, + subnetSchema, + hostReservationSchema +} from '../dhcpFormValidator'; + +import { + transformDhcpFormToApi, + transformDhcpApiToForm, + generateDefaultDhcpConfig, + calculateBroadcastAddress, + suggestIpRange +} from '../dhcpTransformers'; + +import type { DhcpConfigFormValues } from '../dhcpFormValidator'; + +describe('DHCP Validators', () => { + describe('isValidIpAddress', () => { + it('should validate correct IP addresses', () => { + expect(isValidIpAddress('192.168.1.1')).toBe(true); + expect(isValidIpAddress('10.0.0.1')).toBe(true); + expect(isValidIpAddress('172.16.254.1')).toBe(true); + expect(isValidIpAddress('8.8.8.8')).toBe(true); + }); + + it('should reject invalid IP addresses', () => { + expect(isValidIpAddress('256.1.1.1')).toBe(false); + expect(isValidIpAddress('192.168.1')).toBe(false); + expect(isValidIpAddress('192.168.1.1.1')).toBe(false); + expect(isValidIpAddress('not.an.ip')).toBe(false); + expect(isValidIpAddress('')).toBe(false); + }); + }); + + describe('isValidMacAddress', () => { + it('should validate correct MAC addresses', () => { + expect(isValidMacAddress('00:11:22:33:44:55')).toBe(true); + expect(isValidMacAddress('AA:BB:CC:DD:EE:FF')).toBe(true); + expect(isValidMacAddress('00-11-22-33-44-55')).toBe(true); + expect(isValidMacAddress('aa:bb:cc:dd:ee:ff')).toBe(true); + }); + + it('should reject invalid MAC addresses', () => { + expect(isValidMacAddress('00:11:22:33:44')).toBe(false); + expect(isValidMacAddress('00:11:22:33:44:55:66')).toBe(false); + expect(isValidMacAddress('GG:11:22:33:44:55')).toBe(false); + expect(isValidMacAddress('001122334455')).toBe(false); + expect(isValidMacAddress('')).toBe(false); + }); + }); + + describe('isValidNetmask', () => { + it('should validate correct subnet masks', () => { + expect(isValidNetmask('255.255.255.0')).toBe(true); + expect(isValidNetmask('255.255.0.0')).toBe(true); + expect(isValidNetmask('255.0.0.0')).toBe(true); + expect(isValidNetmask('255.255.252.0')).toBe(true); + }); + + it('should reject invalid subnet masks', () => { + expect(isValidNetmask('255.255.255.1')).toBe(false); // Not contiguous + expect(isValidNetmask('255.254.255.0')).toBe(false); // Not contiguous + expect(isValidNetmask('256.255.255.0')).toBe(false); // Invalid IP + expect(isValidNetmask('255.255.255')).toBe(false); // Invalid format + }); + }); + + describe('isValidHostname', () => { + it('should validate correct hostnames', () => { + expect(isValidHostname('server1')).toBe(true); + expect(isValidHostname('web-server')).toBe(true); + expect(isValidHostname('DB1')).toBe(true); + expect(isValidHostname('host123')).toBe(true); + }); + + it('should reject invalid hostnames', () => { + expect(isValidHostname('-server')).toBe(false); // Can't start with - + expect(isValidHostname('server-')).toBe(false); // Can't end with - + expect(isValidHostname('server.example')).toBe(false); // No dots in hostname + expect(isValidHostname('')).toBe(false); + }); + }); + + describe('isIpInNetwork', () => { + it('should correctly identify IPs in network', () => { + expect(isIpInNetwork('192.168.1.100', '192.168.1.0', '255.255.255.0')).toBe(true); + expect(isIpInNetwork('192.168.1.254', '192.168.1.0', '255.255.255.0')).toBe(true); + expect(isIpInNetwork('10.0.0.1', '10.0.0.0', '255.0.0.0')).toBe(true); + }); + + it('should correctly identify IPs not in network', () => { + expect(isIpInNetwork('192.168.2.1', '192.168.1.0', '255.255.255.0')).toBe(false); + expect(isIpInNetwork('172.16.1.1', '192.168.1.0', '255.255.255.0')).toBe(false); + }); + }); +}); + +describe('DHCP Transformers', () => { + describe('generateDefaultDhcpConfig', () => { + it('should generate a valid default configuration', () => { + const config = generateDefaultDhcpConfig(); + + expect(config.dhcpServerStatus).toBe(false); + expect(config.domainName).toBe('local'); + expect(config.domainNameServers).toEqual(['8.8.8.8', '8.8.4.4']); + expect(config.defaultLeaseTime).toBe(86400); + expect(config.maxLeaseTime).toBe(604800); + expect(config.authoritative).toBe(true); + expect(config.ddnsUpdateStyle).toBe('none'); + expect(config.subnets).toEqual([]); + expect(config.hostReservations).toEqual([]); + expect(config.globalOptions).toEqual([]); + }); + }); + + describe('calculateBroadcastAddress', () => { + it('should calculate correct broadcast addresses', () => { + expect(calculateBroadcastAddress('192.168.1.0', '255.255.255.0')).toBe('192.168.1.255'); + expect(calculateBroadcastAddress('10.0.0.0', '255.0.0.0')).toBe('10.255.255.255'); + expect(calculateBroadcastAddress('172.16.0.0', '255.255.0.0')).toBe('172.16.255.255'); + }); + }); + + describe('suggestIpRange', () => { + it('should suggest reasonable IP ranges', () => { + const range = suggestIpRange('192.168.1.0', '255.255.255.0'); + + expect(range.start).toBe('192.168.1.100'); + expect(range.end).toBe('192.168.1.200'); + }); + }); + + describe('transformDhcpFormToApi and transformDhcpApiToForm', () => { + it('should correctly transform form data to API and back', () => { + const formData: DhcpConfigFormValues = { + dhcpServerStatus: true, + domainName: 'example.com', + domainNameServers: '8.8.8.8, 1.1.1.1', + defaultLeaseTime: '7200', + maxLeaseTime: '86400', + authoritative: true, + ddnsUpdateStyle: 'none', + subnets: [{ + id: 'test-subnet-1', + network: '192.168.1.0', + netmask: '255.255.255.0', + rangeStart: '192.168.1.100', + rangeEnd: '192.168.1.200', + defaultGateway: '192.168.1.1', + domainNameServers: '8.8.8.8, 1.1.1.1', + broadcastAddress: '192.168.1.255', + subnetMask: '255.255.255.0' + }], + hostReservations: [{ + id: 'test-host-1', + hostname: 'printer1', + macAddress: '00:11:22:33:44:55', + fixedAddress: '192.168.1.10' + }], + globalOptions: [{ + id: 'test-option-1', + name: 'time-offset', + value: '3600' + }] + }; + + // Transform to API format + const apiData = transformDhcpFormToApi(formData); + + expect(apiData.dhcpServerStatus).toBe(true); + expect(apiData.domainName).toBe('example.com'); + expect(apiData.domainNameServers).toEqual(['8.8.8.8', '1.1.1.1']); + expect(apiData.defaultLeaseTime).toBe(7200); + expect(apiData.maxLeaseTime).toBe(86400); + expect(apiData.subnets).toHaveLength(1); + expect(apiData.subnets[0].range?.start).toBe('192.168.1.100'); + expect(apiData.subnets[0].range?.end).toBe('192.168.1.200'); + expect(apiData.hostReservations).toHaveLength(1); + expect(apiData.hostReservations[0].hostname).toBe('printer1'); + + // Transform back to form format + const backToForm = transformDhcpApiToForm(apiData); + + expect(backToForm.dhcpServerStatus).toBe(formData.dhcpServerStatus); + expect(backToForm.domainName).toBe(formData.domainName); + expect(backToForm.domainNameServers).toBe('8.8.8.8, 1.1.1.1'); + expect(backToForm.defaultLeaseTime).toBe(formData.defaultLeaseTime); + expect(backToForm.maxLeaseTime).toBe(formData.maxLeaseTime); + expect(backToForm.subnets).toHaveLength(1); + expect(backToForm.hostReservations).toHaveLength(1); + expect(backToForm.globalOptions).toHaveLength(1); + }); + }); +}); + +describe('DHCP Schemas', () => { + describe('dhcpConfigSchema', () => { + it('should validate a correct DHCP configuration', () => { + const validConfig: DhcpConfigFormValues = { + dhcpServerStatus: true, + domainName: 'example.com', + domainNameServers: '8.8.8.8, 1.1.1.1', + defaultLeaseTime: '7200', + maxLeaseTime: '86400', + authoritative: true, + ddnsUpdateStyle: 'none', + subnets: [{ + id: 'subnet-1', + network: '192.168.1.0', + netmask: '255.255.255.0', + rangeStart: '192.168.1.100', + rangeEnd: '192.168.1.200', + defaultGateway: '192.168.1.1', + domainNameServers: '8.8.8.8', + broadcastAddress: '192.168.1.255', + subnetMask: '255.255.255.0' + }], + hostReservations: [], + globalOptions: [] + }; + + const result = dhcpConfigSchema.safeParse(validConfig); + expect(result.success).toBe(true); + }); + + it('should reject invalid configurations', () => { + const invalidConfig = { + dhcpServerStatus: true, + domainName: '', // Invalid: empty domain name + domainNameServers: 'invalid-ip', + defaultLeaseTime: 'not-a-number', + maxLeaseTime: '86400', + authoritative: true, + ddnsUpdateStyle: 'none', + subnets: [], // Invalid: no subnets + hostReservations: [], + globalOptions: [] + }; + + const result = dhcpConfigSchema.safeParse(invalidConfig); + expect(result.success).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/packages/shared/src/validators/dhcpFormValidator.ts b/packages/shared/src/validators/dhcpFormValidator.ts new file mode 100644 index 0000000..11b4759 --- /dev/null +++ b/packages/shared/src/validators/dhcpFormValidator.ts @@ -0,0 +1,244 @@ +import { z } from 'zod'; + +// Validation helpers +export const isValidIpAddress = (ip: string): boolean => { + const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + if (!ipv4Regex.test(ip)) return false; + + const octets = ip.split('.'); + return octets.every(octet => { + const num = parseInt(octet, 10); + return num >= 0 && num <= 255; + }); +}; + +export const isValidMacAddress = (mac: string): boolean => { + const macRegex = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/; + return macRegex.test(mac); +}; + +export const isValidNetmask = (netmask: string): boolean => { + if (!isValidIpAddress(netmask)) return false; + + // Convert to binary and check if it's a valid subnet mask + const octets = netmask.split('.').map(octet => parseInt(octet, 10)); + const binary = octets.map(octet => octet.toString(2).padStart(8, '0')).join(''); + + // Valid subnet mask should have consecutive 1s followed by consecutive 0s + return /^1*0*$/.test(binary); +}; + +export const isValidHostname = (hostname: string): boolean => { + // RFC 1123 compliant hostname validation + const hostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; + return hostnameRegex.test(hostname) && hostname.length <= 63; +}; + +export const isValidDomainName = (domain: string): boolean => { + // Basic domain name validation + const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + return domainRegex.test(domain) && domain.length <= 253; +}; + +export const isValidLeaseTime = (time: string): boolean => { + const num = parseInt(time, 10); + return !isNaN(num) && num > 0 && num <= 2147483647; // Max 32-bit signed integer +}; + +// Network calculation helpers +export const isIpInNetwork = (ip: string, network: string, netmask: string): boolean => { + if (!isValidIpAddress(ip) || !isValidIpAddress(network) || !isValidNetmask(netmask)) { + return false; + } + + const ipOctets = ip.split('.').map(Number); + const networkOctets = network.split('.').map(Number); + const maskOctets = netmask.split('.').map(Number); + + for (let i = 0; i < 4; i++) { + if ((ipOctets[i] & maskOctets[i]) !== (networkOctets[i] & maskOctets[i])) { + return false; + } + } + + return true; +}; + +export const isValidIpRange = (startIp: string, endIp: string): boolean => { + if (!isValidIpAddress(startIp) || !isValidIpAddress(endIp)) { + return false; + } + + const startOctets = startIp.split('.').map(Number); + const endOctets = endIp.split('.').map(Number); + + // Convert to 32-bit integers for comparison + const startInt = (startOctets[0] << 24) + (startOctets[1] << 16) + (startOctets[2] << 8) + startOctets[3]; + const endInt = (endOctets[0] << 24) + (endOctets[1] << 16) + (endOctets[2] << 8) + endOctets[3]; + + return startInt <= endInt; +}; + +// Schema for DHCP options +export const dhcpOptionSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1, "Option name is required"), + value: z.string().min(1, "Option value is required"), + code: z.number().int().min(1).max(254).optional(), +}); + +// Schema for host reservations +export const hostReservationSchema = z.object({ + id: z.string().uuid(), + hostname: z.string().min(1, "Hostname is required").refine(isValidHostname, { + message: "Invalid hostname format" + }), + macAddress: z.string().refine(isValidMacAddress, { + message: "Invalid MAC address format (use format: XX:XX:XX:XX:XX:XX or XX-XX-XX-XX-XX-XX)" + }), + fixedAddress: z.string().refine(isValidIpAddress, { + message: "Invalid IP address" + }), +}); + +// Schema for subnets +export const subnetSchema = z.object({ + id: z.string().uuid(), + network: z.string().refine(isValidIpAddress, { + message: "Invalid network address" + }), + netmask: z.string().refine(isValidNetmask, { + message: "Invalid subnet mask" + }), + rangeStart: z.string().refine(isValidIpAddress, { + message: "Invalid start IP address" + }), + rangeEnd: z.string().refine(isValidIpAddress, { + message: "Invalid end IP address" + }), + defaultGateway: z.string().refine(isValidIpAddress, { + message: "Invalid gateway IP address" + }), + domainNameServers: z.string().min(1, "At least one DNS server is required"), + broadcastAddress: z.string().refine((val) => { + return val === '' || isValidIpAddress(val); + }, { + message: "Invalid broadcast address" + }), + subnetMask: z.string().refine((val) => { + return val === '' || isValidNetmask(val); + }, { + message: "Invalid subnet mask" + }), +}).superRefine((data, ctx) => { + // Validate IP range + if (!isValidIpRange(data.rangeStart, data.rangeEnd)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['rangeEnd'], + message: 'End IP must be greater than or equal to start IP' + }); + } + + // Validate that range IPs are in the network + if (!isIpInNetwork(data.rangeStart, data.network, data.netmask)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['rangeStart'], + message: 'Start IP must be within the subnet' + }); + } + + if (!isIpInNetwork(data.rangeEnd, data.network, data.netmask)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['rangeEnd'], + message: 'End IP must be within the subnet' + }); + } + + // Validate that gateway is in the network + if (!isIpInNetwork(data.defaultGateway, data.network, data.netmask)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['defaultGateway'], + message: 'Gateway must be within the subnet' + }); + } + + // Validate DNS servers format + const dnsServers = data.domainNameServers.split(',').map(s => s.trim()).filter(s => s.length > 0); + for (const dns of dnsServers) { + if (!isValidIpAddress(dns)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['domainNameServers'], + message: `Invalid DNS server IP address: ${dns}` + }); + break; + } + } +}); + +// Main DHCP configuration schema +export const dhcpConfigSchema = z.object({ + dhcpServerStatus: z.boolean(), + domainName: z.string().min(1, "Domain name is required").refine(isValidDomainName, { + message: "Invalid domain name format" + }), + domainNameServers: z.string().min(1, "At least one DNS server is required").refine((val) => { + const servers = val.split(',').map(s => s.trim()).filter(s => s.length > 0); + return servers.length > 0 && servers.every(server => isValidIpAddress(server)); + }, { + message: "Invalid DNS server format. Use comma-separated IP addresses." + }), + defaultLeaseTime: z.string().regex(/^\d+$/, { + message: "Default lease time must be a number" + }).refine(isValidLeaseTime, { + message: "Default lease time must be between 1 and 2147483647 seconds" + }), + maxLeaseTime: z.string().regex(/^\d+$/, { + message: "Max lease time must be a number" + }).refine(isValidLeaseTime, { + message: "Max lease time must be between 1 and 2147483647 seconds" + }), + authoritative: z.boolean().default(true), + ddnsUpdateStyle: z.enum(['interim', 'standard', 'none']).default('none'), + + subnets: z.array(subnetSchema).min(1, "At least one subnet is required"), + hostReservations: z.array(hostReservationSchema).default([]), + globalOptions: z.array(dhcpOptionSchema).default([]), +}).superRefine((data, ctx) => { + // Validate that default lease time is less than max lease time + const defaultTime = parseInt(data.defaultLeaseTime); + const maxTime = parseInt(data.maxLeaseTime); + + if (defaultTime > maxTime) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['maxLeaseTime'], + message: 'Max lease time must be greater than or equal to default lease time' + }); + } + + // Validate that host reservations don't conflict with subnet ranges + for (const reservation of data.hostReservations) { + const reservationIp = reservation.fixedAddress; + + for (const subnet of data.subnets) { + if (isIpInNetwork(reservationIp, subnet.network, subnet.netmask)) { + // Check if reservation IP is within the dynamic range + if (isValidIpRange(subnet.rangeStart, reservationIp) && + isValidIpRange(reservationIp, subnet.rangeEnd)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['hostReservations'], + message: `Host reservation ${reservation.hostname} (${reservationIp}) conflicts with dynamic range ${subnet.rangeStart}-${subnet.rangeEnd}` + }); + } + } + } + } +}); + +export type DhcpConfigFormValues = z.infer; \ No newline at end of file diff --git a/packages/shared/src/validators/dhcpTransformers.ts b/packages/shared/src/validators/dhcpTransformers.ts new file mode 100644 index 0000000..ef87842 --- /dev/null +++ b/packages/shared/src/validators/dhcpTransformers.ts @@ -0,0 +1,235 @@ +import type { DhcpConfiguration, DhcpConfigFormValues } from '../types/dhcp'; + +// Helper function to parse comma-separated strings into arrays +export const parseStringToArray = (input: string): string[] => { + return input + .split(',') + .map(item => item.trim()) + .filter(item => item.length > 0); +}; + +// Helper function to convert arrays to comma-separated strings +export const arrayToCommaString = (arr: string[] = []): string => { + return arr.join(', '); +}; + +// Transform UI form data to API format +export const transformDhcpFormToApi = (formData: DhcpConfigFormValues): DhcpConfiguration => { + return { + dhcpServerStatus: formData.dhcpServerStatus, + domainName: formData.domainName, + domainNameServers: parseStringToArray(formData.domainNameServers), + defaultLeaseTime: parseInt(formData.defaultLeaseTime, 10), + maxLeaseTime: parseInt(formData.maxLeaseTime, 10), + authoritative: formData.authoritative, + ddnsUpdateStyle: formData.ddnsUpdateStyle as 'interim' | 'standard' | 'none', + + subnets: formData.subnets.map(subnet => ({ + id: subnet.id, + network: subnet.network, + netmask: subnet.netmask, + range: { + start: subnet.rangeStart, + end: subnet.rangeEnd + }, + defaultGateway: subnet.defaultGateway, + domainNameServers: parseStringToArray(subnet.domainNameServers), + broadcastAddress: subnet.broadcastAddress || undefined, + subnetMask: subnet.subnetMask || undefined, + pools: [], // Will be extended in future versions + options: [] // Will be extended in future versions + })), + + hostReservations: formData.hostReservations.map(host => ({ + id: host.id, + hostname: host.hostname, + macAddress: host.macAddress, + fixedAddress: host.fixedAddress, + options: [] // Will be extended in future versions + })), + + globalOptions: formData.globalOptions.map(option => ({ + id: option.id, + name: option.name, + value: option.value + })) + }; +}; + +// Transform API data to UI form format +export const transformDhcpApiToForm = (apiData: DhcpConfiguration): DhcpConfigFormValues => { + return { + dhcpServerStatus: apiData.dhcpServerStatus, + domainName: apiData.domainName || '', + domainNameServers: arrayToCommaString(apiData.domainNameServers), + defaultLeaseTime: (apiData.defaultLeaseTime || 86400).toString(), + maxLeaseTime: (apiData.maxLeaseTime || 604800).toString(), + authoritative: apiData.authoritative !== false, // Default to true if undefined + ddnsUpdateStyle: apiData.ddnsUpdateStyle || 'none', + + subnets: apiData.subnets.map(subnet => ({ + id: subnet.id, + network: subnet.network, + netmask: subnet.netmask, + rangeStart: subnet.range?.start || '', + rangeEnd: subnet.range?.end || '', + defaultGateway: subnet.defaultGateway || '', + domainNameServers: arrayToCommaString(subnet.domainNameServers), + broadcastAddress: subnet.broadcastAddress || '', + subnetMask: subnet.subnetMask || '' + })), + + hostReservations: apiData.hostReservations.map(host => ({ + id: host.id, + hostname: host.hostname, + macAddress: host.macAddress, + fixedAddress: host.fixedAddress + })), + + globalOptions: (apiData.globalOptions || []).map(option => ({ + id: option.id, + name: option.name, + value: option.value + })) + }; +}; + +// Validation helpers for transformers +export const validateFormData = (formData: DhcpConfigFormValues): string[] => { + const errors: string[] = []; + + // Check for duplicate subnet networks + const networkAddresses = formData.subnets.map(subnet => subnet.network); + const duplicateNetworks = networkAddresses.filter((network, index) => + networkAddresses.indexOf(network) !== index + ); + + if (duplicateNetworks.length > 0) { + errors.push(`Duplicate network addresses found: ${duplicateNetworks.join(', ')}`); + } + + // Check for duplicate MAC addresses in host reservations + const macAddresses = formData.hostReservations.map(host => host.macAddress.toLowerCase()); + const duplicateMacs = macAddresses.filter((mac, index) => + macAddresses.indexOf(mac) !== index + ); + + if (duplicateMacs.length > 0) { + errors.push(`Duplicate MAC addresses found: ${duplicateMacs.join(', ')}`); + } + + // Check for duplicate fixed IP addresses in host reservations + const fixedAddresses = formData.hostReservations.map(host => host.fixedAddress); + const duplicateIps = fixedAddresses.filter((ip, index) => + fixedAddresses.indexOf(ip) !== index + ); + + if (duplicateIps.length > 0) { + errors.push(`Duplicate fixed IP addresses found: ${duplicateIps.join(', ')}`); + } + + // Check for duplicate hostnames in host reservations + const hostnames = formData.hostReservations.map(host => host.hostname.toLowerCase()); + const duplicateHostnames = hostnames.filter((hostname, index) => + hostnames.indexOf(hostname) !== index + ); + + if (duplicateHostnames.length > 0) { + errors.push(`Duplicate hostnames found: ${duplicateHostnames.join(', ')}`); + } + + return errors; +}; + +// Generate a default DHCP configuration +export const generateDefaultDhcpConfig = (): DhcpConfiguration => { + return { + dhcpServerStatus: false, + domainName: 'local', + domainNameServers: ['8.8.8.8', '8.8.4.4'], + defaultLeaseTime: 86400, // 24 hours + maxLeaseTime: 604800, // 7 days + authoritative: true, + ddnsUpdateStyle: 'none', + subnets: [], + hostReservations: [], + globalOptions: [] + }; +}; + +// Calculate network broadcast address from network and netmask +export const calculateBroadcastAddress = (network: string, netmask: string): string => { + try { + const networkOctets = network.split('.').map(Number); + const maskOctets = netmask.split('.').map(Number); + + const broadcastOctets = networkOctets.map((octet, index) => { + return octet | (255 - maskOctets[index]); + }); + + return broadcastOctets.join('.'); + } catch (error) { + return ''; + } +}; + +// Calculate the network address from an IP and netmask +export const calculateNetworkAddress = (ip: string, netmask: string): string => { + try { + const ipOctets = ip.split('.').map(Number); + const maskOctets = netmask.split('.').map(Number); + + const networkOctets = ipOctets.map((octet, index) => { + return octet & maskOctets[index]; + }); + + return networkOctets.join('.'); + } catch (error) { + return ''; + } +}; + +// Suggest a default IP range for a given network +export const suggestIpRange = (network: string, netmask: string): { start: string; end: string } => { + try { + const networkOctets = network.split('.').map(Number); + const maskOctets = netmask.split('.').map(Number); + + // Find the first octet that's not fully masked + let rangeOctetIndex = 3; + for (let i = 0; i < 4; i++) { + if (maskOctets[i] !== 255) { + rangeOctetIndex = i; + break; + } + } + + // Calculate available range + const hostBits = 8 - Math.log2(256 - maskOctets[rangeOctetIndex]); + const maxHosts = Math.pow(2, hostBits) - 2; // Subtract network and broadcast + + // Suggest using 50% of the range starting from .100 if possible + const startOctets = [...networkOctets]; + const endOctets = [...networkOctets]; + + if (rangeOctetIndex === 3) { + // Class C or similar - start from .100 if possible + const suggestedStart = Math.max(100, networkOctets[3] + 1); + const suggestedEnd = Math.min(200, networkOctets[3] + maxHosts); + + startOctets[3] = suggestedStart; + endOctets[3] = suggestedEnd; + } else { + // Larger networks - use a reasonable range + startOctets[rangeOctetIndex] = networkOctets[rangeOctetIndex] + 1; + endOctets[rangeOctetIndex] = networkOctets[rangeOctetIndex] + Math.min(100, maxHosts); + } + + return { + start: startOctets.join('.'), + end: endOctets.join('.') + }; + } catch (error) { + return { start: '', end: '' }; + } +}; \ No newline at end of file diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index e51e1bb..07777da 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -7,6 +7,8 @@ import * as userValidators from './userValidator'; import * as dnsTransformers from './dnsTransformers'; import * as httpFormValidators from './httpFormValidator'; import * as httpTransformers from './httpTransformers'; +import * as dhcpFormValidators from './dhcpFormValidator'; +import * as dhcpTransformers from './dhcpTransformers'; // Re-export everything from dnsConfigValidator export * from './dnsConfigValidator'; @@ -49,5 +51,34 @@ export { transformHttpApiToForm } from './httpTransformers'; +// Re-export DHCP validators and transformers +export { + isValidIpAddress as dhcpIsValidIpAddress, + isValidMacAddress, + isValidNetmask, + isValidHostname, + isValidDomainName, + isValidLeaseTime, + isIpInNetwork, + isValidIpRange, + dhcpOptionSchema, + hostReservationSchema, + subnetSchema, + dhcpConfigSchema +} from './dhcpFormValidator'; +export type { DhcpConfigFormValues } from './dhcpFormValidator'; + +export { + transformDhcpFormToApi, + transformDhcpApiToForm, + parseStringToArray as dhcpParseStringToArray, + arrayToCommaString, + validateFormData as validateDhcpFormData, + generateDefaultDhcpConfig, + calculateBroadcastAddress, + calculateNetworkAddress, + suggestIpRange +} from './dhcpTransformers'; + // Re-export everything from userValidator export * from './userValidator'; \ No newline at end of file