import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; import type { StorageManager } from '../storage/index.js'; /** * MAC address to VLAN mapping */ export interface IMacVlanMapping { /** MAC address (full) or OUI pattern (e.g., "00:11:22" for vendor prefix) */ mac: string; /** VLAN ID to assign */ vlan: number; /** Optional description */ description?: string; /** Whether this mapping is enabled */ enabled: boolean; /** Creation timestamp */ createdAt: number; /** Last update timestamp */ updatedAt: number; } /** * VLAN assignment result */ export interface IVlanAssignmentResult { /** Whether a VLAN was successfully assigned */ assigned: boolean; /** The assigned VLAN ID (or default if not matched) */ vlan: number; /** The matching rule (if any) */ matchedRule?: IMacVlanMapping; /** Whether default VLAN was used */ isDefault: boolean; } /** * VlanManager configuration */ export interface IVlanManagerConfig { /** Default VLAN for unknown MACs */ defaultVlan?: number; /** Whether to allow unknown MACs (assign default VLAN) or reject */ allowUnknownMacs?: boolean; /** Storage key prefix for persistence */ storagePrefix?: string; } /** * Manages MAC address to VLAN mappings with support for: * - Exact MAC address matching * - OUI (vendor prefix) pattern matching * - Wildcard patterns * - Default VLAN for unknown devices */ export class VlanManager { private mappings: Map = new Map(); private config: Required; private storageManager?: StorageManager; // Cache for normalized MAC lookups private normalizedMacCache: Map = new Map(); constructor(config?: IVlanManagerConfig, storageManager?: StorageManager) { this.config = { defaultVlan: config?.defaultVlan ?? 1, allowUnknownMacs: config?.allowUnknownMacs ?? true, storagePrefix: config?.storagePrefix ?? '/radius/vlan-mappings', }; this.storageManager = storageManager; } /** * Initialize the VLAN manager and load persisted mappings */ async initialize(): Promise { if (this.storageManager) { await this.loadMappings(); } logger.log('info', `VlanManager initialized with ${this.mappings.size} mappings, default VLAN: ${this.config.defaultVlan}`); } /** * Normalize a MAC address to lowercase with colons * Accepts formats: 00:11:22:33:44:55, 00-11-22-33-44-55, 001122334455 */ normalizeMac(mac: string): string { // Check cache first const cached = this.normalizedMacCache.get(mac); if (cached) { return cached; } // Remove all separators and convert to lowercase const cleaned = mac.toLowerCase().replace(/[-:]/g, ''); // Format with colons const normalized = cleaned.match(/.{1,2}/g)?.join(':') || mac.toLowerCase(); // Cache the result this.normalizedMacCache.set(mac, normalized); return normalized; } /** * Check if a MAC address matches a pattern * Supports: * - Exact match: "00:11:22:33:44:55" * - OUI match: "00:11:22" (matches any device with this vendor prefix) * - Wildcard: "*" (matches all) */ macMatchesPattern(mac: string, pattern: string): boolean { const normalizedMac = this.normalizeMac(mac); const normalizedPattern = this.normalizeMac(pattern); // Wildcard matches all if (pattern === '*') { return true; } // Exact match if (normalizedMac === normalizedPattern) { return true; } // OUI/prefix match (pattern is shorter than full MAC) if (normalizedPattern.length < 17 && normalizedMac.startsWith(normalizedPattern)) { return true; } return false; } /** * Add or update a MAC to VLAN mapping */ async addMapping(mapping: Omit): Promise { const normalizedMac = this.normalizeMac(mapping.mac); const now = Date.now(); const existingMapping = this.mappings.get(normalizedMac); const fullMapping: IMacVlanMapping = { ...mapping, mac: normalizedMac, createdAt: existingMapping?.createdAt || now, updatedAt: now, }; this.mappings.set(normalizedMac, fullMapping); // Persist to storage if (this.storageManager) { await this.saveMappings(); } logger.log('info', `VLAN mapping ${existingMapping ? 'updated' : 'added'}: ${normalizedMac} -> VLAN ${mapping.vlan}`); return fullMapping; } /** * Remove a MAC to VLAN mapping */ async removeMapping(mac: string): Promise { const normalizedMac = this.normalizeMac(mac); const removed = this.mappings.delete(normalizedMac); if (removed && this.storageManager) { await this.saveMappings(); logger.log('info', `VLAN mapping removed: ${normalizedMac}`); } return removed; } /** * Get a specific mapping by MAC */ getMapping(mac: string): IMacVlanMapping | undefined { return this.mappings.get(this.normalizeMac(mac)); } /** * Get all mappings */ getAllMappings(): IMacVlanMapping[] { return Array.from(this.mappings.values()); } /** * Determine VLAN assignment for a MAC address * Returns the most specific matching rule (exact > OUI > wildcard > default) */ assignVlan(mac: string): IVlanAssignmentResult { const normalizedMac = this.normalizeMac(mac); // First, try exact match const exactMatch = this.mappings.get(normalizedMac); if (exactMatch && exactMatch.enabled) { return { assigned: true, vlan: exactMatch.vlan, matchedRule: exactMatch, isDefault: false, }; } // Try OUI/prefix matches (sorted by specificity - longer patterns first) const patternMatches: IMacVlanMapping[] = []; for (const mapping of this.mappings.values()) { if (mapping.enabled && mapping.mac !== normalizedMac && this.macMatchesPattern(normalizedMac, mapping.mac)) { patternMatches.push(mapping); } } // Sort by pattern length (most specific first) patternMatches.sort((a, b) => b.mac.length - a.mac.length); if (patternMatches.length > 0) { const bestMatch = patternMatches[0]; return { assigned: true, vlan: bestMatch.vlan, matchedRule: bestMatch, isDefault: false, }; } // No match - use default VLAN if allowed if (this.config.allowUnknownMacs) { return { assigned: true, vlan: this.config.defaultVlan, isDefault: true, }; } // Unknown MAC and not allowed return { assigned: false, vlan: 0, isDefault: false, }; } /** * Bulk import mappings */ async importMappings(mappings: Array>): Promise { let imported = 0; for (const mapping of mappings) { await this.addMapping(mapping); imported++; } logger.log('info', `Imported ${imported} VLAN mappings`); return imported; } /** * Export all mappings */ exportMappings(): IMacVlanMapping[] { return this.getAllMappings(); } /** * Update configuration */ updateConfig(config: Partial): void { if (config.defaultVlan !== undefined) { this.config.defaultVlan = config.defaultVlan; } if (config.allowUnknownMacs !== undefined) { this.config.allowUnknownMacs = config.allowUnknownMacs; } logger.log('info', `VlanManager config updated: defaultVlan=${this.config.defaultVlan}, allowUnknown=${this.config.allowUnknownMacs}`); } /** * Get current configuration */ getConfig(): Required { return { ...this.config }; } /** * Get statistics */ getStats(): { totalMappings: number; enabledMappings: number; exactMatches: number; ouiPatterns: number; wildcardPatterns: number; } { let exactMatches = 0; let ouiPatterns = 0; let wildcardPatterns = 0; let enabledMappings = 0; for (const mapping of this.mappings.values()) { if (mapping.enabled) { enabledMappings++; } if (mapping.mac === '*') { wildcardPatterns++; } else if (mapping.mac.length < 17) { // OUI patterns are shorter than full MAC (17 chars with colons) ouiPatterns++; } else { exactMatches++; } } return { totalMappings: this.mappings.size, enabledMappings, exactMatches, ouiPatterns, wildcardPatterns, }; } /** * Load mappings from storage */ private async loadMappings(): Promise { if (!this.storageManager) { return; } try { const data = await this.storageManager.getJSON(this.config.storagePrefix); if (data && Array.isArray(data)) { for (const mapping of data) { this.mappings.set(this.normalizeMac(mapping.mac), mapping); } logger.log('info', `Loaded ${data.length} VLAN mappings from storage`); } } catch (error) { logger.log('warn', `Failed to load VLAN mappings from storage: ${error.message}`); } } /** * Save mappings to storage */ private async saveMappings(): Promise { if (!this.storageManager) { return; } try { const mappings = Array.from(this.mappings.values()); await this.storageManager.setJSON(this.config.storagePrefix, mappings); } catch (error) { logger.log('error', `Failed to save VLAN mappings to storage: ${error.message}`); } } }