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, 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'); $this->ensureGroupSchema($driver); $this->ensureCheckSchema($driver); 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, 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'); $this->ensureGroupSchema($driver); $this->ensureCheckSchema($driver); 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, 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'); $this->ensureGroupSchema($driver); $this->ensureCheckSchema($driver); } 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, group_name, desired_ip, 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, 'group_name' => null, 'desired_ip' => 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, group_name, desired_ip, notes, tags_json, updated_at ) VALUES ( :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, 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, group_name = EXCLUDED.group_name, desired_ip = EXCLUDED.desired_ip, 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, group_name, desired_ip, notes, tags_json, updated_at ) VALUES ( :host_id, :hardware_address, :ip_address, :real_name, :device_name, :owner, :location, :device_type, :group_name, :desired_ip, :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']), '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 { $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 <> ''" ); 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 child.id, child.name, child.description, child.parent_id, parent.name AS parent_name FROM nexus_dhcp_groups child LEFT JOIN nexus_dhcp_groups parent ON parent.id = child.parent_id ORDER BY parent.name ASC, child.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'] ?? ''), 'parent_id' => $row['parent_id'] !== null ? (int)$row['parent_id'] : null, 'parent_name' => (string)($row['parent_name'] ?? ''), '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 = '', string $parentName = ''): void { $name = trim($name); if ($name === '') { throw new \RuntimeException('Gruppenname fehlt.'); } $parentName = trim($parentName); if ($parentName !== '' && strcasecmp($parentName, $name) === 0) { throw new \RuntimeException('Eine Gruppe kann nicht ihre eigene Untergruppe sein.'); } $parentId = $parentName !== '' ? $this->groupIdByName($parentName) : null; if ($parentName !== '' && !$parentId) { throw new \RuntimeException('Uebergeordnete Gruppe wurde nicht gefunden.'); } $driver = (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); if ($driver === 'pgsql' || $driver === 'sqlite') { $sql = "INSERT INTO nexus_dhcp_groups (name, description, parent_id, updated_at) VALUES (:name, :description, :parent_id, CURRENT_TIMESTAMP) ON CONFLICT(name) DO UPDATE SET description = excluded.description, parent_id = excluded.parent_id, updated_at = CURRENT_TIMESTAMP"; } else { $sql = "INSERT INTO nexus_dhcp_groups (name, description, parent_id, updated_at) VALUES (:name, :description, :parent_id, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE description = VALUES(description), parent_id = VALUES(parent_id), updated_at = CURRENT_TIMESTAMP"; } $stmt = $this->pdo->prepare($sql); $stmt->bindValue('name', $name); $stmt->bindValue('description', $this->nullableString($description)); if ($parentId === null) { $stmt->bindValue('parent_id', null, PDO::PARAM_NULL); } else { $stmt->bindValue('parent_id', $parentId, PDO::PARAM_INT); } $stmt->execute(); } 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['desired_ip'] ?? ''), $stmt->fetchAll(PDO::FETCH_ASSOC) ))); } public function saveCheck(string $hostKey, string $checkType, string $status, array $result = [], ?string $nextCheckAt = null): void { $hostKey = trim($hostKey); $checkType = trim($checkType); $status = trim($status); if ($hostKey === '' || $checkType === '' || $status === '') { throw new \RuntimeException('Pruefdaten sind unvollstaendig.'); } $resultJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($resultJson === false) { $resultJson = '{}'; } $driver = (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); if ($driver === 'pgsql') { $stmt = $this->pdo->prepare( "INSERT INTO nexus_dhcp_device_checks ( host_key, check_type, status, result_json, checked_at, next_check_at ) VALUES ( :host_key, :check_type, :status, CAST(:result_json AS jsonb), NOW(), :next_check_at )" ); } else { $stmt = $this->pdo->prepare( "INSERT INTO nexus_dhcp_device_checks ( host_key, check_type, status, result_json, checked_at, next_check_at ) VALUES ( :host_key, :check_type, :status, :result_json, CURRENT_TIMESTAMP, :next_check_at )" ); } $stmt->execute([ 'host_key' => $hostKey, 'check_type' => $checkType, 'status' => $status, 'result_json' => $resultJson, 'next_check_at' => $this->nullableString($nextCheckAt), ]); } public function latestChecks(array $hostKeys): array { $hostKeys = array_values(array_unique(array_filter(array_map( static fn(mixed $value): string => trim((string)$value), $hostKeys )))); if ($hostKeys === [] || !$this->tableExists('nexus_dhcp_device_checks')) { return []; } $params = []; $placeholders = []; foreach ($hostKeys as $idx => $hostKey) { $key = ':host_key_' . $idx; $placeholders[] = $key; $params[$key] = $hostKey; } $stmt = $this->pdo->prepare( 'SELECT host_key, check_type, status, result_json, checked_at FROM nexus_dhcp_device_checks WHERE host_key IN (' . implode(', ', $placeholders) . ') ORDER BY checked_at DESC, id DESC' ); foreach ($params as $key => $value) { $stmt->bindValue($key, $value); } $stmt->execute(); $items = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $hostKey = (string)$row['host_key']; $type = (string)$row['check_type']; if (!isset($items[$hostKey][$type])) { $items[$hostKey][$type] = $row; } } return $items; } 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, parent_id BIGINT REFERENCES nexus_dhcp_groups(id) ON DELETE SET NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )" ); $this->ensureColumn('nexus_dhcp_groups', 'parent_id', 'BIGINT'); $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, parent_id INTEGER, updated_at TEXT NOT NULL DEFAULT (datetime('now')) )" ); $this->ensureColumn('nexus_dhcp_groups', 'parent_id', 'INTEGER'); $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, parent_id BIGINT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )" ); $this->ensureColumn('nexus_dhcp_groups', 'parent_id', 'BIGINT NULL'); $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 ensureCheckSchema(string $driver): void { if ($driver === 'pgsql') { $this->pdo->exec( "CREATE TABLE IF NOT EXISTS nexus_dhcp_device_checks ( id BIGSERIAL PRIMARY KEY, host_key TEXT NOT NULL, check_type TEXT NOT NULL, status TEXT NOT NULL, result_json JSONB, checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), next_check_at TIMESTAMPTZ )" ); $this->pdo->exec( 'CREATE INDEX IF NOT EXISTS idx_nexus_dhcp_device_checks_host_type ON nexus_dhcp_device_checks (host_key, check_type, checked_at)' ); return; } if ($driver === 'sqlite') { $this->pdo->exec( "CREATE TABLE IF NOT EXISTS nexus_dhcp_device_checks ( id INTEGER PRIMARY KEY AUTOINCREMENT, host_key TEXT NOT NULL, check_type TEXT NOT NULL, status TEXT NOT NULL, result_json TEXT, checked_at TEXT NOT NULL DEFAULT (datetime('now')), next_check_at TEXT )" ); $this->pdo->exec( 'CREATE INDEX IF NOT EXISTS idx_nexus_dhcp_device_checks_host_type ON nexus_dhcp_device_checks (host_key, check_type, checked_at)' ); return; } $this->pdo->exec( "CREATE TABLE IF NOT EXISTS nexus_dhcp_device_checks ( id BIGINT AUTO_INCREMENT PRIMARY KEY, host_key VARCHAR(190) NOT NULL, check_type VARCHAR(80) NOT NULL, status VARCHAR(40) NOT NULL, result_json JSON, checked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, next_check_at DATETIME NULL, INDEX idx_nexus_dhcp_device_checks_host_type (host_key, check_type, checked_at) )" ); } 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)) { 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); return $value !== '' ? $value : null; } }