From 55dc9c1cee5146396cac6aa116c3163308127640 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Thu, 29 Jan 2026 10:09:16 +0100 Subject: [PATCH] added avm scanner module to radius --- application/Radius/RadiusController.php | 307 +++++++++++++++++++-- public/js/pages/Radius/Radius.js | 7 +- public/js/pages/Radius/RadiusAVMScanner.js | 268 ++++++++++++++++++ scripts/avm_scanner.php | 277 +++++++++++++++++++ 4 files changed, 832 insertions(+), 27 deletions(-) create mode 100644 public/js/pages/Radius/RadiusAVMScanner.js create mode 100644 scripts/avm_scanner.php diff --git a/application/Radius/RadiusController.php b/application/Radius/RadiusController.php index e39a5f6d8..7c8e96be3 100644 --- a/application/Radius/RadiusController.php +++ b/application/Radius/RadiusController.php @@ -657,47 +657,302 @@ class RadiusController extends mfBaseController { private function getVendor($mac) { - + $mac = strtoupper(str_replace([':', '-', '.'], '', $mac)); - + if (strlen($mac) < 6) return null; - - - + + + $path = TEMP_DIR . '/mac-vendors.csv'; - + if (!file_exists($path)) return null; - - - + + + // Format as XX:XX:XX - + $prefix = substr($mac, 0, 2) . ':' . substr($mac, 2, 2) . ':' . substr($mac, 4, 2); - - - - // Use grep for speed if available, else fallback to basic search? - + + + + // Use grep for speed if available, else fallback to basic search? + // Assuming Linux env as per docker context. - + $cmd = "grep -m 1 \"^" . $prefix . "\" " . escapeshellarg($path); - + $output = shell_exec($cmd); - - - + + + if ($output) { - + $parts = str_getcsv($output); - + if (isset($parts[1])) return $parts[1]; - + } - + return null; - + } - + + // ========== AVM Scanner Methods ========== + + private static $avmScannerStateFile = null; + + private static $avmPrefixes = [ + '00:04:0E', '00:15:0C', '00:1A:4F', '00:1C:4A', '00:1F:3F', '00:24:FE', + '04:B4:FE', '08:96:D7', '08:B6:57', '0C:72:74', + '1C:ED:6F', + '24:65:11', '2C:3A:FD', '2C:91:AB', + '34:31:C4', '34:81:C4', '34:E1:A9', '38:10:D5', '3C:37:12', '3C:A6:2F', + '44:4E:6D', '48:5D:35', + '50:E6:36', '5C:49:79', + '60:B5:8D', + '74:42:7F', '7C:FF:4D', + '80:23:95', + '98:9B:CB', '98:A9:65', '9C:C7:A6', + 'B0:F2:08', 'B4:FC:7D', 'BC:05:43', + 'C0:25:06', 'C8:0E:14', 'CC:CE:1E', + 'D0:12:CB', 'D4:24:DD', 'DC:15:C8', 'DC:39:6F', + 'E0:08:55', 'E0:28:6D', 'E8:DF:70', + 'F0:B0:14' + ]; + + private function getAvmScannerStatePath(): string { + return BASEDIR . '/files/avm_scanner.json'; + } + + private function loadAvmScannerState(): array { + $path = $this->getAvmScannerStatePath(); + if (file_exists($path)) { + $content = file_get_contents($path); + $state = json_decode($content, true); + if (is_array($state)) return $state; + } + return [ + 'scanning' => false, + 'stopRequested' => false, + 'progress' => ['current' => 0, 'total' => 0], + 'currentDevice' => null, + 'startedAt' => null, + 'startedBy' => null, + 'lastUpdated' => date('c'), + 'devices' => [] + ]; + } + + private function saveAvmScannerState(array $state): void { + $dir = dirname($this->getAvmScannerStatePath()); + if (!is_dir($dir)) @mkdir($dir, 0777, true); + $state['lastUpdated'] = date('c'); + file_put_contents($this->getAvmScannerStatePath(), json_encode($state, JSON_PRETTY_PRINT)); + } + + private function isAvmMac(string $mac): bool { + $mac = strtoupper(trim($mac)); + $prefix = substr($mac, 0, 8); + return in_array($prefix, self::$avmPrefixes); + } + + private function fetchRadiusUsersFromApi(): array { + $url = "http://radius.xinon.at/api.php"; + $opts = [ + "http" => [ + "method" => "GET", + "header" => "Authorization: Basic " . base64_encode("admin:saveman"), + "timeout" => 120 + ] + ]; + $context = stream_context_create($opts); + $response = @file_get_contents($url, false, $context); + if ($response === false) return []; + $data = json_decode($response, true); + return is_array($data) ? $data : []; + } + + private function fetchRadacctForUser(string $username): ?array { + $url = "http://radius.xinon.at/api.php?action2=fetchRadacct&username=" . urlencode($username); + $opts = [ + "http" => [ + "method" => "GET", + "header" => "Authorization: Basic " . base64_encode("admin:saveman"), + "timeout" => 10 + ] + ]; + $context = stream_context_create($opts); + $response = @file_get_contents($url, false, $context); + if ($response === false) return null; + $data = json_decode($response, true); + return is_array($data) ? $data : null; + } + + protected function avmScannerGetStateAction() { + $state = $this->loadAvmScannerState(); + header('Content-Type: application/json'); + echo json_encode($state); + die(); + } + + protected function avmScannerGetUsersAction() { + $users = $this->fetchRadiusUsersFromApi(); + $debug = $_GET['debug'] ?? false; + + $macUsers = 0; + $avmMacUsers = 0; + $notInAcs = 0; + $macPrefixCounts = []; + + $avmUsers = []; + foreach ($users as $user) { + $username = trim($user['username'] ?? ''); + if (!preg_match('/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/i', $username)) continue; + $macUsers++; + + $prefix = strtoupper(substr($username, 0, 8)); + $macPrefixCounts[$prefix] = ($macPrefixCounts[$prefix] ?? 0) + 1; + + if (!$this->isAvmMac($username)) continue; + $avmMacUsers++; + + $info = $user['info'] ?? ''; + if (stripos($info, 'ACS') !== false) continue; + $notInAcs++; + + $user['username'] = $username; + $avmUsers[] = $user; + } + + if ($debug) { + arsort($macPrefixCounts); + header('Content-Type: application/json'); + echo json_encode([ + 'totalFromApi' => count($users), + 'macAddressUsers' => $macUsers, + 'avmMacUsers' => $avmMacUsers, + 'notInAcs' => $notInAcs, + 'topMacPrefixes' => array_slice($macPrefixCounts, 0, 30, true), + 'avmPrefixes' => self::$avmPrefixes + ]); + die(); + } + + header('Content-Type: application/json'); + echo json_encode(['users' => $avmUsers, 'count' => count($avmUsers)]); + die(); + } + + protected function avmScannerStartAction() { + $state = $this->loadAvmScannerState(); + if ($state['scanning']) { + header('Content-Type: application/json'); + echo json_encode(['success' => false, 'message' => 'Scan already running']); + die(); + } + + // Count how many users will be scanned (for immediate feedback) + $users = $this->fetchRadiusUsersFromApi(); + $count = 0; + foreach ($users as $user) { + $username = trim($user['username'] ?? ''); + if (!preg_match('/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/i', $username)) continue; + if (!$this->isAvmMac($username)) continue; + $info = $user['info'] ?? ''; + if (stripos($info, 'ACS') !== false) continue; + $count++; + } + + // Spawn background script using nohup to ensure it runs independently + $scriptPath = BASEDIR . '/scripts/avm_scanner.php'; + $logPath = BASEDIR . '/files/avm_scanner.log'; + $cmd = "nohup php " . escapeshellarg($scriptPath) . " >> " . escapeshellarg($logPath) . " 2>&1 &"; + shell_exec($cmd); + + header('Content-Type: application/json'); + echo json_encode(['success' => true, 'message' => 'Scan started', 'total' => $count]); + die(); + } + + protected function avmScannerStopAction() { + $state = $this->loadAvmScannerState(); + $state['stopRequested'] = true; + $this->saveAvmScannerState($state); + header('Content-Type: application/json'); + echo json_encode(['success' => true, 'message' => 'Stop requested']); + die(); + } + + protected function avmScannerToggleErledigtAction() { + $input = json_decode(file_get_contents('php://input'), true); + $mac = $input['mac'] ?? null; + if (!$mac) { + header('Content-Type: application/json'); + http_response_code(400); + echo json_encode(['error' => 'MAC address required']); + die(); + } + + $state = $this->loadAvmScannerState(); + foreach ($state['devices'] as &$device) { + if ($device['mac'] === $mac) { + $device['erledigt'] = !($device['erledigt'] ?? false); + break; + } + } + $this->saveAvmScannerState($state); + header('Content-Type: application/json'); + echo json_encode(['success' => true]); + die(); + } + + private function detectFritzPort(string $ip): ?int { + $url = "https://acs.xinon.at/detect-port"; + $data = json_encode(['fritz_ip' => $ip, 'timeout' => 3]); + $opts = [ + "http" => [ + "method" => "POST", + "header" => "Content-Type: application/json\r\nContent-Length: " . strlen($data) . "\r\n", + "content" => $data, + "timeout" => 15 + ], + "ssl" => ["verify_peer" => false, "verify_peer_name" => false] + ]; + $context = stream_context_create($opts); + $response = @file_get_contents($url, false, $context); + if ($response) { + $json = json_decode($response, true); + if ($json && $json['success'] && isset($json['port'])) { + return (int)$json['port']; + } + } + return null; + } + + private function detectFritzDevice(string $ip, int $port): ?array { + $url = "https://acs.xinon.at/detect-device"; + $data = json_encode(['fritz_ip' => $ip, 'fritz_port' => (string)$port]); + $opts = [ + "http" => [ + "method" => "POST", + "header" => "Content-Type: application/json\r\nContent-Length: " . strlen($data) . "\r\n", + "content" => $data, + "timeout" => 15 + ], + "ssl" => ["verify_peer" => false, "verify_peer_name" => false] + ]; + $context = stream_context_create($opts); + $response = @file_get_contents($url, false, $context); + if ($response) { + $json = json_decode($response, true); + if ($json && $json['success'] && isset($json['device'])) { + return $json['device']; + } + } + return null; + } + } \ No newline at end of file diff --git a/public/js/pages/Radius/Radius.js b/public/js/pages/Radius/Radius.js index f7f97cd2a..14bbb860f 100644 --- a/public/js/pages/Radius/Radius.js +++ b/public/js/pages/Radius/Radius.js @@ -44,6 +44,9 @@ const Radius = {
+
+ +
`, @@ -59,7 +62,8 @@ const Radius = { const options = [ { id: 'users', name: 'Benutzer', icon: 'fa-duotone fa-users' }, { id: 'free', name: 'Freie Benutzer', icon: 'fa-duotone fa-user-plus' }, - { id: 'unused', name: 'Ungenutzte Benutzer', icon: 'fa-duotone fa-user-clock' } + { id: 'unused', name: 'Ungenutzte Benutzer', icon: 'fa-duotone fa-user-clock' }, + { id: 'avmscanner', name: 'AVM Scanner', icon: 'fa-duotone fa-router' } ]; if (window.TT_CONFIG.CAN_BILLING === '1') { @@ -84,6 +88,7 @@ const Radius = { let refName = ''; if (v === 'free') refName = 'freeView'; else if (v === 'unused') refName = 'unusedView'; + else if (v === 'avmscanner') refName = 'avmScannerView'; if (refName) { this.$nextTick(() => { diff --git a/public/js/pages/Radius/RadiusAVMScanner.js b/public/js/pages/Radius/RadiusAVMScanner.js new file mode 100644 index 000000000..e8f1e944a --- /dev/null +++ b/public/js/pages/Radius/RadiusAVMScanner.js @@ -0,0 +1,268 @@ +const RadiusAVMScanner = { + name: 'RadiusAVMScanner', + template: ` +
+
+
+
+ + +
+
+
+ +
+
+ + + +
+
+ +
+
+
+ Scan läuft... + {{ state.progress?.current || 0 }} / {{ state.progress?.total || 0 }} +
+
+
+
+
+ Aktuell: {{ state.currentDevice.mac }} + ({{ state.currentDevice.ip }}) +
+
+
+ +
+ + + + + + +
+ + {{ filteredDevices.length }} von {{ state.devices.length }} Geräten + + + Keine Geräte +
+
+
+ `, + data: () => ({ + window: window, + state: null, + isLoading: false, + polling: null, + deviceTypeFilter: '', + showErledigt: true + }), + computed: { + progressPercent() { + if (!this.state?.progress?.total) return 0; + return Math.round((this.state.progress.current / this.state.progress.total) * 100); + }, + deviceTypes() { + if (!this.state?.devices) return []; + const types = new Set(); + this.state.devices.forEach(d => { + if (d.deviceType) types.add(d.deviceType); + }); + return Array.from(types).sort(); + }, + filteredDevices() { + if (!this.state?.devices) return []; + return this.state.devices.filter(d => { + if (this.deviceTypeFilter && d.deviceType !== this.deviceTypeFilter) return false; + if (!this.showErledigt && d.erledigt) return false; + return true; + }); + } + }, + methods: { + initIfNeeded() { + this.refreshState(); + }, + startPolling() { + if (this.polling) return; + this.polling = setInterval(() => { + this.refreshState(true); + }, 3000); // Poll every 3 seconds + }, + stopPolling() { + if (this.polling) { + clearInterval(this.polling); + this.polling = null; + } + }, + async refreshState(silent = false) { + if (!silent) this.isLoading = true; + try { + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/avmScannerGetState`); + this.state = data; + + // Start/stop polling based on scanning state + if (data.scanning && !this.polling) { + this.startPolling(); + } else if (!data.scanning && this.polling) { + this.stopPolling(); + } + } catch (e) { + console.error('Failed to fetch AVM scanner state:', e); + } + if (!silent) this.isLoading = false; + }, + async startScan() { + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/avmScannerStart`); + if (data.success) { + window.notify('success', `Scan gestartet für ${data.total} Geräte`); + this.startPolling(); // Start polling immediately + } else { + window.notify('warning', data.message || 'Scan konnte nicht gestartet werden'); + } + this.refreshState(); + } catch (e) { + console.error('Failed to start scan:', e); + window.notify('error', 'Fehler beim Starten des Scans'); + } + }, + async stopScan() { + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/avmScannerStop`); + if (data.success) { + window.notify('info', 'Scan wird gestoppt...'); + } + } catch (e) { + console.error('Failed to stop scan:', e); + window.notify('error', 'Fehler beim Stoppen des Scans'); + } + }, + async toggleErledigt(mac) { + try { + await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/avmScannerToggleErledigt`, { mac }); + this.refreshState(true); + } catch (e) { + console.error('Failed to toggle erledigt:', e); + window.notify('error', 'Fehler beim Aktualisieren'); + } + }, + formatDate(dateStr) { + if (!dateStr) return '—'; + const d = new Date(dateStr); + return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); + } + }, + beforeUnmount() { + this.stopPolling(); + } +}; + +if (window.VueApp) { + window.VueApp.component('radius-avm-scanner', RadiusAVMScanner); +} diff --git a/scripts/avm_scanner.php b/scripts/avm_scanner.php new file mode 100644 index 000000000..becb53fe5 --- /dev/null +++ b/scripts/avm_scanner.php @@ -0,0 +1,277 @@ +#!/usr/bin/env php + false, + 'stopRequested' => false, + 'progress' => ['current' => 0, 'total' => 0], + 'currentDevice' => null, + 'startedAt' => null, + 'startedBy' => null, + 'lastUpdated' => date('c'), + 'devices' => [] + ]; +} + +function saveState($state) { + $dir = dirname(getStatePath()); + if (!is_dir($dir)) @mkdir($dir, 0777, true); + $state['lastUpdated'] = date('c'); + file_put_contents(getStatePath(), json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); +} + +function isAvmMac($mac) { + global $avmPrefixes; + $mac = strtoupper(trim($mac)); + $prefix = substr($mac, 0, 8); + return in_array($prefix, $avmPrefixes); +} + +function fetchRadiusUsers() { + $url = "http://radius.xinon.at/api.php"; + $opts = [ + "http" => [ + "method" => "GET", + "header" => "Authorization: Basic " . base64_encode("admin:saveman"), + "timeout" => 120 + ] + ]; + $context = stream_context_create($opts); + $response = @file_get_contents($url, false, $context); + if ($response === false) return []; + $data = json_decode($response, true); + return is_array($data) ? $data : []; +} + +function fetchRadacct($username) { + $url = "http://radius.xinon.at/api.php?action2=fetchRadacct&username=" . urlencode($username); + $opts = [ + "http" => [ + "method" => "GET", + "header" => "Authorization: Basic " . base64_encode("admin:saveman"), + "timeout" => 10 + ] + ]; + $context = stream_context_create($opts); + $response = @file_get_contents($url, false, $context); + if ($response === false) return null; + $data = json_decode($response, true); + return is_array($data) ? $data : null; +} + +function detectFritzPort($ip) { + $url = "http://acs.xinon.at:5000/detect-port"; + $data = json_encode(['fritz_ip' => $ip, 'timeout' => 3]); + $opts = [ + "http" => [ + "method" => "POST", + "header" => "Content-Type: application/json\r\nContent-Length: " . strlen($data) . "\r\n", + "content" => $data, + "timeout" => 20 + ] + ]; + $context = stream_context_create($opts); + $response = @file_get_contents($url, false, $context); + if ($response) { + $json = json_decode($response, true); + if ($json && $json['success'] && isset($json['port'])) { + return (int)$json['port']; + } + } + return null; +} + +function detectFritzDevice($ip, $port) { + $url = "http://acs.xinon.at:5000/detect-device"; + $data = json_encode(['fritz_ip' => $ip, 'fritz_port' => (string)$port]); + $opts = [ + "http" => [ + "method" => "POST", + "header" => "Content-Type: application/json\r\nContent-Length: " . strlen($data) . "\r\n", + "content" => $data, + "timeout" => 20 + ] + ]; + $context = stream_context_create($opts); + $response = @file_get_contents($url, false, $context); + if ($response) { + $json = json_decode($response, true); + if ($json && $json['success'] && isset($json['device'])) { + return $json['device']; + } + } + return null; +} + +// Main execution +echo "AVM Scanner starting...\n"; + +$state = loadState(); +if ($state['scanning']) { + echo "Scan already running, exiting.\n"; + exit(1); +} + +// Fetch all users +echo "Fetching users from RADIUS API...\n"; +$users = fetchRadiusUsers(); +echo "Got " . count($users) . " users from API\n"; + +// Filter AVM users +$avmUsers = []; +foreach ($users as $user) { + $username = trim($user['username'] ?? ''); + if (!preg_match('/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/i', $username)) continue; + if (!isAvmMac($username)) continue; + $info = $user['info'] ?? ''; + if (stripos($info, 'ACS') !== false) continue; + $user['username'] = $username; + $avmUsers[] = $user; +} +echo "Found " . count($avmUsers) . " AVM users to scan\n"; + +if (count($avmUsers) === 0) { + echo "No users to scan, exiting.\n"; + exit(0); +} + +// Preserve existing device data (for erledigt status) +$existingDevices = []; +foreach ($state['devices'] ?? [] as $dev) { + $existingDevices[$dev['mac']] = $dev; +} + +// Initialize scan state +$state['scanning'] = true; +$state['stopRequested'] = false; +$state['progress'] = ['current' => 0, 'total' => count($avmUsers)]; +$state['startedAt'] = date('c'); +$state['startedBy'] = 'script'; +saveState($state); + +// Process each user +foreach ($avmUsers as $idx => $user) { + // Check for stop request + $state = loadState(); + if ($state['stopRequested']) { + echo "Stop requested, terminating.\n"; + $state['scanning'] = false; + $state['stopRequested'] = false; + $state['currentDevice'] = null; + saveState($state); + exit(0); + } + + $mac = $user['username']; + $customerId = $user['customerNumber'] ?? ''; + $customerName = $user['info'] ?? ''; + + echo "[" . ($idx + 1) . "/" . count($avmUsers) . "] Scanning $mac...\n"; + + $state['progress']['current'] = $idx + 1; + $state['currentDevice'] = ['mac' => $mac, 'ip' => null]; + saveState($state); + + // Fetch IP from radacct + $radacct = fetchRadacct($mac); + $ip = $radacct['ip'] ?? null; + + $deviceResult = [ + 'mac' => $mac, + 'ip' => $ip, + 'customerId' => $customerId, + 'customerName' => $customerName, + 'deviceType' => null, + 'oem' => null, + 'port' => null, + 'isRepeater' => false, + 'isGateway' => false, + 'erledigt' => $existingDevices[$mac]['erledigt'] ?? false, + 'scannedAt' => date('c'), + 'error' => null + ]; + + if ($ip) { + echo " IP: $ip - detecting port...\n"; + $port = detectFritzPort($ip); + if ($port) { + echo " Port: $port - detecting device...\n"; + $deviceResult['port'] = $port; + $deviceInfo = detectFritzDevice($ip, $port); + if ($deviceInfo) { + $deviceResult['deviceType'] = $deviceInfo['product_name'] ?? null; + $deviceResult['oem'] = $deviceInfo['oem'] ?? null; + $deviceResult['isRepeater'] = $deviceInfo['is_repeater'] ?? false; + $deviceResult['isGateway'] = $deviceInfo['is_gateway'] ?? false; + echo " Device: " . ($deviceResult['deviceType'] ?? 'unknown') . "\n"; + } else { + echo " Could not detect device info\n"; + } + } else { + $deviceResult['error'] = 'No port detected'; + echo " No port detected\n"; + } + } else { + $deviceResult['error'] = 'No IP address'; + echo " No IP address\n"; + } + + $existingDevices[$mac] = $deviceResult; + $state['devices'] = array_values($existingDevices); + saveState($state); + + // Small delay between requests + usleep(50000); +} + +// Done +$state = loadState(); +$state['scanning'] = false; +$state['currentDevice'] = null; +saveState($state); + +echo "Scan complete!\n";