From 677f9314f51ada4d8d61b13023688286377be183 Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Mon, 13 Apr 2026 02:13:43 +0200 Subject: [PATCH] KEA Setup --- modules/kea/module.json | 30 +++- modules/kea/pages/index.php | 15 +- modules/kea/partials/dashboard.php | 12 +- partials/landingpages/modules/setup.php | 45 +++-- src/App/Database.php | 35 ---- src/App/ModuleManager.php | 1 + src/Repository/KeaHostMetadataRepository.php | 170 +++++++++++++++++++ src/Repository/KeaHostRepository.php | 50 ++++-- 8 files changed, 285 insertions(+), 73 deletions(-) create mode 100644 src/Repository/KeaHostMetadataRepository.php diff --git a/modules/kea/module.json b/modules/kea/module.json index 8ea25ba..f1d5072 100644 --- a/modules/kea/module.json +++ b/modules/kea/module.json @@ -17,13 +17,20 @@ }, "setup": { "fields": [ - { "name": "db.driver", "label": "DB Driver", "type": "text", "required": true }, - { "name": "db.host", "label": "DB Host", "type": "text", "required": true }, - { "name": "db.port", "label": "DB Port", "type": "number", "required": true }, - { "name": "db.dbname", "label": "DB Name", "type": "text", "required": true }, - { "name": "db.schema", "label": "DB Schema", "type": "text", "required": false }, - { "name": "db.user", "label": "DB User", "type": "text", "required": true }, - { "name": "db.password", "label": "DB Passwort", "type": "password", "required": true }, + { "name": "db.driver", "label": "KEA DB Driver", "type": "text", "required": true, "help": "Standard-KEA-Datenbank, die auch vom KEA-Dienst selbst genutzt wird." }, + { "name": "db.host", "label": "KEA DB Host", "type": "text", "required": true }, + { "name": "db.port", "label": "KEA DB Port", "type": "number", "required": true }, + { "name": "db.dbname", "label": "KEA DB Name", "type": "text", "required": true }, + { "name": "db.schema", "label": "KEA DB Schema", "type": "text", "required": false }, + { "name": "db.user", "label": "KEA DB User", "type": "text", "required": true }, + { "name": "db.password", "label": "KEA DB Passwort", "type": "password", "required": true }, + { "name": "metadata_db.driver", "label": "Nexus DHCP DB Driver", "type": "text", "required": true, "help": "Separate Datenbank fuer Nexus-eigene DHCP-Zusatzinfos, nicht fuer KEA-Standardtabellen." }, + { "name": "metadata_db.host", "label": "Nexus DHCP DB Host", "type": "text", "required": true }, + { "name": "metadata_db.port", "label": "Nexus DHCP DB Port", "type": "number", "required": true }, + { "name": "metadata_db.dbname", "label": "Nexus DHCP DB Name", "type": "text", "required": true }, + { "name": "metadata_db.schema", "label": "Nexus DHCP DB Schema", "type": "text", "required": false }, + { "name": "metadata_db.user", "label": "Nexus DHCP DB User", "type": "text", "required": true }, + { "name": "metadata_db.password", "label": "Nexus DHCP DB Passwort", "type": "password", "required": true }, { "name": "kea_db_version", "label": "KEA DB Version", "type": "text", "required": false }, { "name": "kea_init_script", "label": "KEA Init Script", "type": "text", "required": false }, { "name": "kea_init_cmd", "label": "KEA Init Command", "type": "text", "required": false }, @@ -38,5 +45,14 @@ "schema": "public", "user": "", "password": "" + }, + "metadata_db_defaults": { + "driver": "pgsql", + "host": "192.168.178.10", + "port": 5432, + "dbname": "", + "schema": "public", + "user": "", + "password": "" } } diff --git a/modules/kea/pages/index.php b/modules/kea/pages/index.php index 3d27b02..01dc426 100644 --- a/modules/kea/pages/index.php +++ b/modules/kea/pages/index.php @@ -1,15 +1,28 @@ get('kea'); $fallback = $module['db_defaults'] ?? []; $pdo = modules()->modulePdo('kea', $fallback); +$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; +$metadataRepo = null; $hosts = []; $error = null; try { - $repo = new KeaHostRepository($pdo); + if (!empty($metadataConfig['driver']) && !empty($metadataConfig['dbname'])) { + $metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig)); + $metadataRepo->ensureSchema(); + } + + $repo = new KeaHostRepository($pdo, $metadataRepo); $hosts = $repo->findAll(50); } catch (\Exception $e) { $error = "Datenbankfehler: " . $e->getMessage(); diff --git a/modules/kea/partials/dashboard.php b/modules/kea/partials/dashboard.php index b3bffff..4f61022 100644 --- a/modules/kea/partials/dashboard.php +++ b/modules/kea/partials/dashboard.php @@ -35,7 +35,8 @@ Hostname IP Adresse MAC Adresse - Kontext + Echter Name + Standort Edit @@ -44,7 +45,7 @@ - Keine Hosts gefunden. + Keine Hosts gefunden. @@ -59,7 +60,10 @@ - + + + + Bearbeiten @@ -71,4 +75,4 @@ - \ No newline at end of file + diff --git a/partials/landingpages/modules/setup.php b/partials/landingpages/modules/setup.php index d925a05..4380afe 100644 --- a/partials/landingpages/modules/setup.php +++ b/partials/landingpages/modules/setup.php @@ -28,10 +28,39 @@ $defaults = $module['db_defaults'] ?? []; if (empty($current['db']) && is_array($defaults)) { $current['db'] = $defaults; } +$metadataDefaults = $module['metadata_db_defaults'] ?? []; +if (empty($current['metadata_db']) && is_array($metadataDefaults)) { + $current['metadata_db'] = $metadataDefaults; +} + +$setNested = function (array &$target, string $path, mixed $value): void { + $parts = explode('.', $path); + $last = array_pop($parts); + $node = &$target; + foreach ($parts as $part) { + if (!isset($node[$part]) || !is_array($node[$part])) { + $node[$part] = []; + } + $node = &$node[$part]; + } + if ($last !== null && $last !== '') { + $node[$last] = $value; + } +}; + +$getNested = function (array $source, string $path): mixed { + $node = $source; + foreach (explode('.', $path) as $part) { + if (!is_array($node) || !array_key_exists($part, $node)) { + return null; + } + $node = $node[$part]; + } + return $node; +}; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $payload = []; - $db = $current['db'] ?? []; foreach ($fields as $field) { $name = (string)($field['name'] ?? ''); @@ -55,19 +84,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { continue; } - if (str_starts_with($name, 'db.')) { - $key = substr($name, 3); - $db[$key] = $value; + if (str_contains($name, '.')) { + $setNested($payload, $name, $value); continue; } $payload[$name] = $value; } - if (!empty($db)) { - $payload['db'] = $db; - } - modules()->saveSettings($moduleName, $payload); modules()->saveAuth($moduleName, [ 'required' => isset($_POST['auth_required']), @@ -107,9 +131,8 @@ $authConfig = is_array($module['auth'] ?? null) ? $module['auth'] : ['required' $value = ''; if ($name === 'kea_auto_init') { $value = !empty($current[$name]) ? '1' : '0'; - } elseif (str_starts_with($name, 'db.')) { - $key = substr($name, 3); - $value = (string)($current['db'][$key] ?? ''); + } elseif (str_contains($name, '.')) { + $value = (string)($getNested($current, $name) ?? ''); } else { $value = (string)($current[$name] ?? ''); } diff --git a/src/App/Database.php b/src/App/Database.php index 692711f..a798b16 100755 --- a/src/App/Database.php +++ b/src/App/Database.php @@ -98,10 +98,6 @@ final class Database } } - // After init, ensure our metadata table exists (non-invasive) - if (self::tableExists($pdo, 'hosts')) { - self::ensureNexusTables($pdo); - } } private static function tableExists(\PDO $pdo, string $table): bool @@ -179,37 +175,6 @@ final class Database $pdo->exec($sql); } - private static function ensureNexusTables(\PDO $pdo): void - { - $pdo->exec( - "CREATE TABLE IF NOT EXISTS nexus_host_meta ( - host_id BIGINT PRIMARY KEY, - location TEXT, - device_type TEXT, - owner TEXT, - tags JSONB, - notes TEXT, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - )" - ); - - $pdo->exec( - "DO $$ - BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.table_constraints - WHERE constraint_name = 'fk_nexus_host_meta_host' - ) THEN - ALTER TABLE nexus_host_meta - ADD CONSTRAINT fk_nexus_host_meta_host - FOREIGN KEY (host_id) - REFERENCES hosts(host_id) - ON DELETE CASCADE; - END IF; - END $$;" - ); - } - private static function buildMysqlDsn(array $db): string { if (empty($db['dbname'])) { diff --git a/src/App/ModuleManager.php b/src/App/ModuleManager.php index c3004b2..0bf5b6d 100644 --- a/src/App/ModuleManager.php +++ b/src/App/ModuleManager.php @@ -258,6 +258,7 @@ final class ModuleManager 'menu' => $data['menu'] ?? [], 'sidebar' => $data['sidebar'] ?? [], 'db_defaults' => $data['db_defaults'] ?? [], + 'metadata_db_defaults' => $data['metadata_db_defaults'] ?? [], 'path' => $dir, 'entry' => '/module/' . rawurlencode($name), 'auth' => is_array($data['auth'] ?? null) ? $data['auth'] : ['required' => false, 'users' => [], 'groups' => []], diff --git a/src/Repository/KeaHostMetadataRepository.php b/src/Repository/KeaHostMetadataRepository.php new file mode 100644 index 0000000..dee270f --- /dev/null +++ b/src/Repository/KeaHostMetadataRepository.php @@ -0,0 +1,170 @@ +pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + + if ($driver === 'pgsql') { + $this->pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_dhcp_host_meta ( + host_id BIGINT PRIMARY KEY, + hardware_address TEXT, + ip_address TEXT, + real_name TEXT, + device_name TEXT, + owner TEXT, + location TEXT, + device_type TEXT, + notes TEXT, + tags_json JSONB, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )" + ); + return; + } + + if ($driver === 'sqlite') { + $this->pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_dhcp_host_meta ( + host_id INTEGER PRIMARY KEY, + hardware_address TEXT, + ip_address TEXT, + real_name TEXT, + device_name TEXT, + owner TEXT, + location TEXT, + device_type TEXT, + notes TEXT, + tags_json TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ); + return; + } + + $this->pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_dhcp_host_meta ( + host_id BIGINT PRIMARY KEY, + hardware_address TEXT, + ip_address TEXT, + real_name TEXT, + device_name TEXT, + owner TEXT, + location TEXT, + device_type TEXT, + notes TEXT, + tags_json TEXT, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )" + ); + } + + public function findByHostIds(array $hostIds): array + { + $hostIds = array_values(array_unique(array_filter(array_map('intval', $hostIds)))); + if ($hostIds === []) { + return []; + } + + $params = []; + $placeholders = []; + foreach ($hostIds as $idx => $hostId) { + $key = ':host_id_' . $idx; + $placeholders[] = $key; + $params[$key] = $hostId; + } + + $stmt = $this->pdo->prepare( + 'SELECT host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, notes, tags_json, updated_at + FROM nexus_dhcp_host_meta + WHERE host_id IN (' . implode(', ', $placeholders) . ')' + ); + foreach ($params as $key => $value) { + $stmt->bindValue($key, $value, PDO::PARAM_INT); + } + $stmt->execute(); + + $items = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $items[(int)$row['host_id']] = $row; + } + + return $items; + } + + public function saveForHost(int $hostId, string $hardwareAddress, string $ipAddress, array $metadata): void + { + $metadata = array_merge([ + 'real_name' => null, + 'device_name' => null, + 'owner' => null, + 'location' => null, + 'device_type' => null, + 'notes' => null, + 'tags' => [], + ], $metadata); + + $tagsJson = json_encode($metadata['tags'], JSON_UNESCAPED_UNICODE); + if ($tagsJson === false) { + $tagsJson = '[]'; + } + + $driver = (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + if ($driver === 'pgsql') { + $stmt = $this->pdo->prepare( + "INSERT INTO nexus_dhcp_host_meta ( + host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, notes, tags_json, updated_at + ) VALUES ( + :host_id, :hardware_address, :ip_address, :real_name, :device_name, :owner, :location, :device_type, :notes, CAST(:tags_json AS jsonb), NOW() + ) + ON CONFLICT (host_id) DO UPDATE SET + hardware_address = EXCLUDED.hardware_address, + ip_address = EXCLUDED.ip_address, + real_name = EXCLUDED.real_name, + device_name = EXCLUDED.device_name, + owner = EXCLUDED.owner, + location = EXCLUDED.location, + device_type = EXCLUDED.device_type, + notes = EXCLUDED.notes, + tags_json = EXCLUDED.tags_json, + updated_at = NOW()" + ); + } else { + $stmt = $this->pdo->prepare( + "REPLACE INTO nexus_dhcp_host_meta ( + host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, notes, tags_json, updated_at + ) VALUES ( + :host_id, :hardware_address, :ip_address, :real_name, :device_name, :owner, :location, :device_type, :notes, :tags_json, CURRENT_TIMESTAMP + )" + ); + } + + $stmt->execute([ + 'host_id' => $hostId, + 'hardware_address' => $hardwareAddress, + 'ip_address' => $ipAddress, + 'real_name' => $this->nullableString($metadata['real_name']), + 'device_name' => $this->nullableString($metadata['device_name']), + 'owner' => $this->nullableString($metadata['owner']), + 'location' => $this->nullableString($metadata['location']), + 'device_type' => $this->nullableString($metadata['device_type']), + 'notes' => $this->nullableString($metadata['notes']), + 'tags_json' => $tagsJson, + ]); + } + + private function nullableString(mixed $value): ?string + { + $value = trim((string)$value); + return $value !== '' ? $value : null; + } +} diff --git a/src/Repository/KeaHostRepository.php b/src/Repository/KeaHostRepository.php index eab654a..8986978 100644 --- a/src/Repository/KeaHostRepository.php +++ b/src/Repository/KeaHostRepository.php @@ -7,11 +7,14 @@ use PDO; /** * Repository für das KEA DHCP Host-Management. - * Interagiert direkt mit der 'hosts' Tabelle in der PostgreSQL-Datenbank. + * Interagiert direkt mit der 'hosts' Tabelle in der KEA-Datenbank. */ final class KeaHostRepository { - public function __construct(private PDO $pdo) {} + public function __construct( + private PDO $pdo, + private ?KeaHostMetadataRepository $metadata = null + ) {} /** * Ruft eine Liste aller Host-Reservierungen ab (Geräte-Inventar). @@ -29,7 +32,7 @@ final class KeaHostRepository $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->execute(); - return $stmt->fetchAll(PDO::FETCH_ASSOC); + return $this->withMetadata($stmt->fetchAll(PDO::FETCH_ASSOC)); } catch (\PDOException $e) { if ($this->isMissingTable($e)) { throw new \RuntimeException( @@ -85,28 +88,45 @@ final class KeaHostRepository * @param string $ip IPv4-Adresse * @param int $subnetId Die ID des Subnets (dhcp4_subnet_id), in dem die IP liegt * @param string|null $hostname Optionaler Hostname - * @param array $metadata Zusätzliche Infos (Standort, Verantwortlicher etc.) für 'user_context' + * @param array $metadata Zusätzliche Infos fuer die separate Nexus-DHCP-Metadatenbank. */ public function create(string $mac, string $ip, int $subnetId, ?string $hostname = null, array $metadata = []): int { - // Metadaten werden im KEA-Standardfeld 'user_context' als JSON gespeichert - $userContextJson = json_encode($metadata, JSON_THROW_ON_ERROR); - // dhcp_identifier_type 1 = HW_ADDRESS (Ethernet) $stmt = $this->pdo->prepare( - 'INSERT INTO hosts (dhcp_identifier, dhcp_identifier_type, dhcp4_subnet_id, ipv4_address, hostname, user_context) - VALUES (:mac, 1, :subnetId, :ip, :hostname, :user_context)' + 'INSERT INTO hosts (dhcp_identifier, dhcp_identifier_type, dhcp4_subnet_id, ipv4_address, hostname) + VALUES (:mac, 1, :subnetId, :ip, :hostname)' ); $stmt->execute([ - 'mac' => $mac, - 'subnetId' => $subnetId, - 'ip' => $ip, - 'hostname' => $hostname, - 'user_context' => $userContextJson, + 'mac' => $mac, + 'subnetId' => $subnetId, + 'ip' => $ip, + 'hostname' => $hostname, ]); - return $this->lastInsertIdSafe(); + $hostId = $this->lastInsertIdSafe(); + if ($this->metadata !== null && $metadata !== []) { + $this->metadata->saveForHost($hostId, $mac, $ip, $metadata); + } + + return $hostId; + } + + private function withMetadata(array $hosts): array + { + if ($hosts === [] || $this->metadata === null) { + return $hosts; + } + + $metadataByHost = $this->metadata->findByHostIds(array_column($hosts, 'host_id')); + foreach ($hosts as &$host) { + $hostId = (int)($host['host_id'] ?? 0); + $host['metadata'] = $metadataByHost[$hostId] ?? []; + } + unset($host); + + return $hosts; } private function driver(): string