Files
dcrouter/ts/radius/classes.vlan.manager.ts

364 lines
9.4 KiB
TypeScript
Raw Permalink Normal View History

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<string, IMacVlanMapping> = new Map();
private config: Required<IVlanManagerConfig>;
private storageManager?: StorageManager;
// Cache for normalized MAC lookups
private normalizedMacCache: Map<string, string> = 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<void> {
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<IMacVlanMapping, 'createdAt' | 'updatedAt'>): Promise<IMacVlanMapping> {
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<boolean> {
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<Omit<IMacVlanMapping, 'createdAt' | 'updatedAt'>>): Promise<number> {
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<IVlanManagerConfig>): 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<IVlanManagerConfig> {
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<void> {
if (!this.storageManager) {
return;
}
try {
const data = await this.storageManager.getJSON<IMacVlanMapping[]>(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<void> {
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}`);
}
}
}