From c2d3ace0dd509b7294f53559e2bc774170b3df3f Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 1 Feb 2026 19:21:37 +0000 Subject: [PATCH] feat(radius): add RADIUS server with MAC authentication (MAB), VLAN assignment, accounting and OpsServer API handlers --- changelog.md | 12 + npmextra.json | 12 + package.json | 11 +- pnpm-lock.yaml | 11 + readme.hints.md | 57 +++ readme.metrics.md | 202 -------- readme.module-adjustments.md | 173 ------- readme.plan2.md | 71 --- readme.statsgrid.md | 46 -- test_watch/devserver.ts | 35 ++ ts/00_commitinfo_data.ts | 2 +- ts/classes.dcrouter.ts | 83 +++- ts/index.ts | 3 + ts/opsserver/classes.opsserver.ts | 4 +- ts/opsserver/handlers/index.ts | 3 +- ts/opsserver/handlers/radius.handler.ts | 405 ++++++++++++++++ ts/plugins.ts | 3 +- ts/radius/classes.accounting.manager.ts | 607 ++++++++++++++++++++++++ ts/radius/classes.radius.server.ts | 532 +++++++++++++++++++++ ts/radius/classes.vlan.manager.ts | 363 ++++++++++++++ ts/radius/index.ts | 14 + ts_interfaces/requests/index.ts | 3 +- ts_interfaces/requests/radius.ts | 329 +++++++++++++ ts_web/00_commitinfo_data.ts | 2 +- ts_web/elements/ops-dashboard.ts | 6 +- ts_web/elements/ops-view-config.ts | 6 +- ts_web/elements/ops-view-emails.ts | 14 +- ts_web/elements/ops-view-logs.ts | 2 +- ts_web/elements/ops-view-network.ts | 12 +- ts_web/elements/ops-view-overview.ts | 2 +- ts_web/elements/ops-view-security.ts | 4 +- 31 files changed, 2498 insertions(+), 531 deletions(-) delete mode 100644 readme.metrics.md delete mode 100644 readme.module-adjustments.md delete mode 100644 readme.plan2.md delete mode 100644 readme.statsgrid.md create mode 100644 test_watch/devserver.ts create mode 100644 ts/opsserver/handlers/radius.handler.ts create mode 100644 ts/radius/classes.accounting.manager.ts create mode 100644 ts/radius/classes.radius.server.ts create mode 100644 ts/radius/classes.vlan.manager.ts create mode 100644 ts/radius/index.ts create mode 100644 ts_interfaces/requests/radius.ts diff --git a/changelog.md b/changelog.md index e7121d2..6a13d7f 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,17 @@ # Changelog +## 2026-02-01 - 2.13.0 - feat(radius) +add RADIUS server with MAC authentication (MAB), VLAN assignment, accounting and OpsServer API handlers + +- Introduce full RADIUS module under ts/radius: classes.radius.server, classes.vlan.manager, classes.accounting.manager (authentication, VLAN mapping, OUI patterns, accounting, persistence). +- Integrate RADIUS into DcRouter: add radiusConfig option, setupRadiusServer(), updateRadiusConfig(), start/stop lifecycle handling and startup summary output. +- Add OpsServer RadiusHandler (ts/opsserver/handlers/radius.handler.ts) exposing TypedRequest endpoints for client management, VLAN mappings, accounting reports and statistics. +- Add typed request interfaces for RADIUS under ts_interfaces/requests/radius.ts and export them from the requests index. +- Wire smartradius into plugins (ts/plugins.ts) and export the new module; export RADIUS from ts/index.ts and re-export RADIUS types from classes.dcrouter. +- Update package.json & npmextra.json: add tswatch script and dev watcher configuration, add @push.rocks/smartradius dependency and a test_watch/devserver.ts dev server entrypoint. +- Refactor several web UI components (ops-dashboard, ops-view-*) to use 'accessor' for @state properties (small UI state API adjustments). +- Documentation: update readme.hints.md with RADIUS integration notes and examples. + ## 2026-02-01 - 2.12.6 - fix(tests) update tests and test helpers to current email/DNS APIs, use non-privileged ports, and improve robustness and resilience diff --git a/npmextra.json b/npmextra.json index 4b7657a..61cb9aa 100644 --- a/npmextra.json +++ b/npmextra.json @@ -1,4 +1,16 @@ { + "@git.zone/tswatch": { + "watchers": [ + { + "name": "dcrouter-dev", + "watch": ["ts/**/*.ts", "ts_*/**/*.ts", "test_watch/devserver.ts"], + "command": "pnpm run build && tsrun test_watch/devserver.ts", + "restart": true, + "debounce": 500, + "runOnStart": true + } + ] + }, "@git.zone/tsbundle": { "bundles": [ { diff --git a/package.json b/package.json index c93ba48..f6d399f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "start": "(node --max_old_space_size=250 ./cli.js)", "startTs": "(node cli.ts.js)", "build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)", - "bundle": "(tsbundle)" + "bundle": "(tsbundle)", + "watch": "tswatch" }, "devDependencies": { "@git.zone/tsbuild": "^4.1.2", @@ -47,6 +48,7 @@ "@push.rocks/smartpath": "^5.0.5", "@push.rocks/smartpromise": "^4.0.3", "@push.rocks/smartproxy": "^19.6.15", + "@push.rocks/smartradius": "^1.0.3", "@push.rocks/smartrequest": "^2.1.0", "@push.rocks/smartrule": "^2.0.1", "@push.rocks/smartrx": "^3.0.10", @@ -80,7 +82,12 @@ "email templating", "rule management", "SMTP STARTTLS", - "DNS management" + "DNS management", + "RADIUS", + "AAA", + "network authentication", + "VLAN assignment", + "MAC authentication" ], "pnpm": { "onlyBuiltDependencies": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13b8dad..ece24f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: '@push.rocks/smartproxy': specifier: ^19.6.15 version: 19.6.17(@push.rocks/smartserve@2.0.1)(socks@2.8.7) + '@push.rocks/smartradius': + specifier: ^1.0.3 + version: 1.1.0 '@push.rocks/smartrequest': specifier: ^2.1.0 version: 2.1.0 @@ -1069,6 +1072,9 @@ packages: '@push.rocks/smartpuppeteer@2.0.5': resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==} + '@push.rocks/smartradius@1.1.0': + resolution: {integrity: sha512-eocddp/bDcB5a/JOt5lezz0uBWezOKpnDQgMx+I4bl8eJ20KIWh0B6PhYuKYjGuDwo/t01p+s+m0gG7IgyPmzQ==} + '@push.rocks/smartrequest@2.1.0': resolution: {integrity: sha512-3eHLTRInHA+u+W98TqJwgTES7rRimBAsJC4JxVNQC3UUezmblAhM5/TIQsEBQTsbjAY8SeQKy6NHzW6iTiaD8w==} @@ -6502,6 +6508,11 @@ snapshots: - typescript - utf-8-validate + '@push.rocks/smartradius@1.1.0': + dependencies: + '@push.rocks/smartdelay': 3.0.5 + '@push.rocks/smartpromise': 4.2.3 + '@push.rocks/smartrequest@2.1.0': dependencies: '@push.rocks/smartpromise': 4.2.3 diff --git a/readme.hints.md b/readme.hints.md index bae337f..76f9e7a 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -1,5 +1,62 @@ # Implementation Hints and Learnings +## RADIUS Server Integration (2026-02-01) + +### Overview +DcRouter now supports RADIUS server functionality for network authentication via `@push.rocks/smartradius`. + +### Key Features +- **MAC Authentication Bypass (MAB)** - Authenticate network devices based on MAC address +- **VLAN Assignment** - Assign VLANs based on MAC address or OUI patterns +- **RADIUS Accounting** - Track sessions, data usage, and billing + +### Configuration Example +```typescript +const dcRouter = new DcRouter({ + radiusConfig: { + authPort: 1812, // Authentication port (default) + acctPort: 1813, // Accounting port (default) + clients: [ + { + name: 'switch-1', + ipRange: '192.168.1.0/24', + secret: 'shared-secret', + enabled: true + } + ], + vlanAssignment: { + defaultVlan: 100, // VLAN for unknown MACs + allowUnknownMacs: true, + mappings: [ + { mac: '00:11:22:33:44:55', vlan: 10, enabled: true }, + { mac: '00:11:22', vlan: 20, enabled: true } // OUI pattern + ] + }, + accounting: { + enabled: true, + retentionDays: 30 + } + } +}); +``` + +### Components +- `RadiusServer` - Main server wrapping smartradius +- `VlanManager` - MAC-to-VLAN mapping with OUI pattern support +- `AccountingManager` - Session tracking and billing data + +### OpsServer API Endpoints +- `getRadiusClients` / `setRadiusClient` / `removeRadiusClient` - Client management +- `getVlanMappings` / `setVlanMapping` / `removeVlanMapping` - VLAN mappings +- `testVlanAssignment` - Test what VLAN a MAC would get +- `getRadiusSessions` / `disconnectRadiusSession` - Session management +- `getRadiusStatistics` / `getRadiusAccountingSummary` - Statistics + +### Files +- `ts/radius/` - RADIUS module +- `ts/opsserver/handlers/radius.handler.ts` - OpsServer handler +- `ts_interfaces/requests/radius.ts` - TypedRequest interfaces + ## Test Fix: test.dcrouter.email.ts (2026-02-01) ### Issue diff --git a/readme.metrics.md b/readme.metrics.md deleted file mode 100644 index 789d32c..0000000 --- a/readme.metrics.md +++ /dev/null @@ -1,202 +0,0 @@ -# Metrics Implementation Plan with @push.rocks/smartmetrics - -Command to reread CLAUDE.md: `cat /home/centraluser/eu.central.ingress-2/CLAUDE.md` - -## Overview -This plan outlines the migration from placeholder/demo metrics to real metrics using @push.rocks/smartmetrics for the dcrouter project. - -## Current State Analysis - -### Currently Implemented (Real Data) -- CPU usage (basic calculation from os.loadavg) -- Memory usage (from process.memoryUsage) -- System uptime - -### Currently Stubbed (Returns 0 or Demo Data) -- Active connections (HTTP/HTTPS/WebSocket) -- Total connections -- Requests per second -- Email statistics (sent/received/failed/queued/bounce rate) -- DNS statistics (queries/cache hits/response times) -- Security metrics (blocked IPs/auth failures/spam detection) - -## Implementation Plan - -### Phase 1: Core Infrastructure Setup - -1. **Install Dependencies** - ```bash - pnpm install --save @push.rocks/smartmetrics - ``` - -2. **Update plugins.ts** - - Add smartmetrics to ts/plugins.ts - - Import as: `import * as smartmetrics from '@push.rocks/smartmetrics';` - -3. **Create Metrics Manager Class** - - Location: `ts/monitoring/classes.metricsmanager.ts` - - Initialize SmartMetrics with existing logger - - Configure for dcrouter service identification - - Set up automatic metric collection intervals - -### Phase 2: Connection Tracking Implementation - -1. **HTTP/HTTPS Connection Tracking** - - Instrument the SmartProxy connection handlers - - Track active connections in real-time - - Monitor connection lifecycle (open/close events) - - Location: Update connection managers in routing system - -2. **Email Connection Tracking** - - Instrument SMTP server connection handlers - - Track both incoming and outgoing connections - - Location: `ts/mail/delivery/smtpserver/connection-manager.ts` - -3. **DNS Query Tracking** - - Instrument DNS server handlers - - Track query counts and response times - - Location: `ts/mail/routing/classes.dns.manager.ts` - -### Phase 3: Email Metrics Collection - -1. **Email Processing Metrics** - - Track sent/received/failed emails - - Monitor queue sizes - - Calculate delivery and bounce rates - - Location: Instrument `classes.delivery.queue.ts` and `classes.emailsendjob.ts` - -2. **Email Performance Metrics** - - Track processing times - - Monitor queue throughput - - Location: Update delivery system classes - -### Phase 4: Security Metrics Integration - -1. **Security Event Tracking** - - Track blocked IPs from IPReputationChecker - - Monitor authentication failures - - Count spam/malware/phishing detections - - Location: Instrument security classes in `ts/security/` - -### Phase 5: Stats Handler Refactoring - -1. **Update Stats Handler** - - Location: `ts/opsserver/handlers/stats.handler.ts` - - Replace all stub implementations with MetricsManager calls - - Maintain existing API interface structure - -2. **Metrics Aggregation** - - Implement proper time-window aggregations - - Add historical data storage (last hour/day) - - Calculate rates and percentages accurately - -### Phase 6: Prometheus Integration (Optional Enhancement) - -1. **Enable Prometheus Endpoint** - - Add Prometheus metrics endpoint - - Configure port (default: 9090) - - Document metrics for monitoring systems - -## Implementation Details - -### MetricsManager Core Structure -```typescript -export class MetricsManager { - private smartMetrics: smartmetrics.SmartMetrics; - private connectionTrackers: Map; - private emailMetrics: EmailMetricsCollector; - private dnsMetrics: DnsMetricsCollector; - private securityMetrics: SecurityMetricsCollector; - - // Real-time counters - private activeConnections = { - http: 0, - https: 0, - websocket: 0, - smtp: 0 - }; - - // Initialize and start collection - public async start(): Promise; - - // Get aggregated metrics for stats handler - public async getServerStats(): Promise; - public async getEmailStats(): Promise; - public async getDnsStats(): Promise; - public async getSecurityStats(): Promise; -} -``` - -### Connection Tracking Pattern -```typescript -// Example for HTTP connections -onConnectionOpen(type: string) { - this.activeConnections[type]++; - this.totalConnections[type]++; -} - -onConnectionClose(type: string) { - this.activeConnections[type]--; -} -``` - -### Email Metrics Pattern -```typescript -// Track email events -onEmailSent() { this.emailsSentToday++; } -onEmailReceived() { this.emailsReceivedToday++; } -onEmailFailed() { this.emailsFailedToday++; } -onEmailQueued() { this.queueSize++; } -onEmailDequeued() { this.queueSize--; } -``` - -## Testing Strategy - -1. **Unit Tests** - - Test MetricsManager initialization - - Test metric collection accuracy - - Test aggregation calculations - -2. **Integration Tests** - - Test metrics flow from source to API - - Verify real-time updates - - Test under load conditions - -3. **Debug Utilities** - - Create `.nogit/debug/test-metrics.ts` for quick testing - - Add metrics dump endpoint for debugging - -## Migration Steps - -1. Implement MetricsManager without breaking existing code -2. Wire up one metric type at a time -3. Verify each metric shows real data -4. Remove TODO comments from stats handler -5. Update tests to expect real values - -## Success Criteria - -- [ ] All metrics show real, accurate data -- [ ] No performance degradation -- [ ] Metrics update in real-time -- [ ] Historical data is collected -- [ ] All TODO comments removed from stats handler -- [ ] Tests pass with real metric values - -## Notes - -- SmartMetrics provides CPU and memory metrics out of the box -- We'll need custom collectors for application-specific metrics -- Consider adding metric persistence for historical data -- Prometheus integration provides industry-standard monitoring - -## Questions to Address - -1. Should we persist metrics to disk for historical analysis? -2. What time windows should we support (5min, 1hour, 1day)? -3. Should we add alerting thresholds? -4. Do we need custom metric types beyond the current interface? - ---- - -This plan ensures a systematic migration from demo metrics to real, actionable data using @push.rocks/smartmetrics while maintaining the existing API structure and adding powerful monitoring capabilities. \ No newline at end of file diff --git a/readme.module-adjustments.md b/readme.module-adjustments.md deleted file mode 100644 index 6421ed9..0000000 --- a/readme.module-adjustments.md +++ /dev/null @@ -1,173 +0,0 @@ -# Module Adjustments for Metrics Collection - -Command to reread CLAUDE.md: `cat /home/centraluser/eu.central.ingress-2/CLAUDE.md` - -## SmartProxy Adjustments - -### Current State -SmartProxy (@push.rocks/smartproxy) provides: -- Route-level `maxConnections` limiting -- Event emission system (currently only for certificates) -- NFTables integration with packet statistics -- Connection monitoring during active sessions - -### Missing Capabilities for Metrics -1. **No Connection Lifecycle Events** - - No `connection-open` or `connection-close` events - - No way to track active connections in real-time - - No exposure of internal connection tracking - -2. **No Statistics API** - - No methods like `getActiveConnections()` or `getConnectionStats()` - - No access to connection counts per route - - No throughput or performance metrics exposed - -3. **Limited Event System** - - Currently only emits certificate-related events - - No connection, request, or performance events - -### Required Adjustments -1. **Add Connection Tracking Events** - ```typescript - // Emit on new connection - smartProxy.emit('connection-open', { - type: 'http' | 'https' | 'websocket', - routeName: string, - clientIp: string, - timestamp: Date - }); - - // Emit on connection close - smartProxy.emit('connection-close', { - connectionId: string, - duration: number, - bytesTransferred: number - }); - ``` - -2. **Add Statistics API** - ```typescript - interface IProxyStats { - getActiveConnections(): number; - getConnectionsByRoute(): Map; - getTotalConnections(): number; - getRequestsPerSecond(): number; - getThroughput(): { bytesIn: number, bytesOut: number }; - } - ``` - -3. **Expose Internal Metrics** - - Make connection pools accessible - - Expose route-level statistics - - Provide request/response metrics - -### Alternative Approach -Since SmartProxy is already used with socket handlers for email routing, we could: -1. Wrap all SmartProxy socket handlers with a metrics-aware wrapper -2. Use the existing socket-handler pattern to intercept all connections -3. Track connections at the dcrouter level rather than modifying SmartProxy - -## SmartDNS Adjustments - -### Current State -SmartDNS (@push.rocks/smartdns) provides: -- DNS query handling via registered handlers -- Support for UDP (port 53) and DNS-over-HTTPS -- Domain pattern matching and routing -- DNSSEC support - -### Missing Capabilities for Metrics -1. **No Query Tracking** - - No counters for total queries - - No breakdown by query type (A, AAAA, MX, etc.) - - No domain popularity tracking - -2. **No Performance Metrics** - - No response time tracking - - No cache hit/miss statistics - - No error rate tracking - -3. **No Event Emission** - - No query lifecycle events - - No cache events - - No error events - -### Required Adjustments -1. **Add Query Interceptor/Middleware** - ```typescript - // Wrap handler registration to add metrics - smartDns.use((query, next) => { - metricsCollector.trackQuery(query); - const startTime = Date.now(); - - next((response) => { - metricsCollector.trackResponse(response, Date.now() - startTime); - }); - }); - ``` - -2. **Add Event Emissions** - ```typescript - // Query events - smartDns.emit('query-received', { - type: query.type, - domain: query.domain, - source: 'udp' | 'https', - clientIp: string - }); - - smartDns.emit('query-answered', { - cached: boolean, - responseTime: number, - responseCode: string - }); - ``` - -3. **Add Statistics API** - ```typescript - interface IDnsStats { - getTotalQueries(): number; - getQueriesPerSecond(): number; - getCacheStats(): { hits: number, misses: number, hitRate: number }; - getTopDomains(limit: number): Array<{ domain: string, count: number }>; - getQueryTypeBreakdown(): Record; - } - ``` - -### Alternative Approach -Since we control the handler registration in dcrouter: -1. Create a metrics-aware handler wrapper at the dcrouter level -2. Wrap all DNS handlers before registration -3. Track metrics without modifying SmartDNS itself - -## Implementation Strategy - -### Option 1: Fork and Modify Dependencies -- Fork @push.rocks/smartproxy and @push.rocks/smartdns -- Add metrics capabilities directly -- Maintain custom versions -- **Pros**: Clean integration, full control -- **Cons**: Maintenance burden, divergence from upstream - -### Option 2: Wrapper Approach at DcRouter Level -- Create wrapper classes that intercept all operations -- Track metrics at the application level -- No modifications to dependencies -- **Pros**: No dependency modifications, easier to maintain -- **Cons**: May miss some internal events, slightly higher overhead - -### Option 3: Contribute Back to Upstream -- Submit PRs to add metrics capabilities to original packages -- Work with maintainers to add event emissions and stats APIs -- **Pros**: Benefits everyone, no fork maintenance -- **Cons**: Slower process, may not align with maintainer vision - -## Recommendation - -**Use Option 2 (Wrapper Approach)** for immediate implementation: -1. Create `MetricsAwareProxy` and `MetricsAwareDns` wrapper classes -2. Intercept all operations and track metrics -3. Minimal changes to existing codebase -4. Can migrate to Option 3 later if upstream accepts contributions - -This approach allows us to implement comprehensive metrics collection without modifying external dependencies, maintaining compatibility and reducing maintenance burden. \ No newline at end of file diff --git a/readme.plan2.md b/readme.plan2.md deleted file mode 100644 index 6aac189..0000000 --- a/readme.plan2.md +++ /dev/null @@ -1,71 +0,0 @@ -# Network Metrics Integration Status - -## Command: `pnpm run build && curl https://code.foss.global/push.rocks/smartproxy/raw/branch/master/readme.md` - -## Completed Tasks (2025-06-23) - -### ✅ SmartProxy Metrics API Integration -- Updated MetricsManager to use new SmartProxy v19.6.7 metrics API -- Replaced deprecated `getStats()` with `getMetrics()` and `getStatistics()` -- Fixed method calls to use grouped API structure: - - `metrics.connections.active()` for active connections - - `metrics.throughput.instant()` for real-time throughput - - `metrics.connections.topIPs()` for top connected IPs - -### ✅ Removed Mock Data -- Removed hardcoded `0.0.0.0` IPs in security.handler.ts -- Removed `Math.random()` trend data in ops-view-network.ts -- Now using real IP data from SmartProxy metrics - -### ✅ Enhanced Metrics Functionality -- Email metrics: delivery time tracking, top recipients, activity log -- DNS metrics: query rate calculations, response time tracking -- Security metrics: incident logging with severity levels - -### ✅ Fixed Network Traffic Display -- All throughput now shown in bits per second (kbit/s, Mbit/s, Gbit/s) -- Network graph shows separate lines for inbound (green) and outbound (purple) -- Fixed throughput calculation to use same data source as tiles -- Added tooltips showing both timestamp and value - -### ✅ Fixed Requests/sec Tile -- Shows actual request counts (derived from connections) -- Trend line now shows request history, not throughput -- Consistent data between number and trend visualization - -## Current Architecture - -### Data Flow -1. SmartProxy collects metrics via its internal MetricsCollector -2. MetricsManager retrieves data using `smartProxy.getMetrics()` -3. Handlers transform metrics for UI consumption -4. UI components display real-time data with auto-refresh - -### Key Components -- **MetricsManager**: Central metrics aggregation and tracking -- **SmartProxy Integration**: Uses grouped metrics API -- **UI Components**: ops-view-network shows real-time traffic graphs -- **State Management**: Uses appstate for reactive updates - -## Known Limitations -- Request counting is derived from connection data (not true HTTP request counts) -- Some metrics still need backend implementation (e.g., per-connection bytes) -- Historical data limited to current session - -## Testing -```bash -# Build and run -pnpm build -pnpm start - -# Check metrics endpoints -curl http://localhost:4000/api/stats/server -curl http://localhost:4000/api/stats/network -``` - -## Success Metrics -- [x] Real-time throughput data displayed correctly -- [x] No mock data in production UI -- [x] Consistent units across all displays -- [x] Separate in/out traffic visualization -- [x] Working trend lines in stat tiles \ No newline at end of file diff --git a/readme.statsgrid.md b/readme.statsgrid.md deleted file mode 100644 index 896beb6..0000000 --- a/readme.statsgrid.md +++ /dev/null @@ -1,46 +0,0 @@ -# Plan: Implement dees-statsgrid in DCRouter UI - -Command to reread CLAUDE.md: `Read /home/centraluser/eu.central.ingress-2/CLAUDE.md` - -## Overview -Replace the current stats cards with the new dees-statsgrid component from @design.estate/dees-catalog for better visualization and consistency. - -## Implementation Plan - -### 1. Update Overview View (`ops-view-overview.ts`) -- Replace the custom stats cards with dees-statsgrid -- Use appropriate tile types for different metrics: - - `gauge` for CPU and Memory usage - - `number` for Active Connections, Total Requests, etc. - - `trend` for time-series data like requests over time - -### 2. Update Network View (`ops-view-network.ts`) -- Replace the current stats cards section with dees-statsgrid -- Configure tiles for: - - Active Connections (number) - - Requests/sec (number with trend) - - Throughput In/Out (number with units) - - Protocol distribution (percentage) - -### 3. Create Consistent Color Scheme -- Success/Normal: #22c55e (green) -- Warning: #f59e0b (amber) -- Error/Critical: #ef4444 (red) -- Info: #3b82f6 (blue) - -### 4. Add Interactive Features -- Click actions to show detailed views -- Context menu for refresh, export, etc. -- Real-time updates from metrics data - -### 5. Integration Points -- Connect to existing appstate for data -- Use MetricsManager data for real values -- Update on the 1-second refresh interval - -## Benefits -- Consistent UI component usage -- Better visual hierarchy -- Built-in responsive design -- More visualization options (gauges, trends) -- Reduced custom CSS maintenance \ No newline at end of file diff --git a/test_watch/devserver.ts b/test_watch/devserver.ts new file mode 100644 index 0000000..370ec76 --- /dev/null +++ b/test_watch/devserver.ts @@ -0,0 +1,35 @@ +import { DcRouter } from '../ts/index.js'; + +const devRouter = new DcRouter({ + // Configure services as needed for development + // OpsServer always starts on port 3000 + + // Example: Add SmartProxy routes + // smartProxyConfig: { + // routes: [...] + // }, + + // Example: Add email configuration + // emailConfig: { + // ports: [2525], + // hostname: 'localhost', + // domains: [], + // routes: [] + // }, +}); + +console.log('Starting DcRouter in development mode...'); + +await devRouter.start(); + +// Graceful shutdown handlers +const shutdown = async () => { + console.log('\nShutting down...'); + await devRouter.stop(); + process.exit(0); +}; + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); + +console.log('DcRouter dev server running. Press Ctrl+C to stop.'); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 41a70da..ad8af5e 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '2.12.6', + version: '2.13.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index 622abb8..200d7b6 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -14,6 +14,7 @@ import { StorageManager, type IStorageConfig } from './storage/index.js'; import { OpsServer } from './opsserver/index.js'; import { MetricsManager } from './monitoring/index.js'; +import { RadiusServer, type IRadiusServerConfig } from './radius/index.js'; export interface IDcRouterOptions { /** @@ -109,6 +110,12 @@ export interface IDcRouterOptions { /** Storage configuration */ storage?: IStorageConfig; + + /** + * RADIUS server configuration for network authentication + * Enables MAC Authentication Bypass (MAB) and VLAN assignment + */ + radiusConfig?: IRadiusServerConfig; } /** @@ -132,6 +139,7 @@ export class DcRouter { public smartProxy?: plugins.smartproxy.SmartProxy; public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer; public emailServer?: UnifiedEmailServer; + public radiusServer?: RadiusServer; public storageManager: StorageManager; public opsServer: OpsServer; public metricsManager?: MetricsManager; @@ -181,11 +189,16 @@ export class DcRouter { } // Set up DNS server if configured with nameservers and scopes - if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0 && + if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0 && this.options.dnsScopes && this.options.dnsScopes.length > 0) { await this.setupDnsWithSocketHandler(); } - + + // Set up RADIUS server if configured + if (this.options.radiusConfig) { + await this.setupRadiusServer(); + } + this.logStartupSummary(); } catch (error) { console.error('❌ Error starting DcRouter:', error); @@ -261,12 +274,23 @@ export class DcRouter { } } + // RADIUS service summary + if (this.radiusServer && this.options.radiusConfig) { + console.log('\n🔐 RADIUS Service:'); + console.log(` ├─ Auth Port: ${this.options.radiusConfig.authPort || 1812}`); + console.log(` ├─ Acct Port: ${this.options.radiusConfig.acctPort || 1813}`); + console.log(` ├─ Clients configured: ${this.options.radiusConfig.clients?.length || 0}`); + const vlanStats = this.radiusServer.getVlanManager().getStats(); + console.log(` ├─ VLAN mappings: ${vlanStats.totalMappings}`); + console.log(` └─ Accounting: ${this.options.radiusConfig.accounting?.enabled ? 'Enabled' : 'Disabled'}`); + } + // Storage summary if (this.storageManager && this.options.storage) { console.log('\n💾 Storage:'); console.log(` └─ Path: ${this.options.storage.fsPath || 'default'}`); } - + console.log('\n✅ All services are running\n'); } @@ -582,16 +606,21 @@ export class DcRouter { await Promise.all([ // Stop metrics manager if running this.metricsManager ? this.metricsManager.stop().catch(err => console.error('Error stopping MetricsManager:', err)) : Promise.resolve(), - + // Stop unified email server if running this.emailServer ? this.emailServer.stop().catch(err => console.error('Error stopping email server:', err)) : Promise.resolve(), - + // Stop HTTP SmartProxy if running this.smartProxy ? this.smartProxy.stop().catch(err => console.error('Error stopping SmartProxy:', err)) : Promise.resolve(), - + // Stop DNS server if running - this.dnsServer ? - this.dnsServer.stop().catch(err => console.error('Error stopping DNS server:', err)) : + this.dnsServer ? + this.dnsServer.stop().catch(err => console.error('Error stopping DNS server:', err)) : + Promise.resolve(), + + // Stop RADIUS server if running + this.radiusServer ? + this.radiusServer.stop().catch(err => console.error('Error stopping RADIUS server:', err)) : Promise.resolve() ]); @@ -1338,9 +1367,47 @@ export class DcRouter { } }; } + + /** + * Set up RADIUS server for network authentication + */ + private async setupRadiusServer(): Promise { + if (!this.options.radiusConfig) { + return; + } + + logger.log('info', 'Setting up RADIUS server...'); + + this.radiusServer = new RadiusServer(this.options.radiusConfig, this.storageManager); + await this.radiusServer.start(); + + logger.log('info', `RADIUS server started on ports ${this.options.radiusConfig.authPort || 1812} (auth) and ${this.options.radiusConfig.acctPort || 1813} (acct)`); + } + + /** + * Update RADIUS configuration at runtime + */ + public async updateRadiusConfig(config: IRadiusServerConfig): Promise { + // Stop existing RADIUS server if running + if (this.radiusServer) { + await this.radiusServer.stop(); + this.radiusServer = undefined; + } + + // Update configuration + this.options.radiusConfig = config; + + // Start with new configuration + await this.setupRadiusServer(); + + logger.log('info', 'RADIUS configuration updated'); + } } // Re-export email server types for convenience export type { IUnifiedEmailServerOptions }; +// Re-export RADIUS types for convenience +export type { IRadiusServerConfig }; + export default DcRouter; diff --git a/ts/index.ts b/ts/index.ts index fd934e3..08c1188 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -4,4 +4,7 @@ export * from './mail/index.js'; // DcRouter export * from './classes.dcrouter.js'; +// RADIUS module +export * from './radius/index.js'; + export const runCli = async () => {} \ No newline at end of file diff --git a/ts/opsserver/classes.opsserver.ts b/ts/opsserver/classes.opsserver.ts index 17e2706..6bd762c 100644 --- a/ts/opsserver/classes.opsserver.ts +++ b/ts/opsserver/classes.opsserver.ts @@ -16,6 +16,7 @@ export class OpsServer { private logsHandler: handlers.LogsHandler; private securityHandler: handlers.SecurityHandler; private statsHandler: handlers.StatsHandler; + private radiusHandler: handlers.RadiusHandler; constructor(dcRouterRefArg: DcRouter) { this.dcRouterRef = dcRouterRefArg; @@ -53,7 +54,8 @@ export class OpsServer { this.logsHandler = new handlers.LogsHandler(this); this.securityHandler = new handlers.SecurityHandler(this); this.statsHandler = new handlers.StatsHandler(this); - + this.radiusHandler = new handlers.RadiusHandler(this); + console.log('✅ OpsServer TypedRequest handlers initialized'); } diff --git a/ts/opsserver/handlers/index.ts b/ts/opsserver/handlers/index.ts index f867286..fb72c50 100644 --- a/ts/opsserver/handlers/index.ts +++ b/ts/opsserver/handlers/index.ts @@ -2,4 +2,5 @@ export * from './admin.handler.js'; export * from './config.handler.js'; export * from './logs.handler.js'; export * from './security.handler.js'; -export * from './stats.handler.js'; \ No newline at end of file +export * from './stats.handler.js'; +export * from './radius.handler.js'; \ No newline at end of file diff --git a/ts/opsserver/handlers/radius.handler.ts b/ts/opsserver/handlers/radius.handler.ts new file mode 100644 index 0000000..c3e3c96 --- /dev/null +++ b/ts/opsserver/handlers/radius.handler.ts @@ -0,0 +1,405 @@ +import * as plugins from '../../plugins.js'; +import type { OpsServer } from '../classes.opsserver.js'; +import * as interfaces from '../../../ts_interfaces/index.js'; + +export class RadiusHandler { + public typedrouter = new plugins.typedrequest.TypedRouter(); + + constructor(private opsServerRef: OpsServer) { + // Add this handler's router to the parent + this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); + this.registerHandlers(); + } + + private registerHandlers(): void { + // ======================================================================== + // RADIUS Client Management + // ======================================================================== + + // Get all RADIUS clients + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getRadiusClients', + async (dataArg, toolsArg) => { + const radiusServer = this.opsServerRef.dcRouterRef.radiusServer; + + if (!radiusServer) { + return { clients: [] }; + } + + const clients = radiusServer.getClients(); + return { + clients: clients.map(c => ({ + name: c.name, + ipRange: c.ipRange, + description: c.description, + enabled: c.enabled, + })), + }; + } + ) + ); + + // Add or update a RADIUS client + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'setRadiusClient', + async (dataArg, toolsArg) => { + const radiusServer = this.opsServerRef.dcRouterRef.radiusServer; + + if (!radiusServer) { + return { success: false, message: 'RADIUS server not configured' }; + } + + try { + await radiusServer.addClient(dataArg.client); + return { success: true }; + } catch (error) { + return { success: false, message: error.message }; + } + } + ) + ); + + // Remove a RADIUS client + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'removeRadiusClient', + async (dataArg, toolsArg) => { + const radiusServer = this.opsServerRef.dcRouterRef.radiusServer; + + if (!radiusServer) { + return { success: false, message: 'RADIUS server not configured' }; + } + + const removed = radiusServer.removeClient(dataArg.name); + return { + success: removed, + message: removed ? undefined : 'Client not found', + }; + } + ) + ); + + // ======================================================================== + // VLAN Mapping Management + // ======================================================================== + + // Get all VLAN mappings + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getVlanMappings', + async (dataArg, toolsArg) => { + const radiusServer = this.opsServerRef.dcRouterRef.radiusServer; + + if (!radiusServer) { + return { + mappings: [], + config: { defaultVlan: 1, allowUnknownMacs: true }, + }; + } + + const vlanManager = radiusServer.getVlanManager(); + const mappings = vlanManager.getAllMappings(); + const config = vlanManager.getConfig(); + + return { + mappings: mappings.map(m => ({ + mac: m.mac, + vlan: m.vlan, + description: m.description, + enabled: m.enabled, + createdAt: m.createdAt, + updatedAt: m.updatedAt, + })), + config: { + defaultVlan: config.defaultVlan, + allowUnknownMacs: config.allowUnknownMacs, + }, + }; + } + ) + ); + + // Add or update a VLAN mapping + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'setVlanMapping', + async (dataArg, toolsArg) => { + const radiusServer = this.opsServerRef.dcRouterRef.radiusServer; + + if (!radiusServer) { + return { success: false, message: 'RADIUS server not configured' }; + } + + try { + const vlanManager = radiusServer.getVlanManager(); + const mapping = await vlanManager.addMapping(dataArg.mapping); + return { + success: true, + mapping: { + mac: mapping.mac, + vlan: mapping.vlan, + description: mapping.description, + enabled: mapping.enabled, + createdAt: mapping.createdAt, + updatedAt: mapping.updatedAt, + }, + }; + } catch (error) { + return { success: false, message: error.message }; + } + } + ) + ); + + // Remove a VLAN mapping + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'removeVlanMapping', + async (dataArg, toolsArg) => { + const radiusServer = this.opsServerRef.dcRouterRef.radiusServer; + + if (!radiusServer) { + return { success: false, message: 'RADIUS server not configured' }; + } + + const vlanManager = radiusServer.getVlanManager(); + const removed = await vlanManager.removeMapping(dataArg.mac); + return { + success: removed, + message: removed ? undefined : 'Mapping not found', + }; + } + ) + ); + + // Update VLAN configuration + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'updateVlanConfig', + async (dataArg, toolsArg) => { + const radiusServer = this.opsServerRef.dcRouterRef.radiusServer; + + if (!radiusServer) { + return { + success: false, + config: { defaultVlan: 1, allowUnknownMacs: true }, + }; + } + + const vlanManager = radiusServer.getVlanManager(); + vlanManager.updateConfig({ + defaultVlan: dataArg.defaultVlan, + allowUnknownMacs: dataArg.allowUnknownMacs, + }); + + const config = vlanManager.getConfig(); + return { + success: true, + config: { + defaultVlan: config.defaultVlan, + allowUnknownMacs: config.allowUnknownMacs, + }, + }; + } + ) + ); + + // Test VLAN assignment + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'testVlanAssignment', + async (dataArg, toolsArg) => { + const radiusServer = this.opsServerRef.dcRouterRef.radiusServer; + + if (!radiusServer) { + return { assigned: false, vlan: 0, isDefault: false }; + } + + const vlanManager = radiusServer.getVlanManager(); + const result = vlanManager.assignVlan(dataArg.mac); + + return { + assigned: result.assigned, + vlan: result.vlan, + isDefault: result.isDefault, + matchedRule: result.matchedRule + ? { + mac: result.matchedRule.mac, + vlan: result.matchedRule.vlan, + description: result.matchedRule.description, + } + : undefined, + }; + } + ) + ); + + // ======================================================================== + // Accounting / Session Management + // ======================================================================== + + // Get active sessions + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getRadiusSessions', + async (dataArg, toolsArg) => { + const radiusServer = this.opsServerRef.dcRouterRef.radiusServer; + + if (!radiusServer) { + return { sessions: [], totalCount: 0 }; + } + + const accountingManager = radiusServer.getAccountingManager(); + let sessions = accountingManager.getActiveSessions(); + + // Apply filters + if (dataArg.filter) { + if (dataArg.filter.username) { + sessions = sessions.filter(s => s.username === dataArg.filter!.username); + } + if (dataArg.filter.nasIpAddress) { + sessions = sessions.filter(s => s.nasIpAddress === dataArg.filter!.nasIpAddress); + } + if (dataArg.filter.vlanId !== undefined) { + sessions = sessions.filter(s => s.vlanId === dataArg.filter!.vlanId); + } + } + + return { + sessions: sessions.map(s => ({ + sessionId: s.sessionId, + username: s.username, + macAddress: s.macAddress, + nasIpAddress: s.nasIpAddress, + nasIdentifier: s.nasIdentifier, + vlanId: s.vlanId, + framedIpAddress: s.framedIpAddress, + startTime: s.startTime, + lastUpdateTime: s.lastUpdateTime, + status: s.status, + inputOctets: s.inputOctets, + outputOctets: s.outputOctets, + sessionTime: s.sessionTime, + })), + totalCount: sessions.length, + }; + } + ) + ); + + // Disconnect a session + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'disconnectRadiusSession', + async (dataArg, toolsArg) => { + const radiusServer = this.opsServerRef.dcRouterRef.radiusServer; + + if (!radiusServer) { + return { success: false, message: 'RADIUS server not configured' }; + } + + const accountingManager = radiusServer.getAccountingManager(); + const disconnected = await accountingManager.disconnectSession( + dataArg.sessionId, + dataArg.reason || 'AdminReset' + ); + + return { + success: disconnected, + message: disconnected ? undefined : 'Session not found', + }; + } + ) + ); + + // Get accounting summary + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getRadiusAccountingSummary', + async (dataArg, toolsArg) => { + const radiusServer = this.opsServerRef.dcRouterRef.radiusServer; + + if (!radiusServer) { + return { + summary: { + periodStart: dataArg.startTime, + periodEnd: dataArg.endTime, + totalSessions: 0, + activeSessions: 0, + totalInputBytes: 0, + totalOutputBytes: 0, + totalSessionTime: 0, + averageSessionDuration: 0, + uniqueUsers: 0, + sessionsByVlan: {}, + topUsersByTraffic: [], + }, + }; + } + + const accountingManager = radiusServer.getAccountingManager(); + const summary = await accountingManager.getSummary(dataArg.startTime, dataArg.endTime); + + return { summary }; + } + ) + ); + + // ======================================================================== + // Statistics + // ======================================================================== + + // Get RADIUS statistics + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getRadiusStatistics', + async (dataArg, toolsArg) => { + const radiusServer = this.opsServerRef.dcRouterRef.radiusServer; + + if (!radiusServer) { + return { + stats: { + running: false, + uptime: 0, + authRequests: 0, + authAccepts: 0, + authRejects: 0, + accountingRequests: 0, + activeSessions: 0, + vlanMappings: 0, + clients: 0, + }, + vlanStats: { + totalMappings: 0, + enabledMappings: 0, + exactMatches: 0, + ouiPatterns: 0, + wildcardPatterns: 0, + }, + accountingStats: { + activeSessions: 0, + totalSessionsStarted: 0, + totalSessionsStopped: 0, + totalInputBytes: 0, + totalOutputBytes: 0, + interimUpdatesReceived: 0, + }, + }; + } + + const stats = radiusServer.getStats(); + const vlanStats = radiusServer.getVlanManager().getStats(); + const accountingStats = radiusServer.getAccountingManager().getStats(); + + return { + stats, + vlanStats, + accountingStats, + }; + } + ) + ); + } +} diff --git a/ts/plugins.ts b/ts/plugins.ts index 448bbe5..12b755c 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -55,12 +55,13 @@ import * as smartnetwork from '@push.rocks/smartnetwork'; import * as smartpath from '@push.rocks/smartpath'; import * as smartproxy from '@push.rocks/smartproxy'; import * as smartpromise from '@push.rocks/smartpromise'; +import * as smartradius from '@push.rocks/smartradius'; import * as smartrequest from '@push.rocks/smartrequest'; import * as smartrule from '@push.rocks/smartrule'; import * as smartrx from '@push.rocks/smartrx'; import * as smartunique from '@push.rocks/smartunique'; -export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmail, smartmetrics, smartnetwork, smartpath, smartproxy, smartpromise, smartrequest, smartrule, smartrx, smartunique }; +export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmail, smartmetrics, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrule, smartrx, smartunique }; // Define SmartLog types for use in error handling export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug'; diff --git a/ts/radius/classes.accounting.manager.ts b/ts/radius/classes.accounting.manager.ts new file mode 100644 index 0000000..54a7ad5 --- /dev/null +++ b/ts/radius/classes.accounting.manager.ts @@ -0,0 +1,607 @@ +import * as plugins from '../plugins.js'; +import { logger } from '../logger.js'; +import type { StorageManager } from '../storage/index.js'; + +/** + * RADIUS accounting session + */ +export interface IAccountingSession { + /** Unique session ID from RADIUS */ + sessionId: string; + /** Username (often MAC address for MAB) */ + username: string; + /** MAC address of the device */ + macAddress?: string; + /** NAS IP address (switch/AP) */ + nasIpAddress: string; + /** NAS port (physical or virtual) */ + nasPort?: number; + /** NAS port type */ + nasPortType?: string; + /** NAS identifier (name) */ + nasIdentifier?: string; + /** Assigned VLAN */ + vlanId?: number; + /** Assigned IP address (if any) */ + framedIpAddress?: string; + /** Called station ID (usually BSSID for wireless) */ + calledStationId?: string; + /** Calling station ID (usually client MAC) */ + callingStationId?: string; + /** Session start time */ + startTime: number; + /** Session end time (0 if active) */ + endTime: number; + /** Last update time (interim accounting) */ + lastUpdateTime: number; + /** Session status */ + status: 'active' | 'stopped' | 'terminated'; + /** Termination cause (if stopped) */ + terminateCause?: string; + /** Input octets (bytes received by NAS from client) */ + inputOctets: number; + /** Output octets (bytes sent by NAS to client) */ + outputOctets: number; + /** Input packets */ + inputPackets: number; + /** Output packets */ + outputPackets: number; + /** Session duration in seconds */ + sessionTime: number; + /** Service type */ + serviceType?: string; +} + +/** + * Accounting summary for a time period + */ +export interface IAccountingSummary { + /** Time period start */ + periodStart: number; + /** Time period end */ + periodEnd: number; + /** Total sessions */ + totalSessions: number; + /** Active sessions */ + activeSessions: number; + /** Total input bytes */ + totalInputBytes: number; + /** Total output bytes */ + totalOutputBytes: number; + /** Total session time (seconds) */ + totalSessionTime: number; + /** Average session duration (seconds) */ + averageSessionDuration: number; + /** Unique users/devices */ + uniqueUsers: number; + /** Sessions by VLAN */ + sessionsByVlan: Record; + /** Top users by traffic */ + topUsersByTraffic: Array<{ username: string; totalBytes: number }>; +} + +/** + * Accounting manager configuration + */ +export interface IAccountingManagerConfig { + /** Storage key prefix */ + storagePrefix?: string; + /** Session retention period in days (default: 30) */ + retentionDays?: number; + /** Enable detailed session logging */ + detailedLogging?: boolean; + /** Maximum active sessions to track in memory */ + maxActiveSessions?: number; +} + +/** + * Manages RADIUS accounting data including: + * - Session tracking (start/stop/interim) + * - Data usage tracking (bytes in/out) + * - Session history and retention + * - Billing reports and summaries + */ +export class AccountingManager { + private activeSessions: Map = new Map(); + private config: Required; + private storageManager?: StorageManager; + + // Counters for statistics + private stats = { + totalSessionsStarted: 0, + totalSessionsStopped: 0, + totalInputBytes: 0, + totalOutputBytes: 0, + interimUpdatesReceived: 0, + }; + + constructor(config?: IAccountingManagerConfig, storageManager?: StorageManager) { + this.config = { + storagePrefix: config?.storagePrefix ?? '/radius/accounting', + retentionDays: config?.retentionDays ?? 30, + detailedLogging: config?.detailedLogging ?? false, + maxActiveSessions: config?.maxActiveSessions ?? 10000, + }; + this.storageManager = storageManager; + } + + /** + * Initialize the accounting manager + */ + async initialize(): Promise { + if (this.storageManager) { + await this.loadActiveSessions(); + } + logger.log('info', `AccountingManager initialized with ${this.activeSessions.size} active sessions`); + } + + /** + * Handle accounting start request + */ + async handleAccountingStart(data: { + sessionId: string; + username: string; + macAddress?: string; + nasIpAddress: string; + nasPort?: number; + nasPortType?: string; + nasIdentifier?: string; + vlanId?: number; + framedIpAddress?: string; + calledStationId?: string; + callingStationId?: string; + serviceType?: string; + }): Promise { + const now = Date.now(); + + const session: IAccountingSession = { + sessionId: data.sessionId, + username: data.username, + macAddress: data.macAddress, + nasIpAddress: data.nasIpAddress, + nasPort: data.nasPort, + nasPortType: data.nasPortType, + nasIdentifier: data.nasIdentifier, + vlanId: data.vlanId, + framedIpAddress: data.framedIpAddress, + calledStationId: data.calledStationId, + callingStationId: data.callingStationId, + serviceType: data.serviceType, + startTime: now, + endTime: 0, + lastUpdateTime: now, + status: 'active', + inputOctets: 0, + outputOctets: 0, + inputPackets: 0, + outputPackets: 0, + sessionTime: 0, + }; + + // Check if we're at capacity + if (this.activeSessions.size >= this.config.maxActiveSessions) { + // Remove oldest session + const oldest = this.findOldestSession(); + if (oldest) { + await this.evictSession(oldest); + } + } + + this.activeSessions.set(data.sessionId, session); + this.stats.totalSessionsStarted++; + + if (this.config.detailedLogging) { + logger.log('info', `Accounting Start: session=${data.sessionId}, user=${data.username}, NAS=${data.nasIpAddress}`); + } + + // Persist session + if (this.storageManager) { + await this.persistSession(session); + } + } + + /** + * Handle accounting interim update request + */ + async handleAccountingUpdate(data: { + sessionId: string; + inputOctets?: number; + outputOctets?: number; + inputPackets?: number; + outputPackets?: number; + sessionTime?: number; + }): Promise { + const session = this.activeSessions.get(data.sessionId); + + if (!session) { + logger.log('warn', `Interim update for unknown session: ${data.sessionId}`); + return; + } + + // Update session metrics + if (data.inputOctets !== undefined) { + session.inputOctets = data.inputOctets; + } + if (data.outputOctets !== undefined) { + session.outputOctets = data.outputOctets; + } + if (data.inputPackets !== undefined) { + session.inputPackets = data.inputPackets; + } + if (data.outputPackets !== undefined) { + session.outputPackets = data.outputPackets; + } + if (data.sessionTime !== undefined) { + session.sessionTime = data.sessionTime; + } + + session.lastUpdateTime = Date.now(); + this.stats.interimUpdatesReceived++; + + if (this.config.detailedLogging) { + logger.log('debug', `Accounting Interim: session=${data.sessionId}, in=${data.inputOctets}, out=${data.outputOctets}`); + } + + // Update persisted session + if (this.storageManager) { + await this.persistSession(session); + } + } + + /** + * Handle accounting stop request + */ + async handleAccountingStop(data: { + sessionId: string; + terminateCause?: string; + inputOctets?: number; + outputOctets?: number; + inputPackets?: number; + outputPackets?: number; + sessionTime?: number; + }): Promise { + const session = this.activeSessions.get(data.sessionId); + + if (!session) { + logger.log('warn', `Stop for unknown session: ${data.sessionId}`); + return; + } + + // Update final metrics + if (data.inputOctets !== undefined) { + session.inputOctets = data.inputOctets; + } + if (data.outputOctets !== undefined) { + session.outputOctets = data.outputOctets; + } + if (data.inputPackets !== undefined) { + session.inputPackets = data.inputPackets; + } + if (data.outputPackets !== undefined) { + session.outputPackets = data.outputPackets; + } + if (data.sessionTime !== undefined) { + session.sessionTime = data.sessionTime; + } + + session.endTime = Date.now(); + session.lastUpdateTime = session.endTime; + session.status = 'stopped'; + session.terminateCause = data.terminateCause; + + // Update global stats + this.stats.totalSessionsStopped++; + this.stats.totalInputBytes += session.inputOctets; + this.stats.totalOutputBytes += session.outputOctets; + + if (this.config.detailedLogging) { + logger.log('info', `Accounting Stop: session=${data.sessionId}, duration=${session.sessionTime}s, in=${session.inputOctets}, out=${session.outputOctets}`); + } + + // Archive the session + if (this.storageManager) { + await this.archiveSession(session); + } + + // Remove from active sessions + this.activeSessions.delete(data.sessionId); + } + + /** + * Get an active session by ID + */ + getSession(sessionId: string): IAccountingSession | undefined { + return this.activeSessions.get(sessionId); + } + + /** + * Get all active sessions + */ + getActiveSessions(): IAccountingSession[] { + return Array.from(this.activeSessions.values()); + } + + /** + * Get active sessions by username + */ + getSessionsByUsername(username: string): IAccountingSession[] { + return Array.from(this.activeSessions.values()).filter(s => s.username === username); + } + + /** + * Get active sessions by NAS IP + */ + getSessionsByNas(nasIpAddress: string): IAccountingSession[] { + return Array.from(this.activeSessions.values()).filter(s => s.nasIpAddress === nasIpAddress); + } + + /** + * Get active sessions by VLAN + */ + getSessionsByVlan(vlanId: number): IAccountingSession[] { + return Array.from(this.activeSessions.values()).filter(s => s.vlanId === vlanId); + } + + /** + * Get accounting summary for a time period + */ + async getSummary(startTime: number, endTime: number): Promise { + // Get archived sessions for the time period + const archivedSessions = await this.getArchivedSessions(startTime, endTime); + + // Combine with active sessions that started within the period + const activeSessions = Array.from(this.activeSessions.values()).filter( + s => s.startTime >= startTime && s.startTime <= endTime + ); + + const allSessions = [...archivedSessions, ...activeSessions]; + + // Calculate summary + let totalInputBytes = 0; + let totalOutputBytes = 0; + let totalSessionTime = 0; + const uniqueUsers = new Set(); + const sessionsByVlan: Record = {}; + const userTraffic: Record = {}; + + for (const session of allSessions) { + totalInputBytes += session.inputOctets; + totalOutputBytes += session.outputOctets; + totalSessionTime += session.sessionTime; + uniqueUsers.add(session.username); + + if (session.vlanId !== undefined) { + sessionsByVlan[session.vlanId] = (sessionsByVlan[session.vlanId] || 0) + 1; + } + + const userBytes = session.inputOctets + session.outputOctets; + userTraffic[session.username] = (userTraffic[session.username] || 0) + userBytes; + } + + // Top users by traffic + const topUsersByTraffic = Object.entries(userTraffic) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([username, totalBytes]) => ({ username, totalBytes })); + + return { + periodStart: startTime, + periodEnd: endTime, + totalSessions: allSessions.length, + activeSessions: activeSessions.length, + totalInputBytes, + totalOutputBytes, + totalSessionTime, + averageSessionDuration: allSessions.length > 0 ? totalSessionTime / allSessions.length : 0, + uniqueUsers: uniqueUsers.size, + sessionsByVlan, + topUsersByTraffic, + }; + } + + /** + * Get statistics + */ + getStats(): { + activeSessions: number; + totalSessionsStarted: number; + totalSessionsStopped: number; + totalInputBytes: number; + totalOutputBytes: number; + interimUpdatesReceived: number; + } { + return { + activeSessions: this.activeSessions.size, + ...this.stats, + }; + } + + /** + * Disconnect a session (admin action) + */ + async disconnectSession(sessionId: string, reason: string = 'AdminReset'): Promise { + const session = this.activeSessions.get(sessionId); + if (!session) { + return false; + } + + await this.handleAccountingStop({ + sessionId, + terminateCause: reason, + sessionTime: Math.floor((Date.now() - session.startTime) / 1000), + }); + + return true; + } + + /** + * Clean up old archived sessions based on retention policy + */ + async cleanupOldSessions(): Promise { + if (!this.storageManager) { + return 0; + } + + const cutoffTime = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1000; + let deletedCount = 0; + + try { + const keys = await this.storageManager.list(`${this.config.storagePrefix}/archive/`); + + for (const key of keys) { + try { + const session = await this.storageManager.getJSON(key); + if (session && session.endTime > 0 && session.endTime < cutoffTime) { + await this.storageManager.delete(key); + deletedCount++; + } + } catch (error) { + // Ignore individual errors + } + } + + if (deletedCount > 0) { + logger.log('info', `Cleaned up ${deletedCount} old accounting sessions`); + } + } catch (error) { + logger.log('error', `Failed to cleanup old sessions: ${error.message}`); + } + + return deletedCount; + } + + /** + * Find the oldest active session + */ + private findOldestSession(): string | null { + let oldestTime = Infinity; + let oldestSessionId: string | null = null; + + for (const [sessionId, session] of this.activeSessions) { + if (session.lastUpdateTime < oldestTime) { + oldestTime = session.lastUpdateTime; + oldestSessionId = sessionId; + } + } + + return oldestSessionId; + } + + /** + * Evict a session from memory + */ + private async evictSession(sessionId: string): Promise { + const session = this.activeSessions.get(sessionId); + if (session) { + session.status = 'terminated'; + session.terminateCause = 'SessionEvicted'; + session.endTime = Date.now(); + + if (this.storageManager) { + await this.archiveSession(session); + } + + this.activeSessions.delete(sessionId); + logger.log('warn', `Evicted session ${sessionId} due to capacity limit`); + } + } + + /** + * Load active sessions from storage + */ + private async loadActiveSessions(): Promise { + if (!this.storageManager) { + return; + } + + try { + const keys = await this.storageManager.list(`${this.config.storagePrefix}/active/`); + + for (const key of keys) { + try { + const session = await this.storageManager.getJSON(key); + if (session && session.status === 'active') { + this.activeSessions.set(session.sessionId, session); + } + } catch (error) { + // Ignore individual errors + } + } + } catch (error) { + logger.log('warn', `Failed to load active sessions: ${error.message}`); + } + } + + /** + * Persist a session to storage + */ + private async persistSession(session: IAccountingSession): Promise { + if (!this.storageManager) { + return; + } + + const key = `${this.config.storagePrefix}/active/${session.sessionId}.json`; + try { + await this.storageManager.setJSON(key, session); + } catch (error) { + logger.log('error', `Failed to persist session ${session.sessionId}: ${error.message}`); + } + } + + /** + * Archive a completed session + */ + private async archiveSession(session: IAccountingSession): Promise { + if (!this.storageManager) { + return; + } + + try { + // Remove from active + const activeKey = `${this.config.storagePrefix}/active/${session.sessionId}.json`; + await this.storageManager.delete(activeKey); + + // Add to archive with date-based path + const date = new Date(session.endTime); + const archiveKey = `${this.config.storagePrefix}/archive/${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}/${session.sessionId}.json`; + await this.storageManager.setJSON(archiveKey, session); + } catch (error) { + logger.log('error', `Failed to archive session ${session.sessionId}: ${error.message}`); + } + } + + /** + * Get archived sessions for a time period + */ + private async getArchivedSessions(startTime: number, endTime: number): Promise { + if (!this.storageManager) { + return []; + } + + const sessions: IAccountingSession[] = []; + + try { + const keys = await this.storageManager.list(`${this.config.storagePrefix}/archive/`); + + for (const key of keys) { + try { + const session = await this.storageManager.getJSON(key); + if ( + session && + session.endTime > 0 && + session.startTime <= endTime && + session.endTime >= startTime + ) { + sessions.push(session); + } + } catch (error) { + // Ignore individual errors + } + } + } catch (error) { + logger.log('warn', `Failed to get archived sessions: ${error.message}`); + } + + return sessions; + } +} diff --git a/ts/radius/classes.radius.server.ts b/ts/radius/classes.radius.server.ts new file mode 100644 index 0000000..ac274d5 --- /dev/null +++ b/ts/radius/classes.radius.server.ts @@ -0,0 +1,532 @@ +import * as plugins from '../plugins.js'; +import { logger } from '../logger.js'; +import type { StorageManager } from '../storage/index.js'; +import { VlanManager, type IMacVlanMapping, type IVlanManagerConfig } from './classes.vlan.manager.js'; +import { AccountingManager, type IAccountingSession, type IAccountingManagerConfig } from './classes.accounting.manager.js'; + +/** + * RADIUS client (NAS) configuration + */ +export interface IRadiusClient { + /** Client name for identification */ + name: string; + /** IP address or CIDR range */ + ipRange: string; + /** Shared secret for this client */ + secret: string; + /** Optional description */ + description?: string; + /** Whether this client is enabled */ + enabled: boolean; +} + +/** + * RADIUS server configuration + */ +export interface IRadiusServerConfig { + /** Authentication port (default: 1812) */ + authPort?: number; + /** Accounting port (default: 1813) */ + acctPort?: number; + /** Bind address (default: 0.0.0.0) */ + bindAddress?: string; + /** NAS clients configuration */ + clients: IRadiusClient[]; + /** VLAN assignment configuration */ + vlanAssignment?: IVlanManagerConfig & { + /** Static MAC to VLAN mappings */ + mappings?: Array>; + }; + /** Accounting configuration */ + accounting?: IAccountingManagerConfig & { + /** Whether accounting is enabled */ + enabled: boolean; + }; +} + +/** + * RADIUS authentication result + */ +export interface IRadiusAuthResult { + /** Whether authentication was successful */ + success: boolean; + /** Reject reason (if not successful) */ + rejectReason?: string; + /** Reply message to send to client */ + replyMessage?: string; + /** Session timeout in seconds */ + sessionTimeout?: number; + /** Idle timeout in seconds */ + idleTimeout?: number; + /** VLAN to assign */ + vlanId?: number; + /** Framed IP address to assign */ + framedIpAddress?: string; +} + +/** + * Authentication request data from RADIUS + */ +export interface IAuthRequestData { + username: string; + password?: string; + nasIpAddress: string; + nasPort?: number; + nasPortType?: string; + nasIdentifier?: string; + calledStationId?: string; + callingStationId?: string; + serviceType?: string; + framedMtu?: number; +} + +/** + * RADIUS Server wrapper that provides: + * - MAC Authentication Bypass (MAB) for network devices + * - VLAN assignment based on MAC address + * - Accounting for session tracking and billing + * - Integration with SmartProxy routing + */ +export class RadiusServer { + private radiusServer?: plugins.smartradius.RadiusServer; + private vlanManager: VlanManager; + private accountingManager: AccountingManager; + private config: IRadiusServerConfig; + private storageManager?: StorageManager; + private clientSecrets: Map = new Map(); + private running: boolean = false; + + // Statistics + private stats = { + authRequests: 0, + authAccepts: 0, + authRejects: 0, + accountingRequests: 0, + startTime: 0, + }; + + constructor(config: IRadiusServerConfig, storageManager?: StorageManager) { + this.config = { + authPort: config.authPort ?? 1812, + acctPort: config.acctPort ?? 1813, + bindAddress: config.bindAddress ?? '0.0.0.0', + ...config, + }; + this.storageManager = storageManager; + + // Initialize VLAN manager + this.vlanManager = new VlanManager(config.vlanAssignment, storageManager); + + // Initialize accounting manager + this.accountingManager = new AccountingManager(config.accounting, storageManager); + } + + /** + * Start the RADIUS server + */ + async start(): Promise { + if (this.running) { + logger.log('warn', 'RADIUS server is already running'); + return; + } + + logger.log('info', `Starting RADIUS server on ${this.config.bindAddress}:${this.config.authPort} (auth) and ${this.config.acctPort} (acct)`); + + // Initialize managers + await this.vlanManager.initialize(); + await this.accountingManager.initialize(); + + // Import static VLAN mappings if provided + if (this.config.vlanAssignment?.mappings) { + await this.vlanManager.importMappings(this.config.vlanAssignment.mappings); + } + + // Build client secrets map + this.buildClientSecretsMap(); + + // Create the RADIUS server + this.radiusServer = new plugins.smartradius.RadiusServer({ + authPort: this.config.authPort, + acctPort: this.config.acctPort, + bindAddress: this.config.bindAddress, + defaultSecret: this.getDefaultSecret(), + authenticationHandler: this.handleAuthentication.bind(this), + accountingHandler: this.handleAccounting.bind(this), + }); + + // Configure per-client secrets + for (const [ip, secret] of this.clientSecrets) { + this.radiusServer.setClientSecret(ip, secret); + } + + // Start the server + await this.radiusServer.start(); + + this.running = true; + this.stats.startTime = Date.now(); + + logger.log('info', `RADIUS server started with ${this.config.clients.length} configured clients`); + } + + /** + * Stop the RADIUS server + */ + async stop(): Promise { + if (!this.running) { + return; + } + + logger.log('info', 'Stopping RADIUS server...'); + + if (this.radiusServer) { + await this.radiusServer.stop(); + this.radiusServer = undefined; + } + + this.running = false; + logger.log('info', 'RADIUS server stopped'); + } + + /** + * Handle authentication request + */ + private async handleAuthentication(request: any): Promise { + this.stats.authRequests++; + + const authData: IAuthRequestData = { + username: request.attributes?.UserName || '', + password: request.attributes?.UserPassword, + nasIpAddress: request.attributes?.NasIpAddress || request.source?.address || '', + nasPort: request.attributes?.NasPort, + nasPortType: request.attributes?.NasPortType, + nasIdentifier: request.attributes?.NasIdentifier, + calledStationId: request.attributes?.CalledStationId, + callingStationId: request.attributes?.CallingStationId, + serviceType: request.attributes?.ServiceType, + }; + + logger.log('debug', `RADIUS Auth Request: user=${authData.username}, NAS=${authData.nasIpAddress}`); + + // Perform MAC Authentication Bypass (MAB) + // In MAB, the username is typically the MAC address + const result = await this.performMabAuthentication(authData); + + if (result.success) { + this.stats.authAccepts++; + logger.log('info', `RADIUS Auth Accept: user=${authData.username}, VLAN=${result.vlanId}`); + + // Build response with VLAN attributes + const response: any = { + code: plugins.smartradius.ERadiusCode.AccessAccept, + replyMessage: result.replyMessage, + }; + + // Add VLAN attributes if assigned + if (result.vlanId !== undefined) { + response.tunnelType = 13; // VLAN + response.tunnelMediumType = 6; // IEEE 802 + response.tunnelPrivateGroupId = String(result.vlanId); + } + + // Add session timeout if specified + if (result.sessionTimeout) { + response.sessionTimeout = result.sessionTimeout; + } + + // Add idle timeout if specified + if (result.idleTimeout) { + response.idleTimeout = result.idleTimeout; + } + + // Add framed IP if specified + if (result.framedIpAddress) { + response.framedIpAddress = result.framedIpAddress; + } + + return response; + } else { + this.stats.authRejects++; + logger.log('warn', `RADIUS Auth Reject: user=${authData.username}, reason=${result.rejectReason}`); + + return { + code: plugins.smartradius.ERadiusCode.AccessReject, + replyMessage: result.rejectReason || 'Access Denied', + }; + } + } + + /** + * Handle accounting request + */ + private async handleAccounting(request: any): Promise { + this.stats.accountingRequests++; + + if (!this.config.accounting?.enabled) { + // Still respond even if not tracking + return { code: plugins.smartradius.ERadiusCode.AccountingResponse }; + } + + const statusType = request.attributes?.AcctStatusType; + const sessionId = request.attributes?.AcctSessionId || ''; + + const accountingData = { + sessionId, + username: request.attributes?.UserName || '', + macAddress: request.attributes?.CallingStationId, + nasIpAddress: request.attributes?.NasIpAddress || request.source?.address || '', + nasPort: request.attributes?.NasPort, + nasPortType: request.attributes?.NasPortType, + nasIdentifier: request.attributes?.NasIdentifier, + calledStationId: request.attributes?.CalledStationId, + callingStationId: request.attributes?.CallingStationId, + inputOctets: request.attributes?.AcctInputOctets, + outputOctets: request.attributes?.AcctOutputOctets, + inputPackets: request.attributes?.AcctInputPackets, + outputPackets: request.attributes?.AcctOutputPackets, + sessionTime: request.attributes?.AcctSessionTime, + terminateCause: request.attributes?.AcctTerminateCause, + serviceType: request.attributes?.ServiceType, + }; + + try { + switch (statusType) { + case plugins.smartradius.EAcctStatusType.Start: + logger.log('debug', `RADIUS Acct Start: session=${sessionId}, user=${accountingData.username}`); + await this.accountingManager.handleAccountingStart(accountingData); + break; + + case plugins.smartradius.EAcctStatusType.Stop: + logger.log('debug', `RADIUS Acct Stop: session=${sessionId}`); + await this.accountingManager.handleAccountingStop(accountingData); + break; + + case plugins.smartradius.EAcctStatusType.InterimUpdate: + logger.log('debug', `RADIUS Acct Interim: session=${sessionId}`); + await this.accountingManager.handleAccountingUpdate(accountingData); + break; + + default: + logger.log('debug', `RADIUS Acct Unknown status type: ${statusType}`); + } + } catch (error) { + logger.log('error', `RADIUS accounting error: ${error.message}`); + } + + return { code: plugins.smartradius.ERadiusCode.AccountingResponse }; + } + + /** + * Perform MAC Authentication Bypass + */ + private async performMabAuthentication(data: IAuthRequestData): Promise { + // Extract MAC address from username or CallingStationId + const macAddress = this.extractMacAddress(data); + + if (!macAddress) { + return { + success: false, + rejectReason: 'No MAC address found', + }; + } + + // Look up VLAN assignment + const vlanResult = this.vlanManager.assignVlan(macAddress); + + if (!vlanResult.assigned) { + return { + success: false, + rejectReason: 'Unknown MAC address', + }; + } + + // Build successful result + const result: IRadiusAuthResult = { + success: true, + vlanId: vlanResult.vlan, + replyMessage: vlanResult.isDefault + ? `Assigned to default VLAN ${vlanResult.vlan}` + : `Assigned to VLAN ${vlanResult.vlan}`, + }; + + // Apply any additional settings from the matched rule + if (vlanResult.matchedRule) { + // Future: Add session timeout, idle timeout, etc. from rule + } + + return result; + } + + /** + * Extract MAC address from authentication data + */ + private extractMacAddress(data: IAuthRequestData): string | null { + // Try CallingStationId first (most common for MAB) + if (data.callingStationId) { + return this.normalizeMac(data.callingStationId); + } + + // Try username (often MAC address in MAB) + if (data.username && this.looksLikeMac(data.username)) { + return this.normalizeMac(data.username); + } + + return null; + } + + /** + * Check if a string looks like a MAC address + */ + private looksLikeMac(value: string): boolean { + // Remove common separators and check length + const cleaned = value.replace(/[-:. ]/g, ''); + return /^[0-9a-fA-F]{12}$/.test(cleaned); + } + + /** + * Normalize MAC address format + */ + private normalizeMac(mac: string): string { + return this.vlanManager.normalizeMac(mac); + } + + /** + * Build client secrets map from configuration + */ + private buildClientSecretsMap(): void { + this.clientSecrets.clear(); + + for (const client of this.config.clients) { + if (!client.enabled) { + continue; + } + + // Handle CIDR ranges + if (client.ipRange.includes('/')) { + // For CIDR ranges, we'll use the network address as key + // In practice, smartradius may handle this differently + const [network] = client.ipRange.split('/'); + this.clientSecrets.set(network, client.secret); + } else { + this.clientSecrets.set(client.ipRange, client.secret); + } + } + } + + /** + * Get default secret for unknown clients + */ + private getDefaultSecret(): string { + // Use first enabled client's secret as default, or a random one + for (const client of this.config.clients) { + if (client.enabled) { + return client.secret; + } + } + return plugins.crypto.randomBytes(16).toString('hex'); + } + + /** + * Add a RADIUS client + */ + async addClient(client: IRadiusClient): Promise { + // Check if client already exists + const existingIndex = this.config.clients.findIndex(c => c.name === client.name); + if (existingIndex >= 0) { + this.config.clients[existingIndex] = client; + } else { + this.config.clients.push(client); + } + + // Update client secrets if running + if (this.running && this.radiusServer && client.enabled) { + if (client.ipRange.includes('/')) { + const [network] = client.ipRange.split('/'); + this.radiusServer.setClientSecret(network, client.secret); + this.clientSecrets.set(network, client.secret); + } else { + this.radiusServer.setClientSecret(client.ipRange, client.secret); + this.clientSecrets.set(client.ipRange, client.secret); + } + } + + logger.log('info', `RADIUS client ${client.enabled ? 'added' : 'disabled'}: ${client.name} (${client.ipRange})`); + } + + /** + * Remove a RADIUS client + */ + removeClient(name: string): boolean { + const index = this.config.clients.findIndex(c => c.name === name); + if (index >= 0) { + const client = this.config.clients[index]; + this.config.clients.splice(index, 1); + + // Remove from secrets map + if (client.ipRange.includes('/')) { + const [network] = client.ipRange.split('/'); + this.clientSecrets.delete(network); + } else { + this.clientSecrets.delete(client.ipRange); + } + + logger.log('info', `RADIUS client removed: ${name}`); + return true; + } + return false; + } + + /** + * Get configured clients + */ + getClients(): IRadiusClient[] { + return [...this.config.clients]; + } + + /** + * Get VLAN manager for direct access to VLAN operations + */ + getVlanManager(): VlanManager { + return this.vlanManager; + } + + /** + * Get accounting manager for direct access to accounting operations + */ + getAccountingManager(): AccountingManager { + return this.accountingManager; + } + + /** + * Get server statistics + */ + getStats(): { + running: boolean; + uptime: number; + authRequests: number; + authAccepts: number; + authRejects: number; + accountingRequests: number; + activeSessions: number; + vlanMappings: number; + clients: number; + } { + return { + running: this.running, + uptime: this.running ? Date.now() - this.stats.startTime : 0, + authRequests: this.stats.authRequests, + authAccepts: this.stats.authAccepts, + authRejects: this.stats.authRejects, + accountingRequests: this.stats.accountingRequests, + activeSessions: this.accountingManager.getStats().activeSessions, + vlanMappings: this.vlanManager.getStats().totalMappings, + clients: this.config.clients.filter(c => c.enabled).length, + }; + } + + /** + * Check if server is running + */ + isRunning(): boolean { + return this.running; + } +} diff --git a/ts/radius/classes.vlan.manager.ts b/ts/radius/classes.vlan.manager.ts new file mode 100644 index 0000000..8bfd9a9 --- /dev/null +++ b/ts/radius/classes.vlan.manager.ts @@ -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 = 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}`); + } + } +} diff --git a/ts/radius/index.ts b/ts/radius/index.ts new file mode 100644 index 0000000..fef2450 --- /dev/null +++ b/ts/radius/index.ts @@ -0,0 +1,14 @@ +/** + * RADIUS module for DcRouter + * + * Provides: + * - MAC Authentication Bypass (MAB) for network device authentication + * - VLAN assignment based on MAC addresses + * - OUI (vendor prefix) pattern matching for device categorization + * - RADIUS accounting for session tracking and billing + * - Integration with StorageManager for persistence + */ + +export * from './classes.radius.server.js'; +export * from './classes.vlan.manager.js'; +export * from './classes.accounting.manager.js'; diff --git a/ts_interfaces/requests/index.ts b/ts_interfaces/requests/index.ts index 9f7e091..93812e4 100644 --- a/ts_interfaces/requests/index.ts +++ b/ts_interfaces/requests/index.ts @@ -2,4 +2,5 @@ export * from './admin.js'; export * from './config.js'; export * from './logs.js'; export * from './stats.js'; -export * from './combined.stats.js'; \ No newline at end of file +export * from './combined.stats.js'; +export * from './radius.js'; \ No newline at end of file diff --git a/ts_interfaces/requests/radius.ts b/ts_interfaces/requests/radius.ts new file mode 100644 index 0000000..e3064b8 --- /dev/null +++ b/ts_interfaces/requests/radius.ts @@ -0,0 +1,329 @@ +import * as plugins from '../plugins.js'; +import * as authInterfaces from '../data/auth.js'; + +// ============================================================================ +// RADIUS Client Management +// ============================================================================ + +/** + * Get all RADIUS clients (NAS devices) + */ +export interface IReq_GetRadiusClients extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetRadiusClients +> { + method: 'getRadiusClients'; + request: { + identity?: authInterfaces.IIdentity; + }; + response: { + clients: Array<{ + name: string; + ipRange: string; + description?: string; + enabled: boolean; + }>; + }; +} + +/** + * Add or update a RADIUS client + */ +export interface IReq_SetRadiusClient extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_SetRadiusClient +> { + method: 'setRadiusClient'; + request: { + identity?: authInterfaces.IIdentity; + client: { + name: string; + ipRange: string; + secret: string; + description?: string; + enabled: boolean; + }; + }; + response: { + success: boolean; + message?: string; + }; +} + +/** + * Remove a RADIUS client + */ +export interface IReq_RemoveRadiusClient extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_RemoveRadiusClient +> { + method: 'removeRadiusClient'; + request: { + identity?: authInterfaces.IIdentity; + name: string; + }; + response: { + success: boolean; + message?: string; + }; +} + +// ============================================================================ +// VLAN Mapping Management +// ============================================================================ + +/** + * Get all MAC-to-VLAN mappings + */ +export interface IReq_GetVlanMappings extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetVlanMappings +> { + method: 'getVlanMappings'; + request: { + identity?: authInterfaces.IIdentity; + }; + response: { + mappings: Array<{ + mac: string; + vlan: number; + description?: string; + enabled: boolean; + createdAt: number; + updatedAt: number; + }>; + config: { + defaultVlan: number; + allowUnknownMacs: boolean; + }; + }; +} + +/** + * Add or update a VLAN mapping + */ +export interface IReq_SetVlanMapping extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_SetVlanMapping +> { + method: 'setVlanMapping'; + request: { + identity?: authInterfaces.IIdentity; + mapping: { + mac: string; + vlan: number; + description?: string; + enabled: boolean; + }; + }; + response: { + success: boolean; + mapping?: { + mac: string; + vlan: number; + description?: string; + enabled: boolean; + createdAt: number; + updatedAt: number; + }; + message?: string; + }; +} + +/** + * Remove a VLAN mapping + */ +export interface IReq_RemoveVlanMapping extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_RemoveVlanMapping +> { + method: 'removeVlanMapping'; + request: { + identity?: authInterfaces.IIdentity; + mac: string; + }; + response: { + success: boolean; + message?: string; + }; +} + +/** + * Update VLAN configuration + */ +export interface IReq_UpdateVlanConfig extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_UpdateVlanConfig +> { + method: 'updateVlanConfig'; + request: { + identity?: authInterfaces.IIdentity; + defaultVlan?: number; + allowUnknownMacs?: boolean; + }; + response: { + success: boolean; + config: { + defaultVlan: number; + allowUnknownMacs: boolean; + }; + }; +} + +/** + * Test VLAN assignment for a MAC address + */ +export interface IReq_TestVlanAssignment extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_TestVlanAssignment +> { + method: 'testVlanAssignment'; + request: { + identity?: authInterfaces.IIdentity; + mac: string; + }; + response: { + assigned: boolean; + vlan: number; + isDefault: boolean; + matchedRule?: { + mac: string; + vlan: number; + description?: string; + }; + }; +} + +// ============================================================================ +// Accounting / Session Management +// ============================================================================ + +/** + * Get active RADIUS sessions + */ +export interface IReq_GetRadiusSessions extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetRadiusSessions +> { + method: 'getRadiusSessions'; + request: { + identity?: authInterfaces.IIdentity; + filter?: { + username?: string; + nasIpAddress?: string; + vlanId?: number; + }; + }; + response: { + sessions: Array<{ + sessionId: string; + username: string; + macAddress?: string; + nasIpAddress: string; + nasIdentifier?: string; + vlanId?: number; + framedIpAddress?: string; + startTime: number; + lastUpdateTime: number; + status: 'active' | 'stopped' | 'terminated'; + inputOctets: number; + outputOctets: number; + sessionTime: number; + }>; + totalCount: number; + }; +} + +/** + * Disconnect a RADIUS session + */ +export interface IReq_DisconnectRadiusSession extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_DisconnectRadiusSession +> { + method: 'disconnectRadiusSession'; + request: { + identity?: authInterfaces.IIdentity; + sessionId: string; + reason?: string; + }; + response: { + success: boolean; + message?: string; + }; +} + +/** + * Get accounting summary/report + */ +export interface IReq_GetRadiusAccountingSummary extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetRadiusAccountingSummary +> { + method: 'getRadiusAccountingSummary'; + request: { + identity?: authInterfaces.IIdentity; + startTime: number; + endTime: number; + }; + response: { + summary: { + periodStart: number; + periodEnd: number; + totalSessions: number; + activeSessions: number; + totalInputBytes: number; + totalOutputBytes: number; + totalSessionTime: number; + averageSessionDuration: number; + uniqueUsers: number; + sessionsByVlan: Record; + topUsersByTraffic: Array<{ username: string; totalBytes: number }>; + }; + }; +} + +// ============================================================================ +// Statistics +// ============================================================================ + +/** + * Get RADIUS server statistics + */ +export interface IReq_GetRadiusStatistics extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetRadiusStatistics +> { + method: 'getRadiusStatistics'; + request: { + identity?: authInterfaces.IIdentity; + }; + response: { + stats: { + running: boolean; + uptime: number; + authRequests: number; + authAccepts: number; + authRejects: number; + accountingRequests: number; + activeSessions: number; + vlanMappings: number; + clients: number; + }; + vlanStats: { + totalMappings: number; + enabledMappings: number; + exactMatches: number; + ouiPatterns: number; + wildcardPatterns: number; + }; + accountingStats: { + activeSessions: number; + totalSessionsStarted: number; + totalSessionsStopped: number; + totalInputBytes: number; + totalOutputBytes: number; + interimUpdatesReceived: number; + }; + }; +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 41a70da..ad8af5e 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '2.12.6', + version: '2.13.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/elements/ops-dashboard.ts b/ts_web/elements/ops-dashboard.ts index 00119df..7292d04 100644 --- a/ts_web/elements/ops-dashboard.ts +++ b/ts_web/elements/ops-dashboard.ts @@ -21,12 +21,12 @@ import { OpsViewSecurity } from './ops-view-security.js'; @customElement('ops-dashboard') export class OpsDashboard extends DeesElement { - @state() private loginState: appstate.ILoginState = { + @state() accessor loginState: appstate.ILoginState = { identity: null, isLoggedIn: false, }; - - @state() private uiState: appstate.IUiState = { + + @state() accessor uiState: appstate.IUiState = { activeView: 'overview', sidebarCollapsed: false, autoRefresh: true, diff --git a/ts_web/elements/ops-view-config.ts b/ts_web/elements/ops-view-config.ts index 7fd2ca1..5f5bf87 100644 --- a/ts_web/elements/ops-view-config.ts +++ b/ts_web/elements/ops-view-config.ts @@ -14,17 +14,17 @@ import { @customElement('ops-view-config') export class OpsViewConfig extends DeesElement { @state() - private configState: appstate.IConfigState = { + accessor configState: appstate.IConfigState = { config: null, isLoading: false, error: null, }; @state() - private editingSection: string | null = null; + accessor editingSection: string | null = null; @state() - private editedConfig: any = null; + accessor editedConfig: any = null; constructor() { super(); diff --git a/ts_web/elements/ops-view-emails.ts b/ts_web/elements/ops-view-emails.ts index 53c78fa..10826f9 100644 --- a/ts_web/elements/ops-view-emails.ts +++ b/ts_web/elements/ops-view-emails.ts @@ -33,25 +33,25 @@ interface IEmail { @customElement('ops-view-emails') export class OpsViewEmails extends DeesElement { @state() - private selectedFolder: 'inbox' | 'sent' | 'draft' | 'trash' = 'inbox'; + accessor selectedFolder: 'inbox' | 'sent' | 'draft' | 'trash' = 'inbox'; @state() - private emails: IEmail[] = []; + accessor emails: IEmail[] = []; @state() - private selectedEmail: IEmail | null = null; + accessor selectedEmail: IEmail | null = null; @state() - private showCompose = false; + accessor showCompose = false; @state() - private isLoading = false; + accessor isLoading = false; @state() - private searchTerm = ''; + accessor searchTerm = ''; @state() - private emailDomains: string[] = []; + accessor emailDomains: string[] = []; constructor() { super(); diff --git a/ts_web/elements/ops-view-logs.ts b/ts_web/elements/ops-view-logs.ts index 0411442..60fed92 100644 --- a/ts_web/elements/ops-view-logs.ts +++ b/ts_web/elements/ops-view-logs.ts @@ -14,7 +14,7 @@ import { @customElement('ops-view-logs') export class OpsViewLogs extends DeesElement { @state() - private logState: appstate.ILogState = { + accessor logState: appstate.ILogState = { recentLogs: [], isStreaming: false, filters: {}, diff --git a/ts_web/elements/ops-view-network.ts b/ts_web/elements/ops-view-network.ts index f9db92d..94643af 100644 --- a/ts_web/elements/ops-view-network.ts +++ b/ts_web/elements/ops-view-network.ts @@ -28,20 +28,20 @@ interface INetworkRequest { @customElement('ops-view-network') export class OpsViewNetwork extends DeesElement { @state() - private statsState = appstate.statsStatePart.getState(); + accessor statsState = appstate.statsStatePart.getState(); @state() - private networkState = appstate.networkStatePart.getState(); + accessor networkState = appstate.networkStatePart.getState(); @state() - private networkRequests: INetworkRequest[] = []; + accessor networkRequests: INetworkRequest[] = []; @state() - private trafficDataIn: Array<{ x: string | number; y: number }> = []; - + accessor trafficDataIn: Array<{ x: string | number; y: number }> = []; + @state() - private trafficDataOut: Array<{ x: string | number; y: number }> = []; + accessor trafficDataOut: Array<{ x: string | number; y: number }> = []; // Track if we need to update the chart to avoid unnecessary re-renders private lastChartUpdate = 0; diff --git a/ts_web/elements/ops-view-overview.ts b/ts_web/elements/ops-view-overview.ts index 2d688c8..c4a5a83 100644 --- a/ts_web/elements/ops-view-overview.ts +++ b/ts_web/elements/ops-view-overview.ts @@ -16,7 +16,7 @@ import { type IStatsTile } from '@design.estate/dees-catalog'; @customElement('ops-view-overview') export class OpsViewOverview extends DeesElement { @state() - private statsState: appstate.IStatsState = { + accessor statsState: appstate.IStatsState = { serverStats: null, emailStats: null, dnsStats: null, diff --git a/ts_web/elements/ops-view-security.ts b/ts_web/elements/ops-view-security.ts index 1ccaf98..2b5670f 100644 --- a/ts_web/elements/ops-view-security.ts +++ b/ts_web/elements/ops-view-security.ts @@ -15,7 +15,7 @@ import { type IStatsTile } from '@design.estate/dees-catalog'; @customElement('ops-view-security') export class OpsViewSecurity extends DeesElement { @state() - private statsState: appstate.IStatsState = { + accessor statsState: appstate.IStatsState = { serverStats: null, emailStats: null, dnsStats: null, @@ -26,7 +26,7 @@ export class OpsViewSecurity extends DeesElement { }; @state() - private selectedTab: 'overview' | 'blocked' | 'authentication' | 'email-security' = 'overview'; + accessor selectedTab: 'overview' | 'blocked' | 'authentication' | 'email-security' = 'overview'; constructor() { super();