diff --git a/modules/kea/migrations/001_1.0.0_baseline.php b/modules/kea/migrations/001_1.0.0_baseline.php new file mode 100644 index 0000000..ec668a0 --- /dev/null +++ b/modules/kea/migrations/001_1.0.0_baseline.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 31cf00e..f135b43 100644 --- a/modules/kea/module.json +++ b/modules/kea/module.json @@ -1,6 +1,7 @@ { "title": "KEA DHCP", "version": "1.0.0", + "schema_version": 1, "description": "Verwaltung von KEA DHCP Hosts und Reservierungen.", "menu": [ { "label": "Hosts", "href": "/module/kea" }, diff --git a/modules/kea/pages/edit.php b/modules/kea/pages/edit.php index 3dc3192..bb302a4 100644 --- a/modules/kea/pages/edit.php +++ b/modules/kea/pages/edit.php @@ -18,6 +18,7 @@ $error = null; $notice = null; $host = null; $metadataRepo = null; +$groups = []; try { $pdo = modules()->modulePdo('kea', $fallback); @@ -27,6 +28,7 @@ try { $metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig)); $metadataRepo->ensureSchema(); + $groups = $metadataRepo->listGroups(); $repo = new KeaHostRepository($pdo, $metadataRepo); $host = $repo->findDisplayByKey($source, $id); if (!$host) { @@ -40,16 +42,26 @@ try { 'owner' => $_POST['owner'] ?? '', 'location' => $_POST['location'] ?? '', 'device_type' => $_POST['device_type'] ?? '', + 'group_name' => $_POST['group_name'] ?? '', + 'desired_ip' => $_POST['desired_ip'] ?? '', 'notes' => $_POST['notes'] ?? '', 'tags' => [], ]; - $metadataRepo->saveForHost( - $id, - (string)($host['dhcp_identifier'] ?? ''), - (string)($host['ipv4_address'] ?? ''), - $metadata - ); - $notice = 'Zusatzdaten gespeichert.'; + $desiredIp = trim((string)$metadata['desired_ip']); + if ($desiredIp !== '') { + $newHostId = $repo->reserveDisplayEntry($host, $desiredIp, $metadata); + $source = 'reservation'; + $id = $newHostId; + $notice = 'Zusatzdaten gespeichert und KEA-Reservierung gesetzt.'; + } else { + $metadataRepo->saveForHost( + $id, + (string)($host['dhcp_identifier'] ?? ''), + (string)($host['ipv4_address'] ?? ''), + $metadata + ); + $notice = 'Zusatzdaten gespeichert.'; + } $host = $repo->findDisplayByKey($source, $id) ?: $host; } } catch (Throwable $e) { @@ -114,6 +126,20 @@ $metadata = is_array($host['metadata'] ?? null) ? $host['metadata'] : []; Gerätetyp + + Gruppe + + + + + + + + + Feste IP + + Wenn gesetzt, wird der Eintrag als KEA-Reservierung gespeichert. + Notizen = e((string)($metadata['notes'] ?? '')) ?> diff --git a/modules/kea/pages/index.php b/modules/kea/pages/index.php index 7289860..d29452d 100644 --- a/modules/kea/pages/index.php +++ b/modules/kea/pages/index.php @@ -15,6 +15,12 @@ $metadataRepo = null; $hosts = []; $error = null; $warnings = []; +$stats = [ + 'total' => 0, + 'reservations' => 0, + 'leases' => 0, + 'groups' => [], +]; try { $pdo = modules()->modulePdo('kea', $fallback); @@ -30,8 +36,20 @@ try { $repo = new KeaHostRepository($pdo, $metadataRepo); $hosts = $repo->findAll(50); + $stats['total'] = count($hosts); + foreach ($hosts as $host) { + if (($host['source'] ?? '') === 'lease') { + $stats['leases']++; + } else { + $stats['reservations']++; + } + $group = trim((string)($host['metadata']['group_name'] ?? '')); + if ($group !== '') { + $stats['groups'][$group] = ($stats['groups'][$group] ?? 0) + 1; + } + } } catch (\Exception $e) { $error = "Datenbankfehler: " . $e->getMessage(); } -module_tpl('kea', 'dashboard', compact('hosts', 'error', 'warnings')); +module_tpl('kea', 'dashboard', compact('hosts', 'error', 'warnings', 'stats')); diff --git a/modules/kea/partials/dashboard.php b/modules/kea/partials/dashboard.php index 423533c..be19616 100644 --- a/modules/kea/partials/dashboard.php +++ b/modules/kea/partials/dashboard.php @@ -3,6 +3,7 @@ * @var array $hosts Die Liste der KEA-Hosts. * @var string|null $error Eine Fehlermeldung, falls vorhanden. * @var array $warnings Hinweise, falls Zusatzdaten nicht geladen werden konnten. + * @var array $stats Kennzahlen fuer die Uebersicht. */ ?> @@ -27,6 +28,25 @@ + + + Einträge + = e((string)($stats['total'] ?? 0)) ?> + + + Reservierungen + = e((string)($stats['reservations'] ?? 0)) ?> + + + Leases + = e((string)($stats['leases'] ?? 0)) ?> + + + Gruppen + = e((string)count($stats['groups'] ?? [])) ?> + + + @@ -45,13 +65,14 @@ MAC Adresse Echter Name Standort + Gruppe Aktion - + Keine Reservierungen oder aktiven Leases gefunden. @@ -62,7 +83,7 @@ = ($host['source'] ?? '') === 'lease' ? 'Lease' : 'Reservierung' ?> - = e($host['hostname'] ?: 'Unbekannt') ?> + = e((string)($host['metadata']['device_name'] ?? $host['metadata']['real_name'] ?? $host['display_name'] ?? $host['hostname'] ?? 'Unbekannt')) ?> = e($host['ipv4_address']) ?> @@ -76,6 +97,9 @@ = e((string)($host['metadata']['location'] ?? '-')) ?> + + = e((string)($host['metadata']['group_name'] ?? '-')) ?> + Bearbeiten diff --git a/partials/landingpages/modules/index.php b/partials/landingpages/modules/index.php index c74dbf6..4dc8d66 100644 --- a/partials/landingpages/modules/index.php +++ b/partials/landingpages/modules/index.php @@ -13,6 +13,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { modules()->setEnabled($name, $enabled); $notice = $enabled ? 'Modul aktiviert.' : 'Modul deaktiviert.'; $modules = modules()->all(); + } elseif ($name !== '' && $action === 'migrate') { + $applied = modules()->applyPendingMigrations($name); + $notice = count($applied) . ' Migration(en) angewendet.'; + $modules = modules()->all(); } else { $error = 'Ungültige Aktion.'; } @@ -49,6 +53,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $accessLabel = $hasSpecificAccess ? 'Spezielle Zugriffsrechte' : ($authRequired ? 'Login erforderlich' : 'Kein Modulschutz'); + $migrationStatus = modules()->migrationStatus($module['name']); + $pendingMigrations = $migrationStatus['pending'] ?? []; + $changedMigrations = $migrationStatus['changed'] ?? []; ?> @@ -61,8 +68,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { = e($accessLabel) ?> + + + = e((string)count($pendingMigrations)) ?> Migration(en) + + 0): ?> + Schema aktuell + + + + Achtung: = e((string)count($changedMigrations)) ?> bereits angewendete Migration(en) wurden veraendert. + + @@ -80,6 +99,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { Öffnen Setup Zugriff + + + + Migrationen anwenden + + Deaktivieren diff --git a/partials/landingpages/modules/install.php b/partials/landingpages/modules/install.php index a7391ce..c1ed671 100644 --- a/partials/landingpages/modules/install.php +++ b/partials/landingpages/modules/install.php @@ -11,6 +11,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { modules()->setEnabled($name, $action === 'enable'); $notice = $action === 'enable' ? 'Modul aktiviert.' : 'Modul deaktiviert.'; $modules = modules()->all(); + } elseif ($name !== '' && $action === 'migrate') { + $applied = modules()->applyPendingMigrations($name); + $notice = count($applied) . ' Migration(en) angewendet.'; + $modules = modules()->all(); } else { $error = 'Ungültige Aktion.'; } @@ -48,11 +52,21 @@ foreach ($modules as $m) { Aktive Module + migrationStatus($module['name']); + $pendingMigrations = $migrationStatus['pending'] ?? []; + ?> = e($module['title']) ?> = e($module['description'] ?? '') ?> Öffnen + + + + Migrationen anwenden + + Deaktivieren @@ -65,11 +79,21 @@ foreach ($modules as $m) { Deaktivierte Module + migrationStatus($module['name']); + $pendingMigrations = $migrationStatus['pending'] ?? []; + ?> = e($module['title']) ?> = e($module['description'] ?? '') ?> Setup + + + + Migrationen anwenden + + Aktivieren diff --git a/src/App/BaseSchema.php b/src/App/BaseSchema.php index 4646d6c..7df554e 100644 --- a/src/App/BaseSchema.php +++ b/src/App/BaseSchema.php @@ -40,6 +40,17 @@ final class BaseSchema )" ); + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_module_migrations ( + module_name TEXT NOT NULL, + migration TEXT NOT NULL, + version TEXT, + checksum TEXT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (module_name, migration) + )" + ); + $pdo->exec( "CREATE TABLE IF NOT EXISTS nexus_module_auth ( name TEXT PRIMARY KEY, @@ -117,6 +128,17 @@ final class BaseSchema )" ); + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_module_migrations ( + module_name TEXT NOT NULL, + migration TEXT NOT NULL, + version TEXT, + checksum TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (module_name, migration) + )" + ); + $pdo->exec( "CREATE TABLE IF NOT EXISTS nexus_module_auth ( name TEXT PRIMARY KEY, @@ -194,6 +216,17 @@ final class BaseSchema )" ); + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_module_migrations ( + module_name VARCHAR(190) NOT NULL, + migration VARCHAR(190) NOT NULL, + version VARCHAR(64), + checksum VARCHAR(64) NOT NULL, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (module_name, migration) + )" + ); + $pdo->exec( "CREATE TABLE IF NOT EXISTS nexus_module_auth ( name VARCHAR(190) PRIMARY KEY, diff --git a/src/App/ModuleManager.php b/src/App/ModuleManager.php index ba5aad3..7fed9e8 100644 --- a/src/App/ModuleManager.php +++ b/src/App/ModuleManager.php @@ -65,6 +65,24 @@ final class ModuleManager $this->modules[$name]['enabled'] = $enabled; } + public function migrationStatus(string $name): array + { + if (!$this->basePdo || !isset($this->modules[$name])) { + return ['available' => 0, 'applied' => 0, 'pending' => [], 'changed' => []]; + } + + return (new ModuleMigrationService($this->basePdo, $this))->status($this->modules[$name]); + } + + public function applyPendingMigrations(string $name): array + { + if (!$this->basePdo || !isset($this->modules[$name])) { + return []; + } + + return (new ModuleMigrationService($this->basePdo, $this))->applyPending($this->modules[$name]); + } + public function settings(string $name): array { if (!$this->basePdo) { @@ -260,6 +278,7 @@ final class ModuleManager 'sidebar' => $data['sidebar'] ?? [], 'db_defaults' => $data['db_defaults'] ?? [], 'metadata_db_defaults' => $data['metadata_db_defaults'] ?? [], + 'schema_version' => (int)($data['schema_version'] ?? 0), 'path' => $dir, 'entry' => '/module/' . rawurlencode($name), 'auth' => $this->loadAuth($name, $manifestAuth), diff --git a/src/App/ModuleMigrationContext.php b/src/App/ModuleMigrationContext.php new file mode 100644 index 0000000..0752d44 --- /dev/null +++ b/src/App/ModuleMigrationContext.php @@ -0,0 +1,18 @@ +modules->settings((string)$this->module['name']); + } +} diff --git a/src/App/ModuleMigrationService.php b/src/App/ModuleMigrationService.php new file mode 100644 index 0000000..31fd363 --- /dev/null +++ b/src/App/ModuleMigrationService.php @@ -0,0 +1,172 @@ +discover($module); + $applied = $this->applied((string)$module['name']); + $pending = []; + $changed = []; + + foreach ($migrations as $migration) { + $id = $migration['id']; + if (!isset($applied[$id])) { + $pending[] = $migration; + continue; + } + if (($applied[$id]['checksum'] ?? '') !== $migration['checksum']) { + $changed[] = $migration; + } + } + + return [ + 'available' => count($migrations), + 'applied' => count($applied), + 'pending' => $pending, + 'changed' => $changed, + ]; + } + + public function applyPending(array $module): array + { + $status = $this->status($module); + $applied = []; + $moduleName = (string)$module['name']; + $context = new ModuleMigrationContext($this->basePdo, $this->modules, $module); + + foreach ($status['pending'] as $migration) { + $this->basePdo->beginTransaction(); + try { + $runner = require $migration['path']; + if (is_object($runner) && method_exists($runner, 'up')) { + $runner->up($context); + } elseif (is_callable($runner)) { + $runner($context); + } else { + throw new \RuntimeException('Migration does not return a callable or object with up().'); + } + + $this->record($moduleName, $migration); + $this->basePdo->commit(); + $applied[] = $migration; + } catch (\Throwable $e) { + if ($this->basePdo->inTransaction()) { + $this->basePdo->rollBack(); + } + throw new \RuntimeException( + 'Migration fehlgeschlagen: ' . $moduleName . '/' . $migration['id'] . ' - ' . $e->getMessage(), + 0, + $e + ); + } + } + + if ($applied !== []) { + $this->recordModuleVersion($moduleName, (string)($module['version'] ?? '')); + } + + return $applied; + } + + private function discover(array $module): array + { + $path = (string)($module['path'] ?? '') . '/migrations'; + if (!is_dir($path)) { + return []; + } + + $items = []; + foreach (glob($path . '/*.php') ?: [] as $file) { + $id = basename($file, '.php'); + $items[] = [ + 'id' => $id, + 'version' => $this->versionFromId($id), + 'path' => $file, + 'checksum' => hash_file('sha256', $file) ?: '', + ]; + } + + usort($items, static fn(array $a, array $b): int => strnatcmp($a['id'], $b['id'])); + return $items; + } + + private function applied(string $moduleName): array + { + $stmt = $this->basePdo->prepare( + "SELECT migration, version, checksum, applied_at + FROM nexus_module_migrations + WHERE module_name = :module + ORDER BY migration ASC" + ); + $stmt->execute(['module' => $moduleName]); + + $items = []; + foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) as $row) { + $items[(string)$row['migration']] = $row; + } + return $items; + } + + private function record(string $moduleName, array $migration): void + { + $driver = (string)$this->basePdo->getAttribute(\PDO::ATTR_DRIVER_NAME); + if ($driver === 'pgsql' || $driver === 'sqlite') { + $sql = "INSERT INTO nexus_module_migrations (module_name, migration, version, checksum, applied_at) + VALUES (:module, :migration, :version, :checksum, CURRENT_TIMESTAMP) + ON CONFLICT(module_name, migration) DO UPDATE SET + version = excluded.version, + checksum = excluded.checksum, + applied_at = CURRENT_TIMESTAMP"; + } else { + $sql = "INSERT INTO nexus_module_migrations (module_name, migration, version, checksum, applied_at) + VALUES (:module, :migration, :version, :checksum, CURRENT_TIMESTAMP) + ON DUPLICATE KEY UPDATE + version = VALUES(version), + checksum = VALUES(checksum), + applied_at = CURRENT_TIMESTAMP"; + } + + $stmt = $this->basePdo->prepare($sql); + $stmt->execute([ + 'module' => $moduleName, + 'migration' => $migration['id'], + 'version' => $migration['version'], + 'checksum' => $migration['checksum'], + ]); + } + + private function recordModuleVersion(string $moduleName, string $version): void + { + if ($version === '') { + return; + } + + $stmt = $this->basePdo->prepare( + "UPDATE nexus_modules + SET version = :version, + updated_at = CURRENT_TIMESTAMP + WHERE name = :module" + ); + $stmt->execute([ + 'module' => $moduleName, + 'version' => $version, + ]); + } + + private function versionFromId(string $id): string + { + if (preg_match('/(?:^|_)(\d+\.\d+\.\d+)(?:_|$)/', $id, $m)) { + return $m[1]; + } + return ''; + } +} diff --git a/src/Repository/KeaHostMetadataRepository.php b/src/Repository/KeaHostMetadataRepository.php index dee270f..51a99f2 100644 --- a/src/Repository/KeaHostMetadataRepository.php +++ b/src/Repository/KeaHostMetadataRepository.php @@ -24,11 +24,15 @@ final class KeaHostMetadataRepository owner TEXT, location TEXT, device_type TEXT, + group_name TEXT, + desired_ip TEXT, notes TEXT, tags_json JSONB, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )" ); + $this->ensureColumn('nexus_dhcp_host_meta', 'group_name', 'TEXT'); + $this->ensureColumn('nexus_dhcp_host_meta', 'desired_ip', 'TEXT'); return; } @@ -43,11 +47,15 @@ final class KeaHostMetadataRepository owner TEXT, location TEXT, device_type TEXT, + group_name TEXT, + desired_ip TEXT, notes TEXT, tags_json TEXT, updated_at TEXT NOT NULL DEFAULT (datetime('now')) )" ); + $this->ensureColumn('nexus_dhcp_host_meta', 'group_name', 'TEXT'); + $this->ensureColumn('nexus_dhcp_host_meta', 'desired_ip', 'TEXT'); return; } @@ -61,11 +69,16 @@ final class KeaHostMetadataRepository owner TEXT, location TEXT, device_type TEXT, + group_name TEXT, + desired_ip TEXT, notes TEXT, tags_json TEXT, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )" ); + + $this->ensureColumn('nexus_dhcp_host_meta', 'group_name', 'TEXT'); + $this->ensureColumn('nexus_dhcp_host_meta', 'desired_ip', 'TEXT'); } public function findByHostIds(array $hostIds): array @@ -84,7 +97,7 @@ final class KeaHostMetadataRepository } $stmt = $this->pdo->prepare( - 'SELECT host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, notes, tags_json, updated_at + 'SELECT host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, group_name, desired_ip, notes, tags_json, updated_at FROM nexus_dhcp_host_meta WHERE host_id IN (' . implode(', ', $placeholders) . ')' ); @@ -109,6 +122,8 @@ final class KeaHostMetadataRepository 'owner' => null, 'location' => null, 'device_type' => null, + 'group_name' => null, + 'desired_ip' => null, 'notes' => null, 'tags' => [], ], $metadata); @@ -122,9 +137,9 @@ final class KeaHostMetadataRepository 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 + host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, group_name, desired_ip, 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() + :host_id, :hardware_address, :ip_address, :real_name, :device_name, :owner, :location, :device_type, :group_name, :desired_ip, :notes, CAST(:tags_json AS jsonb), NOW() ) ON CONFLICT (host_id) DO UPDATE SET hardware_address = EXCLUDED.hardware_address, @@ -134,6 +149,8 @@ final class KeaHostMetadataRepository owner = EXCLUDED.owner, location = EXCLUDED.location, device_type = EXCLUDED.device_type, + group_name = EXCLUDED.group_name, + desired_ip = EXCLUDED.desired_ip, notes = EXCLUDED.notes, tags_json = EXCLUDED.tags_json, updated_at = NOW()" @@ -141,9 +158,9 @@ final class KeaHostMetadataRepository } 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 + host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, group_name, desired_ip, 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 + :host_id, :hardware_address, :ip_address, :real_name, :device_name, :owner, :location, :device_type, :group_name, :desired_ip, :notes, :tags_json, CURRENT_TIMESTAMP )" ); } @@ -157,11 +174,85 @@ final class KeaHostMetadataRepository 'owner' => $this->nullableString($metadata['owner']), 'location' => $this->nullableString($metadata['location']), 'device_type' => $this->nullableString($metadata['device_type']), + 'group_name' => $this->nullableString($metadata['group_name']), + 'desired_ip' => $this->nullableString($metadata['desired_ip']), 'notes' => $this->nullableString($metadata['notes']), 'tags_json' => $tagsJson, ]); } + public function listGroups(): array + { + $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" + ); + + return array_values(array_filter(array_map( + static fn(array $row): string => (string)($row['group_name'] ?? ''), + $stmt->fetchAll(PDO::FETCH_ASSOC) + ))); + } + + private function ensureColumn(string $table, string $column, string $definition): void + { + if ($this->columnExists($table, $column)) { + return; + } + + $driver = (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $quote = $driver === 'mysql' ? '`' : '"'; + try { + $this->pdo->exec( + 'ALTER TABLE ' . $quote . $table . $quote + . ' ADD COLUMN ' . $quote . $column . $quote . ' ' . $definition + ); + } catch (\PDOException $e) { + if (!$this->columnExists($table, $column)) { + throw $e; + } + } + } + + private function columnExists(string $table, string $column): bool + { + $driver = (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + + if ($driver === 'sqlite') { + $stmt = $this->pdo->query('PRAGMA table_info(' . $table . ')'); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + if (($row['name'] ?? '') === $column) { + return true; + } + } + return false; + } + + if ($driver === 'pgsql') { + $stmt = $this->pdo->prepare( + "SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = :table + AND column_name = :column + LIMIT 1" + ); + } else { + $stmt = $this->pdo->prepare( + "SELECT 1 + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = :table + AND column_name = :column + LIMIT 1" + ); + } + $stmt->execute(['table' => $table, 'column' => $column]); + return (bool)$stmt->fetchColumn(); + } + private function nullableString(mixed $value): ?string { $value = trim((string)$value); diff --git a/src/Repository/KeaHostRepository.php b/src/Repository/KeaHostRepository.php index 5b14976..c5e3961 100644 --- a/src/Repository/KeaHostRepository.php +++ b/src/Repository/KeaHostRepository.php @@ -84,6 +84,7 @@ final class KeaHostRepository {$ipExpr} AS ipv4_address_text, hostname, user_context, + dhcp4_subnet_id AS subnet_id, 'reservation' AS source, host_id AS sort_id, {$sortExpr} AS sort_time @@ -114,6 +115,7 @@ final class KeaHostRepository {$ipExpr} AS ipv4_address_text, hostname, user_context, + subnet_id, 'lease' AS source, address AS sort_id, {$expireExpr} AS sort_time @@ -185,6 +187,7 @@ final class KeaHostRepository {$ipExpr} AS ipv4_address_text, hostname, user_context, + dhcp4_subnet_id AS subnet_id, 'reservation' AS source, host_id AS sort_id, {$sortExpr} AS sort_time @@ -220,16 +223,19 @@ final class KeaHostRepository */ public function create(string $mac, string $ip, int $subnetId, ?string $hostname = null, array $metadata = []): int { - // dhcp_identifier_type 1 = HW_ADDRESS (Ethernet) + $macHex = $this->macToHex($mac); + $ipNumber = $this->ipv4ToNumber($ip); + $identifierExpr = $this->binaryFromHexExpression(':mac_hex'); + $stmt = $this->pdo->prepare( - 'INSERT INTO hosts (dhcp_identifier, dhcp_identifier_type, dhcp4_subnet_id, ipv4_address, hostname) - VALUES (:mac, 1, :subnetId, :ip, :hostname)' + "INSERT INTO hosts (dhcp_identifier, dhcp_identifier_type, dhcp4_subnet_id, ipv4_address, hostname) + VALUES ({$identifierExpr}, 1, :subnetId, :ip, :hostname)" ); $stmt->execute([ - 'mac' => $mac, + 'mac_hex' => $macHex, 'subnetId' => $subnetId, - 'ip' => $ip, + 'ip' => $ipNumber, 'hostname' => $hostname, ]); @@ -241,6 +247,49 @@ final class KeaHostRepository return $hostId; } + public function reserveDisplayEntry(array $host, string $ip, array $metadata = []): int + { + $source = (string)($host['source'] ?? 'reservation'); + $subnetId = (int)($host['subnet_id'] ?? 0); + if ($subnetId <= 0) { + throw new \RuntimeException('Subnet-ID fehlt. Ohne Subnet kann keine KEA-Reservierung angelegt werden.'); + } + + $hostname = (string)($host['hostname'] ?? ''); + if ($source === 'reservation') { + $hostId = (int)($host['host_id'] ?? 0); + if ($hostId <= 0) { + throw new \RuntimeException('Reservierung hat keine gueltige Host-ID.'); + } + + $stmt = $this->pdo->prepare( + 'UPDATE hosts + SET ipv4_address = :ip, hostname = :hostname, dhcp4_subnet_id = :subnet_id + WHERE host_id = :host_id' + ); + $stmt->execute([ + 'ip' => $this->ipv4ToNumber($ip), + 'hostname' => $hostname !== '' ? $hostname : null, + 'subnet_id' => $subnetId, + 'host_id' => $hostId, + ]); + + if ($this->metadata !== null) { + $this->metadata->saveForHost($hostId, (string)($host['dhcp_identifier'] ?? ''), $ip, $metadata); + } + + return $hostId; + } + + return $this->create( + (string)($host['dhcp_identifier'] ?? ''), + $ip, + $subnetId, + $hostname !== '' ? $hostname : null, + $metadata + ); + } + private function withMetadata(array $hosts): array { if ($hosts === [] || $this->metadata === null) { @@ -323,11 +372,50 @@ final class KeaHostRepository $row['dhcp_identifier'] = $this->formatMac((string)($row['dhcp_identifier_hex'] ?? '')); $row['ipv4_address'] = (string)($row['ipv4_address_text'] ?? ''); $row['hostname'] = (string)($row['hostname'] ?? ''); + $contextName = $this->nameFromUserContext((string)($row['user_context'] ?? '')); + $row['display_name'] = $row['hostname'] !== '' ? $row['hostname'] : ($contextName !== '' ? $contextName : 'Unbekannt'); $row['metadata'] = []; return $row; } + private function nameFromUserContext(string $userContext): string + { + if (trim($userContext) === '') { + return ''; + } + + $decoded = json_decode($userContext, true); + if (!is_array($decoded)) { + return ''; + } + + return $this->findContextValue($decoded, ['hostname', 'host-name', 'client-hostname', 'device_name', 'name', 'label']); + } + + private function findContextValue(array $data, array $keys): string + { + foreach ($keys as $key) { + if (isset($data[$key]) && is_scalar($data[$key])) { + $value = trim((string)$data[$key]); + if ($value !== '') { + return $value; + } + } + } + + foreach ($data as $value) { + if (is_array($value)) { + $found = $this->findContextValue($value, $keys); + if ($found !== '') { + return $found; + } + } + } + + return ''; + } + private function formatMac(string $hex): string { $hex = strtolower(preg_replace('/[^a-fA-F0-9]/', '', $hex) ?? ''); @@ -338,6 +426,35 @@ final class KeaHostRepository return implode(':', str_split($hex, 2)); } + private function macToHex(string $mac): string + { + $hex = strtolower(preg_replace('/[^a-fA-F0-9]/', '', $mac) ?? ''); + if ($hex === '') { + throw new \RuntimeException('MAC-Adresse fehlt.'); + } + + return $hex; + } + + private function binaryFromHexExpression(string $placeholder): string + { + return match ($this->driver()) { + 'pgsql' => "decode({$placeholder}, 'hex')", + 'mysql' => "UNHEX({$placeholder})", + default => $placeholder, + }; + } + + private function ipv4ToNumber(string $ip): string + { + $long = ip2long($ip); + if ($long === false) { + throw new \RuntimeException('Ungueltige IPv4-Adresse.'); + } + + return sprintf('%u', $long); + } + private function lastInsertIdSafe(): int { $driver = $this->driver();