From 598348a4b684b19928e5a848ad7d64a023a5804f Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Wed, 15 Apr 2026 02:48:23 +0200 Subject: [PATCH] kea --- modules/kea/migrations/002_1.1.0_groups.php | 26 ++ modules/kea/module.json | 6 +- modules/kea/pages/edit.php | 54 +++- modules/kea/pages/groups.php | 167 ++++++++++++ modules/kea/pages/index.php | 10 + modules/kea/partials/dashboard.php | 4 + src/Repository/KeaHostMetadataRepository.php | 268 ++++++++++++++++++- src/Repository/KeaHostRepository.php | 8 + 8 files changed, 532 insertions(+), 11 deletions(-) create mode 100644 modules/kea/migrations/002_1.1.0_groups.php create mode 100644 modules/kea/pages/groups.php diff --git a/modules/kea/migrations/002_1.1.0_groups.php b/modules/kea/migrations/002_1.1.0_groups.php new file mode 100644 index 0000000..ec668a0 --- /dev/null +++ b/modules/kea/migrations/002_1.1.0_groups.php @@ -0,0 +1,26 @@ +settings(); + $fallback = is_array($context->module['metadata_db_defaults'] ?? null) + ? $context->module['metadata_db_defaults'] + : []; + $config = is_array($settings['metadata_db'] ?? null) + ? array_replace($fallback, $settings['metadata_db']) + : $fallback; + + if (empty($config['driver']) || empty($config['dbname'])) { + return; + } + + $repo = new KeaHostMetadataRepository(Database::createFromArray($config)); + $repo->ensureSchema(); + } +}; diff --git a/modules/kea/module.json b/modules/kea/module.json index f135b43..0061568 100644 --- a/modules/kea/module.json +++ b/modules/kea/module.json @@ -1,10 +1,11 @@ { "title": "KEA DHCP", - "version": "1.0.0", - "schema_version": 1, + "version": "1.1.0", + "schema_version": 2, "description": "Verwaltung von KEA DHCP Hosts und Reservierungen.", "menu": [ { "label": "Hosts", "href": "/module/kea" }, + { "label": "Gruppen", "href": "/module/kea/groups" }, { "label": "Setup", "href": "/modules/setup/kea" } ], "sidebar": { @@ -13,6 +14,7 @@ "default": "collapsed", "items": [ { "label": "Hosts", "href": "/module/kea" }, + { "label": "Gruppen", "href": "/module/kea/groups" }, { "label": "Setup", "href": "/modules/setup/kea" } ] }, diff --git a/modules/kea/pages/edit.php b/modules/kea/pages/edit.php index bb302a4..7f409a2 100644 --- a/modules/kea/pages/edit.php +++ b/modules/kea/pages/edit.php @@ -19,6 +19,7 @@ $notice = null; $host = null; $metadataRepo = null; $groups = []; +$availableIpsByGroup = []; try { $pdo = modules()->modulePdo('kea', $fallback); @@ -64,11 +65,19 @@ try { } $host = $repo->findDisplayByKey($source, $id) ?: $host; } + + $usedIps = array_diff( + array_merge($repo->usedIpAddresses(), $metadataRepo->desiredIps()), + [(string)($host['ipv4_address'] ?? ''), (string)($host['metadata']['desired_ip'] ?? '')] + ); + $availableIpsByGroup = $metadataRepo->availableIpsByGroup($usedIps); } catch (Throwable $e) { $error = $e->getMessage(); } $metadata = is_array($host['metadata'] ?? null) ? $host['metadata'] : []; +$selectedGroup = (string)($metadata['group_name'] ?? ''); +$selectedIp = (string)($metadata['desired_ip'] ?? ''); ?>
@@ -128,17 +137,19 @@ $metadata = is_array($host['metadata'] ?? null) ? $host['metadata'] : [];
+
diff --git a/modules/kea/pages/groups.php b/modules/kea/pages/groups.php new file mode 100644 index 0000000..62fdb0f --- /dev/null +++ b/modules/kea/pages/groups.php @@ -0,0 +1,167 @@ +get('kea'); +$settings = modules()->settings('kea'); +$metadataFallback = is_array($module['metadata_db_defaults'] ?? null) ? $module['metadata_db_defaults'] : []; +$metadataConfig = is_array($settings['metadata_db'] ?? null) + ? array_replace($metadataFallback, $settings['metadata_db']) + : $metadataFallback; +$fallback = $module['db_defaults'] ?? []; +$error = null; +$notice = null; +$groups = []; +$availableIpsByGroup = []; + +try { + if (empty($metadataConfig['driver']) || empty($metadataConfig['dbname'])) { + throw new RuntimeException('Nexus DHCP Zusatzdatenbank ist nicht konfiguriert.'); + } + + $metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig)); + $metadataRepo->ensureSchema(); + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = (string)($_POST['action'] ?? ''); + if ($action === 'save_group') { + $metadataRepo->saveGroup((string)($_POST['name'] ?? ''), (string)($_POST['description'] ?? '')); + $notice = 'Gruppe gespeichert.'; + } elseif ($action === 'add_range') { + $metadataRepo->addRange( + (string)($_POST['group_name'] ?? ''), + (string)($_POST['start_ip'] ?? ''), + (string)($_POST['end_ip'] ?? '') + ); + $notice = 'IP-Bereich gespeichert.'; + } + } + + $groups = $metadataRepo->listGroupsWithRanges(); + $keaRepo = new KeaHostRepository(modules()->modulePdo('kea', $fallback), $metadataRepo); + $availableIpsByGroup = $metadataRepo->availableIpsByGroup( + array_merge($keaRepo->usedIpAddresses(), $metadataRepo->desiredIps()), + 4096 + ); +} catch (Throwable $e) { + $error = $e->getMessage(); +} +?> +
+
+
+

KEA Gruppen

+

Gruppen und IP-Bereiche fuer DHCP-Reservierungen.

+
+ Zurueck +
+ + + + +
+ + +
+
+
+ Gruppe +

Gruppe anlegen

+
+
+
+ + + +
+ +
+
+
+ +
+
+
+ IP-Bereich +

Bereich zuweisen

+
+
+
+ + + + +
+ +
+
+
+ +
+
+
+ Uebersicht +

Gruppen und freie IPs

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
GruppeBeschreibungBereicheFreie IPs
Noch keine Gruppen definiert.
+ + Kein Bereich + + +
-
+ + +
+
+
+
diff --git a/modules/kea/pages/index.php b/modules/kea/pages/index.php index d29452d..10b20dc 100644 --- a/modules/kea/pages/index.php +++ b/modules/kea/pages/index.php @@ -20,6 +20,7 @@ $stats = [ 'reservations' => 0, 'leases' => 0, 'groups' => [], + 'free_ips' => [], ]; try { @@ -48,6 +49,15 @@ try { $stats['groups'][$group] = ($stats['groups'][$group] ?? 0) + 1; } } + if ($metadataRepo !== null) { + $stats['free_ips'] = array_map( + static fn(array $ips): int => count($ips), + $metadataRepo->availableIpsByGroup( + array_merge($repo->usedIpAddresses(), $metadataRepo->desiredIps()), + 4096 + ) + ); + } } catch (\Exception $e) { $error = "Datenbankfehler: " . $e->getMessage(); } diff --git a/modules/kea/partials/dashboard.php b/modules/kea/partials/dashboard.php index be19616..7b35980 100644 --- a/modules/kea/partials/dashboard.php +++ b/modules/kea/partials/dashboard.php @@ -45,6 +45,10 @@ Gruppen +
+ Freie Gruppen-IPs + +
diff --git a/src/Repository/KeaHostMetadataRepository.php b/src/Repository/KeaHostMetadataRepository.php index 51a99f2..84d9ec8 100644 --- a/src/Repository/KeaHostMetadataRepository.php +++ b/src/Repository/KeaHostMetadataRepository.php @@ -33,6 +33,7 @@ final class KeaHostMetadataRepository ); $this->ensureColumn('nexus_dhcp_host_meta', 'group_name', 'TEXT'); $this->ensureColumn('nexus_dhcp_host_meta', 'desired_ip', 'TEXT'); + $this->ensureGroupSchema($driver); return; } @@ -56,6 +57,7 @@ final class KeaHostMetadataRepository ); $this->ensureColumn('nexus_dhcp_host_meta', 'group_name', 'TEXT'); $this->ensureColumn('nexus_dhcp_host_meta', 'desired_ip', 'TEXT'); + $this->ensureGroupSchema($driver); return; } @@ -79,6 +81,7 @@ final class KeaHostMetadataRepository $this->ensureColumn('nexus_dhcp_host_meta', 'group_name', 'TEXT'); $this->ensureColumn('nexus_dhcp_host_meta', 'desired_ip', 'TEXT'); + $this->ensureGroupSchema($driver); } public function findByHostIds(array $hostIds): array @@ -183,19 +186,278 @@ final class KeaHostMetadataRepository public function listGroups(): array { + $groups = []; + + if ($this->tableExists('nexus_dhcp_groups')) { + $stmt = $this->pdo->query("SELECT name FROM nexus_dhcp_groups ORDER BY name ASC"); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $name = trim((string)($row['name'] ?? '')); + if ($name !== '') { + $groups[] = $name; + } + } + } + $stmt = $this->pdo->query( "SELECT DISTINCT group_name FROM nexus_dhcp_host_meta - WHERE group_name IS NOT NULL AND group_name <> '' - ORDER BY group_name ASC" + WHERE group_name IS NOT NULL AND group_name <> ''" + ); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $name = trim((string)($row['group_name'] ?? '')); + if ($name !== '') { + $groups[] = $name; + } + } + + sort($groups, SORT_NATURAL | SORT_FLAG_CASE); + return array_values(array_unique($groups)); + } + + public function listGroupsWithRanges(): array + { + if (!$this->tableExists('nexus_dhcp_groups')) { + return []; + } + + $groups = []; + $stmt = $this->pdo->query( + "SELECT id, name, description + FROM nexus_dhcp_groups + ORDER BY name ASC" + ); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $id = (int)$row['id']; + $groups[$id] = [ + 'id' => $id, + 'name' => (string)$row['name'], + 'description' => (string)($row['description'] ?? ''), + 'ranges' => [], + ]; + } + + if ($groups !== [] && $this->tableExists('nexus_dhcp_group_ranges')) { + $stmt = $this->pdo->query( + "SELECT id, group_id, start_ip, end_ip + FROM nexus_dhcp_group_ranges + ORDER BY start_ip ASC" + ); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $groupId = (int)$row['group_id']; + if (isset($groups[$groupId])) { + $groups[$groupId]['ranges'][] = [ + 'id' => (int)$row['id'], + 'start_ip' => (string)$row['start_ip'], + 'end_ip' => (string)$row['end_ip'], + ]; + } + } + } + + return array_values($groups); + } + + public function saveGroup(string $name, string $description = ''): void + { + $name = trim($name); + if ($name === '') { + throw new \RuntimeException('Gruppenname fehlt.'); + } + + $driver = (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + if ($driver === 'pgsql' || $driver === 'sqlite') { + $sql = "INSERT INTO nexus_dhcp_groups (name, description, updated_at) + VALUES (:name, :description, CURRENT_TIMESTAMP) + ON CONFLICT(name) DO UPDATE SET + description = excluded.description, + updated_at = CURRENT_TIMESTAMP"; + } else { + $sql = "INSERT INTO nexus_dhcp_groups (name, description, updated_at) + VALUES (:name, :description, CURRENT_TIMESTAMP) + ON DUPLICATE KEY UPDATE + description = VALUES(description), + updated_at = CURRENT_TIMESTAMP"; + } + + $stmt = $this->pdo->prepare($sql); + $stmt->execute([ + 'name' => $name, + 'description' => $this->nullableString($description), + ]); + } + + public function addRange(string $groupName, string $startIp, string $endIp): void + { + $groupName = trim($groupName); + $startIp = trim($startIp); + $endIp = trim($endIp); + if ($groupName === '' || !filter_var($startIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) || !filter_var($endIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + throw new \RuntimeException('Gruppe oder IP-Bereich ist ungueltig.'); + } + + if (ip2long($startIp) > ip2long($endIp)) { + throw new \RuntimeException('Start-IP muss vor der End-IP liegen.'); + } + + $groupId = $this->groupIdByName($groupName); + if ($groupId <= 0) { + throw new \RuntimeException('Gruppe wurde nicht gefunden.'); + } + + $stmt = $this->pdo->prepare( + "INSERT INTO nexus_dhcp_group_ranges (group_id, start_ip, end_ip, updated_at) + VALUES (:group_id, :start_ip, :end_ip, CURRENT_TIMESTAMP)" + ); + $stmt->execute([ + 'group_id' => $groupId, + 'start_ip' => $startIp, + 'end_ip' => $endIp, + ]); + } + + public function availableIpsByGroup(array $usedIps, int $limitPerGroup = 512): array + { + $used = array_flip(array_filter(array_map('strval', $usedIps))); + $items = []; + foreach ($this->listGroupsWithRanges() as $group) { + $available = []; + foreach ($group['ranges'] as $range) { + $start = ip2long((string)$range['start_ip']); + $end = ip2long((string)$range['end_ip']); + if ($start === false || $end === false) { + continue; + } + for ($ip = $start; $ip <= $end && count($available) < $limitPerGroup; $ip++) { + $address = long2ip($ip); + if ($address !== false && !isset($used[$address])) { + $available[] = $address; + } + } + } + $items[(string)$group['name']] = $available; + } + + return $items; + } + + public function desiredIps(): array + { + $stmt = $this->pdo->query( + "SELECT desired_ip + FROM nexus_dhcp_host_meta + WHERE desired_ip IS NOT NULL AND desired_ip <> ''" ); return array_values(array_filter(array_map( - static fn(array $row): string => (string)($row['group_name'] ?? ''), + static fn(array $row): string => (string)($row['desired_ip'] ?? ''), $stmt->fetchAll(PDO::FETCH_ASSOC) ))); } + private function ensureGroupSchema(string $driver): void + { + if ($driver === 'pgsql') { + $this->pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_dhcp_groups ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )" + ); + $this->pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_dhcp_group_ranges ( + id BIGSERIAL PRIMARY KEY, + group_id BIGINT NOT NULL REFERENCES nexus_dhcp_groups(id) ON DELETE CASCADE, + start_ip TEXT NOT NULL, + end_ip TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )" + ); + return; + } + + if ($driver === 'sqlite') { + $this->pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_dhcp_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ); + $this->pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_dhcp_group_ranges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL, + start_ip TEXT NOT NULL, + end_ip TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ); + return; + } + + $this->pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_dhcp_groups ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(190) NOT NULL UNIQUE, + description TEXT, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )" + ); + $this->pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_dhcp_group_ranges ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + group_id BIGINT NOT NULL, + start_ip VARCHAR(45) NOT NULL, + end_ip VARCHAR(45) NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_nexus_dhcp_group_ranges_group_id (group_id) + )" + ); + } + + private function groupIdByName(string $name): int + { + $stmt = $this->pdo->prepare('SELECT id FROM nexus_dhcp_groups WHERE name = :name LIMIT 1'); + $stmt->execute(['name' => $name]); + return (int)$stmt->fetchColumn(); + } + + private function tableExists(string $table): bool + { + $driver = (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + + if ($driver === 'sqlite') { + $stmt = $this->pdo->prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = :table LIMIT 1" + ); + $stmt->execute(['table' => $table]); + return (bool)$stmt->fetchColumn(); + } + + if ($driver === 'pgsql') { + $stmt = $this->pdo->prepare( + "SELECT 1 + FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = :table + LIMIT 1" + ); + } else { + $stmt = $this->pdo->prepare( + "SELECT 1 + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = :table + LIMIT 1" + ); + } + $stmt->execute(['table' => $table]); + return (bool)$stmt->fetchColumn(); + } + private function ensureColumn(string $table, string $column, string $definition): void { if ($this->columnExists($table, $column)) { diff --git a/src/Repository/KeaHostRepository.php b/src/Repository/KeaHostRepository.php index c5e3961..af1dfc9 100644 --- a/src/Repository/KeaHostRepository.php +++ b/src/Repository/KeaHostRepository.php @@ -66,6 +66,14 @@ final class KeaHostRepository } } + public function usedIpAddresses(int $limit = 10000): array + { + return array_values(array_unique(array_filter(array_map( + static fn(array $host): string => (string)($host['ipv4_address'] ?? ''), + $this->findAll($limit) + )))); + } + private function findReservations(int $limit): array { if (!$this->tableExists('hosts')) {