feat(radius): add RADIUS server with MAC authentication (MAB), VLAN assignment, accounting and OpsServer API handlers
This commit is contained in:
363
ts/radius/classes.vlan.manager.ts
Normal file
363
ts/radius/classes.vlan.manager.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user