From f57d57b46d5665736c282d157193e3f32a5041f1 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Fri, 10 May 2024 23:07:21 +0200 Subject: [PATCH] Added IPAM IpNetwork Module --- application/IpNetwork/IpNetwork.php | 9 + application/IpNetwork/IpNetworkController.php | 184 +++++++++++++++ application/IpNetwork/IpNetworkModel.php | 212 ++++++++++++++++++ .../20240510225400_add_ip_network.php | 33 +++ 4 files changed, 438 insertions(+) create mode 100644 application/IpNetwork/IpNetwork.php create mode 100644 application/IpNetwork/IpNetworkController.php create mode 100644 application/IpNetwork/IpNetworkModel.php create mode 100644 db/migrations/20240510225400_add_ip_network.php diff --git a/application/IpNetwork/IpNetwork.php b/application/IpNetwork/IpNetwork.php new file mode 100644 index 000000000..a801c807c --- /dev/null +++ b/application/IpNetwork/IpNetwork.php @@ -0,0 +1,9 @@ +loadMe(); + $this->layout()->set("me", $me); + $this->me = $me; + + if (!$this->me->isAdmin()) { + $this->redirect("dashboard"); + } + } + + protected function indexAction(): void { + $JSGlobals = ["BASE_URL" => self::getUrl("IpNetwork"), + "DASHBOARD_URL" => self::getUrl("Dashboard"), + "MFAPPNAME" => MFAPPNAME_SLUG, + "PAGE_TITLE" => "IPAM", + "PATH" => [ + ["text" => MFAPPNAME_SLUG, "href" => self::getUrl("Dashboard")], + ["text" => "IPAM", "href" => self::getUrl("IpNetwork")] + ], + "IPNETWORK_API_URL" => self::getUrl("IpNetwork/api"), + ]; + + $this->layout()->set("vueViewName", "IpNetwork"); + $this->layout()->set("JSGlobals", $JSGlobals); + $this->layout()->setTemplate("VueViews/Vue"); + } + + protected function apiAction() { + $do = $this->request->do; + + if (!$this->me->isAdmin()) { + $this->redirect("dashboard"); + } + + switch ($do) { + case "get": + $return = $this->get(); + break; + case "getById": + $return = $this->getById(); + break; + case "create": + $return = $this->create(); + break; + case "update": + $return = $this->update(); + break; + case "delete": + $return = $this->delete(); + break; + default: + $return = false; + break; + } + + if (!$return) { + $return = [ + "status" => "error", + "message" => "Invalid request." + ]; + } + + header('Content-Type: application/json'); + die(json_encode($return)); + } + + private function aggregateChildren($network, &$childrenCount) { + $children = IpNetworkModel::getChildren($network->id); + + foreach ($children as $child) { + $childrenCount++; + if ($child->cidr !== 32) { + $this->aggregateChildren($child, $childrenCount); + } + } + } + + private function get(): array { + $json = json_decode(file_get_contents('php://input'), true); + + $filters = $json['filters'] ?? []; + $order = $json['order'] ?? []; + $page = $json['pagination']['page'] ?? 1; + $perPage = $json['pagination']['per_page'] ?? 10; + + $orderChildren = false; + + if ($order['key'] === 'network_address_str') { + $order['key'] = 'network_address'; + } else if ($order['key'] === 'children') { + $orderChildren = $order['order']; + $order = null; + } + + $networks = IpNetworkModel::getIpNetworks($filters, null, $perPage * $page - $perPage, $order); + $total_rows = IpNetworkModel::countIpNetworks([ + "parent_network_id" => $filters['parent_network_id'] ?? '' + ]); + + $processedNetworks = []; + + foreach ($networks as $network) { + $childrenCount = 0; + $this->aggregateChildren($network, $childrenCount); + + $from = $filters['children']['from'] ?? null; + $to = $filters['children']['to'] ?? null; + + if (($from !== null && $childrenCount < $from) || ($to !== null && $childrenCount > $to)) { + continue; + } + + $network->children = $childrenCount; + $processedNetworks[] = $network; + } + + if ($orderChildren) { + usort($processedNetworks, function ($a, $b) use ($orderChildren) { + if ($orderChildren === 'asc') { + return $a->children <=> $b->children; + } else { + return $b->children <=> $a->children; + } + }); + } + + return [ + "rows" => array_slice($processedNetworks, $perPage * $page - $perPage, $perPage), + "pagination" => [ + "page" => $page, + "total_pages" => ceil(count($processedNetworks) / $perPage), + "filtered_available" => count($processedNetworks), + "per_page" => $perPage, + "total_rows" => intval($total_rows) + ] + ]; + + } + + private function getById(): array { + $json = json_decode(file_get_contents('php://input'), true); + + $network = IpNetworkModel::getById($json['id']); + + if ($network === null) { + return [ + "status" => "error", + "message" => "Network not found." + ]; + } + + return [ + "status" => "success", + "network" => $network + ]; + } + + + private function create(): array { + $json = json_decode(file_get_contents('php://input'), true); + + try { + IpNetworkModel::createIpNetwork($json); + return [ + "status" => "success", + "message" => "IP Network created." + ]; + } catch (Exception $e) { + return [ + "status" => "error", + "message" => $e->getMessage() + ]; + } + } + + +} \ No newline at end of file diff --git a/application/IpNetwork/IpNetworkModel.php b/application/IpNetwork/IpNetworkModel.php new file mode 100644 index 000000000..ee19afbfb --- /dev/null +++ b/application/IpNetwork/IpNetworkModel.php @@ -0,0 +1,212 @@ + $value) { + if (property_exists(get_called_class(), $field)) { + $this->$field = $value; + } + } + } + + public static function getIpNetworks($filters, $limit = null, $offset = 0, $order = null): array { + $db = FronkDB::singleton(); + + $sql = "SELECT *, CONCAT(INET_NTOA(network_address), '/', cidr) AS network_address_str FROM `IpNetwork` WHERE 1 "; + $sql .= isset($filters['network_address_str']) ? " AND CONCAT(INET_NTOA(network_address), '/', cidr) LIKE '%" . $filters['network_address_str'] . "%'" : ""; + $sql .= self::getSqlFilter($filters); + $sql .= $order === null || $order['key'] === null ? " ORDER BY `network_address` ASC" : " ORDER BY `" . $order['key'] . "` " . $order['order']; + $sql .= $limit === null ? "" : " LIMIT " . $limit . " OFFSET " . $offset; + + $result = $db->query($sql); + $rows = []; + while ($row = $result->fetch_assoc()) { + $rows[] = new IpNetworkModel($row); + } + + return $rows; + } + + public static function getSqlFilter($filters): string { + $sql = isset($filters['name']) ? Helper::generateFilterCondition($filters['name'], 'name') : ""; + $sql .= isset($filters['description']) ? Helper::generateFilterCondition($filters['description'], 'description') : ""; + $sql .= isset($filters['location']) ? Helper::generateFilterCondition($filters['location'], 'location') : ""; + $sql .= isset($filters['status']) ? Helper::generateFilterCondition($filters['status'], 'status') : ""; + $sql .= empty($filters['parent_network_id']) ? " AND `parent_network_id` IS NULL" : " AND `parent_network_id` = " . $filters['parent_network_id']; + + return $sql; + } + + public static function countIpNetworks($filters) { + $db = FronkDB::singleton(); + $sql = "SELECT COUNT(*) as `total_rows` FROM `IpNetwork` WHERE 1 " . self::getSqlFilter($filters); + $result = $db->query($sql); + return $result->fetch_assoc()['total_rows']; + } + + public static function countChildren($id) { + $db = FronkDB::singleton(); + $sql = "SELECT COUNT(*) as `total_rows` FROM `IpNetwork` WHERE `parent_network_id` = $id"; + $result = $db->query($sql); + return $result->fetch_assoc()['total_rows']; + } + + public static function getChildren($id): array { + $db = FronkDB::singleton(); + $sql = "SELECT * FROM `IpNetwork` WHERE `parent_network_id` = $id"; + $result = $db->query($sql); + $rows = []; + while ($row = $result->fetch_assoc()) { + $rows[] = new IpNetworkModel($row); + } + + return $rows; + } + + /** + * @throws Exception + */ + public static function createIpNetwork($data): void { + $db = FronkDB::singleton(); + + $network_address = $data['network_address']; + $cidr = $data['cidr']; + $parent_network_id = $data['parent_network_id'] ?? 'NULL'; + $status = $data['status']; + $name = $data['name']; + $description = $data['description']; + $location = $data['location']; + + // Convert network address to integer + $network_address_int = ip2long($network_address); + + // Define query to check for overlapping networks + $check_sql = " + SELECT `id`, `network_address`, `cidr` + FROM `IpNetwork` + WHERE ( + (INET_ATON('$network_address') & ~((1 << (32 - `cidr`)) - 1)) = `network_address` + OR + (`network_address` & ~((1 << (32 - $cidr)) - 1)) = INET_ATON('$network_address') + )"; + +// die($check_sql); + + $result = $db->query($check_sql); + + $parentFound = false; + $parentFoundId = null; + + while ($row = $result->fetch_assoc()) { + $existing_network_address_int = $row['network_address']; + $existing_cidr = $row['cidr']; + + // Check if the new network is within an existing network + if ($network_address_int >= $existing_network_address_int && + $network_address_int < ($existing_network_address_int + pow(2, (32 - $existing_cidr)))) { + + if ($cidr <= $existing_cidr) { + // The new network is larger or equal, which is invalid + if ($cidr == 32) { + throw new Exception("Address $network_address/32 already exists"); + } else { + throw new Exception("Network $network_address/$cidr conflicts with existing network " . long2ip($existing_network_address_int) . "/$existing_cidr"); + } + } + + // Check if the new network is a correct subnetwork + if ($parent_network_id != 'NULL') { + $result->data_seek(0); + while ($row = $result->fetch_assoc()) { + if ($row['id'] == $parent_network_id) { + $parentFoundId = $row['id']; + $parentFound = true; + break; + } + } + if (!$parentFound) { + throw new Exception("Parent network ID $parent_network_id does not match the actual parent network ID {$row['id']}"); + } + +// New check for conflicts with child networks + $check_child_sql = " + SELECT `id`, `network_address`, `cidr` + FROM `IpNetwork` + WHERE `parent_network_id` = $parent_network_id + AND ( + INET_ATON('$network_address') BETWEEN `network_address` + AND (`network_address` + POW(2, (32 - `cidr`)) - 1) + OR + `network_address` BETWEEN INET_ATON('$network_address') + AND (INET_ATON('$network_address') + POW(2, (32 - $cidr)) - 1) + )"; +; + + $child_result = $db->query($check_child_sql); + + while ($child_row = $child_result->fetch_assoc()) { + $existing_child_network_address_int = $child_row['network_address']; + $existing_child_cidr = $child_row['cidr']; + + // Check if the new network overlaps any existing child networks + if ($network_address_int < $existing_child_network_address_int && + ($network_address_int + pow(2, (32 - $cidr))) > $existing_child_network_address_int) { + throw new Exception("Network $network_address/$cidr conflicts with child network " . long2ip($existing_child_network_address_int) . "/$existing_child_cidr"); + } + } + + } else { + // If no parent ID provided and the new network is within an existing network, throw an error + throw new Exception("Network $network_address/$cidr must be a child of " . long2ip($existing_network_address_int) . "/$existing_cidr."); + } + + // For CIDR 32, check if it already exists but also check if $parent_network_id is same as $parentFoundId but if $parentFoundId is null, throw an error + if ($cidr == 32 && $parent_network_id != 'NULL' && $parent_network_id != $parentFoundId) { + throw new Exception("CIDR 32 address $network_address already exists within " . long2ip($existing_network_address_int) . "/$existing_cidr"); + } + } + + // Check if the new network overlaps any existing networks + if ($network_address_int < $existing_network_address_int && + ($network_address_int + pow(2, (32 - $cidr))) > $existing_network_address_int) { + throw new Exception("Network $network_address/$cidr overlaps with existing network " . long2ip($existing_network_address_int) . "/$existing_cidr"); + } + } + + if (!$parentFound && $parent_network_id === 'NULL' && intval($cidr) >= 32) { + throw new Exception("Root Networks cannot be single IPs"); + } + + // Proceed with insertion if no conflicts are found + $sql = "INSERT INTO `IpNetwork` (`network_address`, `cidr`, `parent_network_id`, `status`, `name`, `description`, `location`, `create`, `edit`) + VALUES (INET_ATON('$network_address'), $cidr, $parent_network_id, '$status', '$name', '$description', '$location', UNIX_TIMESTAMP(), UNIX_TIMESTAMP())"; + $result = $db->query($sql); + + if (!$result) { + throw new Exception("Failed to insert network"); + } + } + + + public static function getById($id) { + $db = FronkDB::singleton(); + $sql = "SELECT *, INET_NTOA(network_address) as network_address_str FROM `IpNetwork` WHERE `id` = $id"; + $result = $db->query($sql); + $row = $result->fetch_assoc(); + return $row ? new IpNetworkModel($row) : null; + } + +} \ No newline at end of file diff --git a/db/migrations/20240510225400_add_ip_network.php b/db/migrations/20240510225400_add_ip_network.php new file mode 100644 index 000000000..e547f18e6 --- /dev/null +++ b/db/migrations/20240510225400_add_ip_network.php @@ -0,0 +1,33 @@ +getEnvironment() == "thetool") { + + // IpNetwork Table + $ipNetworkTable = $this->table("IpNetwork", ["signed" => true]); + $ipNetworkTable->addColumn("network_address", "integer", ["null" => false, "signed" => false]) + ->addColumn("cidr", "integer", ["null" => false]) + ->addColumn("parent_network_id", "integer", ["null" => true, "signed" => true]) + ->addColumn("status", "enum", ["values" => ["active", "inactive", "reserved"], "null" => false]) + ->addColumn("name", "string", ["null" => true, "limit" => 100]) + ->addColumn("description", "text", ["null" => true]) + ->addColumn("location", "string", ["null" => true, "limit" => 255]) + ->addColumn("create", "integer", ["null" => true, "signed" => false]) + ->addColumn("edit", "integer", ["null" => true, "signed" => false]) + ->addForeignKey("parent_network_id", "IpNetwork", "id", ["delete" => "SET_NULL", "update" => "NO_ACTION"]) + ->addIndex(["parent_network_id"]); + + $ipNetworkTable->save(); + } + } + + public function down(): void { + if ($this->getEnvironment() == "thetool") { + $this->table("IpNetwork")->drop()->save(); + } + } +}