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'] ?? '');
?>
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
+
+
+
+
+
Fehler
+
= e($error) ?>
+
+
+ = e($notice) ?>
+
+
+
+
+
+ Gruppe
+
Gruppe anlegen
+
+
+
+
+
+
+
+
+ IP-Bereich
+
Bereich zuweisen
+
+
+
+
+
+
+
+
+ Uebersicht
+
Gruppen und freie IPs
+
+
+
+
+
+
+ | Gruppe |
+ Beschreibung |
+ Bereiche |
+ Freie IPs |
+
+
+
+
+ | Noch keine Gruppen definiert. |
+
+
+
+
+ | = e((string)$group['name']) ?> |
+ = e((string)($group['description'] ?? '-')) ?> |
+
+
+ Kein Bereich
+
+
+ = e((string)$range['start_ip']) ?> - = e((string)$range['end_ip']) ?>
+
+
+ |
+ = e((string)count($available)) ?> |
+
+
+
+
+
+
+
+
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
= e((string)count($stats['groups'] ?? [])) ?>
+
+ Freie Gruppen-IPs
+ = e((string)array_sum($stats['free_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')) {