added avm scanner module to radius
This commit is contained in:
@@ -698,6 +698,261 @@ class RadiusController extends mfBaseController {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +44,9 @@ const Radius = {
|
|||||||
<section v-show="view === 'ontReverse'" class="card-in">
|
<section v-show="view === 'ontReverse'" class="card-in">
|
||||||
<radius-ont-finder />
|
<radius-ont-finder />
|
||||||
</section>
|
</section>
|
||||||
|
<section v-show="view === 'avmscanner'" class="card-in">
|
||||||
|
<radius-avm-scanner ref="avmScannerView" />
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
@@ -59,7 +62,8 @@ const Radius = {
|
|||||||
const options = [
|
const options = [
|
||||||
{ id: 'users', name: 'Benutzer', icon: 'fa-duotone fa-users' },
|
{ id: 'users', name: 'Benutzer', icon: 'fa-duotone fa-users' },
|
||||||
{ id: 'free', name: 'Freie Benutzer', icon: 'fa-duotone fa-user-plus' },
|
{ 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') {
|
if (window.TT_CONFIG.CAN_BILLING === '1') {
|
||||||
@@ -84,6 +88,7 @@ const Radius = {
|
|||||||
let refName = '';
|
let refName = '';
|
||||||
if (v === 'free') refName = 'freeView';
|
if (v === 'free') refName = 'freeView';
|
||||||
else if (v === 'unused') refName = 'unusedView';
|
else if (v === 'unused') refName = 'unusedView';
|
||||||
|
else if (v === 'avmscanner') refName = 'avmScannerView';
|
||||||
|
|
||||||
if (refName) {
|
if (refName) {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
|
|||||||
268
public/js/pages/Radius/RadiusAVMScanner.js
Normal file
268
public/js/pages/Radius/RadiusAVMScanner.js
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
const RadiusAVMScanner = {
|
||||||
|
name: 'RadiusAVMScanner',
|
||||||
|
template: `
|
||||||
|
<div class="tt-scope">
|
||||||
|
<div class="filters-layout" style="margin-bottom: 16px;">
|
||||||
|
<div class="field">
|
||||||
|
<div class="input-wrap">
|
||||||
|
<i class="fa-duotone fa-router input-icon"></i>
|
||||||
|
<select class="ri" v-model="deviceTypeFilter" style="padding-left: 36px;">
|
||||||
|
<option value="">Alle Gerätetypen</option>
|
||||||
|
<option v-for="dt in deviceTypes" :key="dt" :value="dt">{{ dt }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="switch-field">
|
||||||
|
<span class="mini muted">Erledigte anzeigen</span>
|
||||||
|
<span class="switch">
|
||||||
|
<input type="checkbox" v-model="showErledigt">
|
||||||
|
<span class="switch-track">
|
||||||
|
<i class="fa-duotone fa-eye on"></i>
|
||||||
|
<i class="fa-duotone fa-eye-slash off"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="cluster" style="gap: 8px;">
|
||||||
|
<button
|
||||||
|
class="primary-btn"
|
||||||
|
@click="startScan"
|
||||||
|
:disabled="state?.scanning"
|
||||||
|
v-if="!state?.scanning"
|
||||||
|
>
|
||||||
|
<i class="fa-duotone fa-play"></i> Scan starten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="danger-btn"
|
||||||
|
@click="stopScan"
|
||||||
|
v-if="state?.scanning"
|
||||||
|
>
|
||||||
|
<i class="fa-duotone fa-stop"></i> Scan stoppen
|
||||||
|
</button>
|
||||||
|
<button class="ghost-btn" @click="refreshState" :disabled="isLoading" data-tooltip="Aktualisieren">
|
||||||
|
<i class="fa-duotone fa-refresh" :class="{ 'fa-spin': isLoading }"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="state?.scanning" class="progress-container" style="margin-bottom: 16px;">
|
||||||
|
<div class="card" style="padding: 16px;">
|
||||||
|
<div class="cluster" style="justify-content: space-between; margin-bottom: 8px;">
|
||||||
|
<span class="fw-500">Scan läuft...</span>
|
||||||
|
<span class="mono">{{ state.progress?.current || 0 }} / {{ state.progress?.total || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar" style="height: 8px; background: var(--tt-bg-muted); border-radius: 4px; overflow: hidden;">
|
||||||
|
<div
|
||||||
|
class="progress-fill"
|
||||||
|
:style="{ width: progressPercent + '%', background: 'var(--tt-primary)', height: '100%', transition: 'width 0.3s' }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="state.currentDevice" class="mt-between" style="font-size: 13px; color: var(--tt-text-muted);">
|
||||||
|
Aktuell: <span class="mono">{{ state.currentDevice.mac }}</span>
|
||||||
|
<span v-if="state.currentDevice.ip"> ({{ state.currentDevice.ip }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="results-container">
|
||||||
|
<tt-data-table
|
||||||
|
:items="filteredDevices"
|
||||||
|
:is-loading="isLoading && !state"
|
||||||
|
:has-searched="true"
|
||||||
|
:skeleton-row-count="6"
|
||||||
|
initial-placeholder-text="Keine Geräte gescannt. Klicken Sie auf 'Scan starten'."
|
||||||
|
>
|
||||||
|
<template #head>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 170px;">MAC-Adresse</th>
|
||||||
|
<th style="width: 140px;">IP-Adresse</th>
|
||||||
|
<th style="width: 150px;">Kunde</th>
|
||||||
|
<th>Gerätetyp</th>
|
||||||
|
<th style="width: 80px; text-align: center;">Port</th>
|
||||||
|
<th style="width: 80px; text-align: center;">Erledigt</th>
|
||||||
|
<th style="width: 120px;">Gescannt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</template>
|
||||||
|
<template #skeleton-row>
|
||||||
|
<td><tt-skeleton /></td>
|
||||||
|
<td><tt-skeleton /></td>
|
||||||
|
<td><tt-skeleton /></td>
|
||||||
|
<td><tt-skeleton /></td>
|
||||||
|
<td><tt-skeleton /></td>
|
||||||
|
<td><tt-skeleton /></td>
|
||||||
|
<td><tt-skeleton /></td>
|
||||||
|
</template>
|
||||||
|
<template #row="{ item }">
|
||||||
|
<td class="mono nowrap">
|
||||||
|
<tt-copy-button :text="item.mac" tooltip-align="right" />
|
||||||
|
{{ item.mac }}
|
||||||
|
</td>
|
||||||
|
<td class="mono">
|
||||||
|
<template v-if="item.ip">
|
||||||
|
<tt-copy-button :text="item.ip" tooltip-align="right" />
|
||||||
|
{{ item.ip }}
|
||||||
|
</template>
|
||||||
|
<span v-else class="muted">—</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
v-if="item.customerId"
|
||||||
|
class="link"
|
||||||
|
target="_blank"
|
||||||
|
:href="window.TT_CONFIG.BASE_PATH + '/Address?filter%5Bcustomer_number%5D=' + item.customerId"
|
||||||
|
>
|
||||||
|
{{ item.customerId }}
|
||||||
|
</a>
|
||||||
|
<div v-if="item.customerName" class="mini muted clamp-1">{{ item.customerName }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="item.deviceType" class="fw-500">{{ item.deviceType }}</span>
|
||||||
|
<span v-else-if="item.error" class="text-danger mini">{{ item.error }}</span>
|
||||||
|
<span v-else class="muted">—</span>
|
||||||
|
<div v-if="item.isRepeater" class="mini muted">Repeater</div>
|
||||||
|
<div v-if="item.isGateway" class="mini muted">Gateway</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono" style="text-align: center;">
|
||||||
|
<span v-if="item.port">{{ item.port }}</span>
|
||||||
|
<span v-else class="muted">—</span>
|
||||||
|
</td>
|
||||||
|
<td style="text-align: center;">
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" :checked="item.erledigt" @change="toggleErledigt(item.mac)">
|
||||||
|
<span class="switch-track">
|
||||||
|
<i class="fa-duotone fa-check on"></i>
|
||||||
|
<i class="fa-duotone fa-xmark off"></i>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td class="mini muted nowrap">
|
||||||
|
{{ formatDate(item.scannedAt) }}
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tt-data-table>
|
||||||
|
|
||||||
|
<div class="results-summary" style="margin-top: 12px;">
|
||||||
|
<span v-if="state?.devices?.length">
|
||||||
|
{{ filteredDevices.length }} von {{ state.devices.length }} Geräten
|
||||||
|
<template v-if="state.scanning"> (Scan läuft...)</template>
|
||||||
|
</span>
|
||||||
|
<span v-else class="muted">Keine Geräte</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
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);
|
||||||
|
}
|
||||||
277
scripts/avm_scanner.php
Normal file
277
scripts/avm_scanner.php
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* AVM Scanner Background Script
|
||||||
|
* Run with: php scripts/avm_scanner.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Bootstrap the application
|
||||||
|
require_once(__DIR__ . "/../config/config.php");
|
||||||
|
|
||||||
|
define('FRONKDB_SQLDEBUG', false);
|
||||||
|
error_reporting(E_ALL & ~(E_NOTICE | E_STRICT | E_DEPRECATED));
|
||||||
|
|
||||||
|
require_once(LIBDIR . "/mvcfronk/mfRouter/mfRouter.php");
|
||||||
|
require_once(LIBDIR . "/mvcfronk/mfBase/mfBaseModel.php");
|
||||||
|
require_once(LIBDIR . "/mvcfronk/mfBase/mfBaseController.php");
|
||||||
|
|
||||||
|
$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'
|
||||||
|
];
|
||||||
|
|
||||||
|
function getStatePath() {
|
||||||
|
return dirname(__DIR__) . '/files/avm_scanner.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadState() {
|
||||||
|
$path = getStatePath();
|
||||||
|
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' => []
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
Reference in New Issue
Block a user