diff --git a/zabbix_ipam/Module.php b/zabbix_ipam/Module.php new file mode 100644 index 0000000..954c36d --- /dev/null +++ b/zabbix_ipam/Module.php @@ -0,0 +1,29 @@ +get('menu.main')->findOrAdd(_('Inventory'))->getSubmenu(); + $menu->add((new \CMenuItem(LanguageManager::t('IPAM')))->setSubMenu(new \CMenu([ + (new \CMenuItem(LanguageManager::t('IP Management')))->setAction('ip.manager'), + (new \CMenuItem(LanguageManager::t('IP Details')))->setAction('ip.detail'), + (new \CMenuItem(LanguageManager::t('Task Management')))->setAction('ip.scan') + ]))); + } + } catch (\Throwable $e) { error_log('IPAM module menu: ' . $e->getMessage()); } + } +} diff --git a/zabbix_ipam/README.md b/zabbix_ipam/README.md new file mode 100644 index 0000000..136bc83 --- /dev/null +++ b/zabbix_ipam/README.md @@ -0,0 +1,33 @@ +# Zabbix IPAM + +面向 Zabbix 6.0、7.x 与 8.0 的前端 IP 地址管理模块。它管理 CIDR/IPv4 起止地址段,通过 ICMP 异步扫描存活主机,并把结果与 Zabbix 主机接口自动匹配。 + +## 安装 + +1. 将整个 `zabbix_ipam` 目录复制至模块目录:Zabbix 6.0/7.0 通常为 `/usr/share/zabbix/modules`;7.2+/8.0 请以 `zabbix.conf.php` 的 `$ZBX_MODULES_DIR` 为准。 +2. 让 Web 服务器用户可写 `zabbix_ipam/data`(包括 `scan_tasks` 与 `results`)。例如:`chown -R www-data:www-data zabbix_ipam/data`。 +3. 在 **Administration → General → Modules** 启用 IPAM。入口位于 **Inventory → IP 管理 / IPAM**。 + +## 定时扫描 + +每个 IP 段可设置独立扫描间隔(分钟);启用后由唯一 cron 入口检查到期任务: + +```cron +*/5 * * * * www-data /usr/bin/php /usr/share/zabbix/modules/zabbix_ipam/cli/scan_cron.php >> /var/log/zabbix/ipam-cron.log 2>&1 +``` + +扫描任务使用状态文件避免同一 IP 段重复提交。Web 页面启动扫描时会后台执行同一 CLI;前端每 1.5 秒轮询任务进度。若后台启动在受限 PHP 环境中被禁止,可仅使用以上 cron 调度。 + +## 扫描与限制 + +CIDR 会默认按 64 个地址分片(可通过 AJAX 参数 `shard_size` 指定)。CLI 上有 `pcntl` 时最多同时运行 4 个 fork 子进程,每个分片以独立临时 JSON 结果文件回传;没有 `pcntl` 时安全地串行回退。扫描仅使用 `fping` ICMP 探测,不会连接目标 TCP 端口。后台任务及 cron 需要 PHP CLI;多进程需要 `php-process`/`pcntl`。 + +仅接受 IPv4,单个范围最多 65,536 个地址。请只扫描已获授权的网络;生产中建议将 `max_execution_time`、防火墙速率和 cron 的运行用户纳入运维策略。模块操作限制为 Zabbix 管理员。 + +JSON 存储封装在 `lib/IpStorage.php`,可在后续替换为数据库实现而不改变控制器和扫描器。 + +## 页面使用 + +模块提供三个业务页面:**IP 管理**、**IP 详情**与**任务管理**。IP 管理页上方可按名称/CIDR、IP 段和状态筛选;“添加 IP 段”和“修改”共用同一弹窗,只需填写名称、IPv4 CIDR(或起止地址)、扫描间隔并选择是否启用定时扫描。每行均可启动扫描、查看扫描任务、修改或删除。 + +点击“存活 IP / IP 总数”会打开 IP 使用矩阵。绿色方块表示最近一次扫描中可通过 ICMP 到达,灰色方块表示不可达或尚未扫描;绿色地址已匹配 Zabbix 主机时可直接点击进入主机详情。IP 详情页按 IP、IP 段、存活状态和主机关联状态筛选所有地址,并自动按照 Zabbix 主机接口 IP 建立关联。任务管理页集中显示后台任务状态、扫描进度、存活数及已完成分片数;页面会自动轮询运行中的任务,无需手动刷新。 diff --git a/zabbix_ipam/README_en.md b/zabbix_ipam/README_en.md new file mode 100644 index 0000000..eb78deb --- /dev/null +++ b/zabbix_ipam/README_en.md @@ -0,0 +1,3 @@ +# Zabbix IPAM + +See [README.md](README.md) for installation, cron configuration, asynchronous shard scanning, dependencies and operational safeguards. The module supports Zabbix 6.0, 7.x and 8.0 and appears under Inventory → IPAM. diff --git a/zabbix_ipam/actions/IpAjax.php b/zabbix_ipam/actions/IpAjax.php new file mode 100644 index 0000000..db378d1 --- /dev/null +++ b/zabbix_ipam/actions/IpAjax.php @@ -0,0 +1,153 @@ +disableCsrfValidation(); + } + elseif (method_exists($this, 'disableSIDvalidation')) { + $this->disableSIDvalidation(); + } + if (method_exists($this, 'disableView')) { + $this->disableView(); + } + } + + protected function checkInput(): bool { + return $this->validateInput([ + 'op' => 'in status,start,stop,save_range,delete_range,range_ips', + 'rangeid' => 'string', + 'taskid' => 'string', + 'id' => 'string', + 'name' => 'string', + 'range' => 'string', + 'enabled' => 'in 0,1', + 'scan_interval' => 'int32', + 'shard_size' => 'int32' + ]); + } + + protected function checkPermissions(): bool { + return $this->getUserType() >= USER_TYPE_ZABBIX_ADMIN; + } + + protected function doAction(): void { + $storage = new IpStorage(); + $operation = $this->getInput('op', 'status'); + + try { + switch ($operation) { + case 'save_range': + $value = trim($this->getInput('range', '')); + IpScanner::parseRange($value); + $range = $storage->saveRange([ + 'id' => $this->getInput('id', '') ?: null, + 'name' => trim($this->getInput('name', '')) ?: $value, + 'range' => $value, + 'enabled' => $this->getInput('enabled', '1') === '1', + 'scan_interval' => max(1, $this->getInput('scan_interval', 60)), + 'ports' => '' + ]); + $output = ['ok' => true, 'message' => LanguageManager::t('Range saved.'), 'range' => $range]; + break; + + case 'delete_range': + $range_id = $this->getInput('rangeid', ''); + foreach ($storage->tasks() as $task) { + if ($task['range_id'] === $range_id && in_array($task['status'], ['pending', 'running'], true)) { + throw new \RuntimeException('Stop active tasks before deleting this range.'); + } + } + $storage->deleteRange($range_id); + $output = ['ok' => true, 'message' => LanguageManager::t('Range deleted.')]; + break; + + case 'start': + $manager = new TaskManager($storage); + $task = $manager->start($this->getInput('rangeid', ''), max(1, $this->getInput('shard_size', 64))); + if (!$manager->dispatch($task['id'])) { + throw new \RuntimeException('Failed to start task.'); + } + $output = ['ok' => true, 'task' => $task, 'message' => LanguageManager::t('Task started.')]; + break; + + case 'stop': + (new TaskManager($storage))->stop($this->getInput('taskid', '')); + $output = ['ok' => true, 'message' => LanguageManager::t('Task stopped.')]; + break; + + case 'range_ips': + $output = $this->rangeIps($storage, $this->getInput('rangeid', '')); + break; + + default: + $task = $storage->task($this->getInput('taskid', '')); + if (!$task) { + throw new \InvalidArgumentException('Task not found.'); + } + $output = ['ok' => true, 'task' => $task, 'results' => $storage->results($task['id'])]; + } + } + catch (\Throwable $exception) { + $output = ['ok' => false, 'message' => LanguageManager::t($exception->getMessage())]; + } + + header('Content-Type: application/json; charset=UTF-8'); + echo json_encode($output, JSON_UNESCAPED_UNICODE); + exit; + } + + private function rangeIps(IpStorage $storage, string $range_id): array { + $range = $storage->range($range_id); + if (!$range) { + throw new \InvalidArgumentException('Range not found.'); + } + + $parsed = IpScanner::parseRange($range['range']); + $latest = null; + foreach ($storage->tasks() as $task) { + if ($task['range_id'] === $range_id && in_array($task['status'], ['completed', 'running'], true)) { + $latest = $task; + break; + } + } + + $known = []; + if ($latest) { + foreach ($storage->results($latest['id']) as $result) { + $known[$result['ip']] = $result; + } + } + + $host_map = HostMatcher::byIp(); + $ips = []; + for ($number = $parsed['start']; $number <= $parsed['end']; $number++) { + $ip = long2ip($number); + $hosts = $host_map[$ip] ?? []; + $ips[] = [ + 'ip' => $ip, + 'alive' => (bool) ($known[$ip]['alive'] ?? false), + 'scanned' => isset($known[$ip]), + 'host_name' => $hosts[0]['name'] ?? null, + 'host_url' => $hosts[0]['url'] ?? null + ]; + } + + return ['ok' => true, 'range' => $range, 'task' => $latest, 'ips' => $ips]; + } +} diff --git a/zabbix_ipam/actions/IpDetail.php b/zabbix_ipam/actions/IpDetail.php new file mode 100644 index 0000000..c8182f7 --- /dev/null +++ b/zabbix_ipam/actions/IpDetail.php @@ -0,0 +1,139 @@ +disableCsrfValidation(); + } + elseif (method_exists($this, 'disableSIDvalidation')) { + $this->disableSIDvalidation(); + } + } + + protected function checkInput(): bool { + return $this->validateInput([ + 'search' => 'string', + 'rangeid' => 'string', + 'status' => 'in all,alive,down,unscanned', + 'associated' => 'in all,yes,no', + 'page' => 'int32' + ]); + } + + protected function checkPermissions(): bool { + return $this->getUserType() >= USER_TYPE_ZABBIX_ADMIN; + } + + protected function doAction(): void { + $storage = new IpStorage(); + $ranges = $storage->ranges(); + $range_id = $this->getInput('rangeid', ''); + $status = $this->getInput('status', 'all'); + $associated = $this->getInput('associated', 'all'); + $search = mb_strtolower(trim($this->getInput('search', ''))); + $page = max(1, (int) $this->getInput('page', 1)); + $offset = ($page - 1) * self::PAGE_SIZE; + + $latest = []; + foreach ($storage->tasks() as $task) { + $task_range_id = (string) ($task['range_id'] ?? ''); + if (!isset($latest[$task_range_id]) && in_array($task['status'] ?? '', ['completed', 'running'], true)) { + $latest[$task_range_id] = $task; + } + } + + $host_map = HostMatcher::byIp(); + $rows = []; + $total = 0; + + foreach ($ranges as $range) { + if ($range_id !== '' && $range['id'] !== $range_id) { + continue; + } + + try { + $parsed = IpScanner::parseRange($range['range']); + } + catch (\Throwable $exception) { + continue; + } + + $known = []; + if (isset($latest[$range['id']])) { + foreach ($storage->results($latest[$range['id']]['id']) as $result) { + $known[$result['ip']] = $result; + } + } + + for ($number = $parsed['start']; $number <= $parsed['end']; $number++) { + $ip = long2ip($number); + $state = !isset($known[$ip]) ? 'unscanned' : (!empty($known[$ip]['alive']) ? 'alive' : 'down'); + $hosts = $host_map[$ip] ?? []; + $is_associated = $hosts !== []; + + if ($status !== 'all' && $state !== $status) { + continue; + } + if (($associated === 'yes' && !$is_associated) || ($associated === 'no' && $is_associated)) { + continue; + } + + $host_names = implode(' ', array_column($hosts, 'name')); + $haystack = mb_strtolower($ip.' '.$range['name'].' '.$range['range'].' '.$host_names); + if ($search !== '' && strpos($haystack, $search) === false) { + continue; + } + + if ($total >= $offset && count($rows) < self::PAGE_SIZE) { + $rows[] = [ + 'ip' => $ip, + 'range_id' => $range['id'], + 'range_name' => $range['name'], + 'range' => $range['range'], + 'state' => $state, + 'hosts' => $hosts, + 'associated' => $is_associated + ]; + } + $total++; + } + } + + $pages = max(1, (int) ceil($total / self::PAGE_SIZE)); + $page = min($page, $pages); + + $this->setResponse(new CControllerResponseData([ + 'title' => LanguageManager::t('IP Details'), + 'rows' => $rows, + 'ranges' => $ranges, + 'filters' => [ + 'search' => $search, + 'rangeid' => $range_id, + 'status' => $status, + 'associated' => $associated + ], + 'pagination' => [ + 'page' => $page, + 'pages' => $pages, + 'total' => $total, + 'page_size' => self::PAGE_SIZE + ] + ])); + } +} diff --git a/zabbix_ipam/actions/IpManager.php b/zabbix_ipam/actions/IpManager.php new file mode 100644 index 0000000..7d3386a --- /dev/null +++ b/zabbix_ipam/actions/IpManager.php @@ -0,0 +1,11 @@ +disableCsrfValidation();elseif(method_exists($this,'disableSIDvalidation'))$this->disableSIDvalidation();} + protected function checkInput():bool{return $this->validateInput(['rangeid'=>'string','status'=>'in all,enabled,disabled,pending,running,completed,failed','search'=>'string']);} + protected function checkPermissions():bool{return $this->getUserType()>=USER_TYPE_ZABBIX_ADMIN;} + protected function doAction():void{$s=new IpStorage();$tasks=$s->tasks();$latest=[];foreach($tasks as $task)if(!isset($latest[$task['range_id']]))$latest[$task['range_id']]=$task;$rows=[];foreach($s->ranges() as $range){try{$parsed=IpScanner::parseRange($range['range']);$total=$parsed['count'];}catch(\Throwable $e){$total=0;}$task=$latest[$range['id']]??null;$rows[]=['range'=>$range,'task'=>$task,'total'=>$task['total']??$total,'alive'=>$task['alive']??0,'scanned'=>$task['scanned']??0,'task_status'=>$task['status']??'never'];}$selected=$this->getInput('rangeid','');$status=$this->getInput('status','all');$search=mb_strtolower(trim($this->getInput('search','')));$rows=array_values(array_filter($rows,function($row)use($selected,$status,$search){$r=$row['range'];$matchStatus=$status==='all'||($status==='enabled'&&!empty($r['enabled']))||($status==='disabled'&&empty($r['enabled']))||$row['task_status']===$status;return(!$selected||$r['id']===$selected)&&$matchStatus&&(!$search||strpos(mb_strtolower(($r['name']??'').' '.($r['range']??'')),$search)!==false);}));$this->setResponse(new CControllerResponseData(['title'=>LanguageManager::t('IPAM'),'rows'=>$rows,'all_ranges'=>$s->ranges(),'filters'=>['rangeid'=>$selected,'status'=>$status,'search'=>$search]]));} +} diff --git a/zabbix_ipam/actions/IpScan.php b/zabbix_ipam/actions/IpScan.php new file mode 100644 index 0000000..4b758c6 --- /dev/null +++ b/zabbix_ipam/actions/IpScan.php @@ -0,0 +1,11 @@ +disableCsrfValidation();elseif(method_exists($this,'disableSIDvalidation'))$this->disableSIDvalidation();} + protected function checkInput():bool{return $this->validateInput(['taskid'=>'string','status'=>'in all,pending,running,completed,failed,stopped','search'=>'string']);} + protected function checkPermissions():bool{return $this->getUserType()>=USER_TYPE_ZABBIX_ADMIN;} + protected function doAction():void{$s=new IpStorage();$id=$this->getInput('taskid','');$status=$this->getInput('status','all');$search=mb_strtolower(trim($this->getInput('search','')));$names=[];foreach($s->ranges() as $r)$names[$r['id']]=$r['name'];$tasks=[];foreach($s->tasks() as $task){$task['range_name']=$names[$task['range_id']]??$task['range_id'];if($id&&$task['id']!==$id)continue;if($status!=='all'&&$task['status']!==$status)continue;if($search&&strpos(mb_strtolower($task['id'].' '.$task['range_name']),$search)===false)continue;$tasks[]=$task;}$this->setResponse(new CControllerResponseData(['title'=>LanguageManager::t('Task Management'),'tasks'=>$tasks,'selected'=>$id,'filters'=>['status'=>$status,'search'=>$search]]));} +} diff --git a/zabbix_ipam/assets/css/ipam-responsive.css b/zabbix_ipam/assets/css/ipam-responsive.css new file mode 100644 index 0000000..66284ae --- /dev/null +++ b/zabbix_ipam/assets/css/ipam-responsive.css @@ -0,0 +1,160 @@ +.ipam { + width: calc(100vw - 215px); + max-width: 1440px; + margin-left: 0; + margin-right: 0; +} + +.ipam-head, +.ipam-toolbar, +.ipam-table-wrap { + width: 100%; + min-width: 0; +} + +.ipam-head-actions { + flex: 0 0 auto; + flex-wrap: wrap; + justify-content: flex-end; +} + +.ipam-toolbar label { + flex: 1 1 180px; + min-width: 150px; +} + +.ipam-toolbar label:first-of-type { + flex: 2 1 260px; + min-width: 220px; +} + +.ipam-table { + table-layout: fixed; +} + +.ipam-range-table th:nth-child(1) { + width: 32%; +} + +.ipam-range-table th:nth-child(2) { + width: 20%; +} + +.ipam-task-table th:nth-child(1) { width: 28%; } +.ipam-task-table th:nth-child(2) { width: 13%; } +.ipam-task-table th:nth-child(3) { width: 20%; } +.ipam-task-table th:nth-child(4) { width: 8%; } +.ipam-task-table th:nth-child(5) { width: 9%; } +.ipam-task-table th:nth-child(6) { width: 14%; } +.ipam-task-table th:nth-child(7) { width: 8%; } + +.ipam-detail-table th:nth-child(1) { width: 14%; } +.ipam-detail-table th:nth-child(2) { width: 18%; } +.ipam-detail-table th:nth-child(3) { width: 18%; } +.ipam-detail-table th:nth-child(4) { width: 13%; } +.ipam-detail-table th:nth-child(5) { width: 15%; } +.ipam-detail-table th:nth-child(6) { width: 22%; } + +.ip-address { + font-size: 14px; +} + +.ip-status, +.association-state { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 12px; + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.ip-status-alive, +.association-state.is-associated { + background: #e7f6ee; + color: #20784b; +} + +.ip-status-down { + background: #fdeaea; + color: #a92e2e; +} + +.ip-status-unscanned, +.association-state.is-unassociated { + background: #eef1f3; + color: #687784; +} + +.ipam-host-link { + color: var(--ipam-blue); + font-weight: 600; + text-decoration: none; +} + +.ipam-host-link:hover, +.ip-cell.is-linked:hover { + text-decoration: underline; +} + +.ipam-muted { + color: var(--ipam-muted); +} + +.ip-cell.is-linked { + cursor: pointer; + text-decoration: none; + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .45); +} + +.ipam-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 2px; + color: var(--ipam-muted); +} + +.ipam-pagination nav { + display: flex; + align-items: center; + gap: 10px; +} + +.ipam-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 4px; +} + +.ipam-actions > * { + margin-left: 0; +} + +.ipam a.btn-primary, +.ipam a.btn-primary:hover { + color: #fff !important; +} + +@media (max-width: 760px) { + .ipam { + width: calc(100vw - 16px); + } + + .ipam-table { + min-width: 680px; + } + + .ipam-detail-table, + .ipam-task-table { + min-width: 920px; + } + + .ipam-pagination { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/zabbix_ipam/assets/css/ipam.css b/zabbix_ipam/assets/css/ipam.css new file mode 100644 index 0000000..fc4de38 --- /dev/null +++ b/zabbix_ipam/assets/css/ipam.css @@ -0,0 +1 @@ +.ipam{--ipam-blue:#206bc4;--ipam-blue-soft:#eaf2fb;--ipam-green:#36a269;--ipam-red:#d64545;--ipam-border:#dce3e9;--ipam-muted:#687784;max-width:1440px;margin:20px auto;padding:0 20px;color:var(--text-color,#2b2f33)}.ipam *{box-sizing:border-box}.ipam-head{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-bottom:18px}.ipam-head h1{font-size:24px;margin:0 0 5px}.ipam-head p{margin:0;color:var(--ipam-muted)}.ipam-head-actions{display:flex;gap:10px;align-items:center}.btn-primary,.btn-alt,.btn-link,.btn-danger-link{display:inline-flex;align-items:center;justify-content:center;min-height:34px;padding:7px 13px;border-radius:5px;border:1px solid transparent;cursor:pointer;text-decoration:none;font-weight:600}.btn-primary{background:var(--ipam-blue);color:#fff}.btn-primary:hover{background:#175ca8;color:#fff}.btn-alt{background:#fff;border-color:#aebbc5;color:#253746}.btn-link{background:transparent;color:var(--ipam-blue)}.btn-danger-link{background:transparent;color:var(--ipam-red)}button:disabled{opacity:.55;cursor:not-allowed}.ipam-help{display:flex;gap:12px;align-items:flex-start;margin:0 0 16px;padding:13px 15px;border:1px solid #cfe0f2;border-left:4px solid var(--ipam-blue);border-radius:6px;background:var(--ipam-blue-soft);line-height:1.5}.ipam-help strong{white-space:nowrap}.ipam-toolbar{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap;margin:0 0 16px;padding:14px;border:1px solid var(--ipam-border);border-radius:7px;background:#fff}.ipam-toolbar label{display:flex;flex-direction:column;gap:5px;min-width:190px}.ipam-toolbar label:first-of-type{flex:1;min-width:240px}.ipam-toolbar label span{font-size:12px;font-weight:600;color:var(--ipam-muted)}.ipam-toolbar input,.ipam-toolbar select,.ipam-dialog input{height:36px;padding:7px 10px;border:1px solid #b8c4cc;border-radius:5px;background:#fff}.ipam-table-wrap{overflow:auto;border:1px solid var(--ipam-border);border-radius:7px;background:#fff}.ipam-table{width:100%;border-collapse:collapse}.ipam-table th{padding:11px 14px;background:#f5f7f9;border-bottom:1px solid var(--ipam-border);text-align:left;font-size:12px;color:#52616d;text-transform:uppercase;letter-spacing:.02em}.ipam-table td{padding:14px;border-bottom:1px solid #e8edf1;vertical-align:middle}.ipam-table tbody tr:last-child td{border-bottom:0}.ipam-table tbody tr:hover{background:#fafcfd}.ipam-range-main{font-size:15px;font-weight:700}.ipam-range-sub{margin-top:4px;color:var(--ipam-muted);font-size:12px}.ipam-state{display:inline-block;margin-left:6px;padding:2px 6px;border-radius:10px}.ipam-state.is-on{background:#e7f6ee;color:#20784b}.ipam-state.is-off{background:#eef1f3;color:#697780}.ipam-count{border:0;background:transparent;color:#24333e;cursor:pointer;padding:3px 0;font-size:16px}.ipam-count strong{font-size:21px;color:var(--ipam-green)}.ipam-count span{padding:0 5px;color:#94a0a8}.ipam-count:hover{text-decoration:underline}.ipam-task-state{display:inline-block;margin-top:5px;padding:2px 7px;border-radius:10px;background:#eef1f3;color:#687784;font-size:11px}.status-running,.status-pending{background:#fff2cf!important;color:#8b6500!important}.status-completed{background:#e7f6ee!important;color:#20784b!important}.status-failed,.status-stopped{background:#fdeaea!important;color:#a92e2e!important}.ipam-actions{white-space:nowrap;text-align:right}.ipam-actions>*{margin-left:3px}.ipam-empty{padding:32px!important;text-align:center!important;color:var(--ipam-muted)}.ipam-modal[hidden],.ipam-toast[hidden]{display:none}.ipam-modal{position:fixed;z-index:10010;inset:0;display:flex;align-items:center;justify-content:center;padding:24px}.ipam-modal-backdrop{position:absolute;inset:0;background:rgba(23,32,38,.52)}.ipam-dialog{position:relative;width:min(520px,100%);max-height:90vh;overflow:auto;border-radius:8px;background:#fff;box-shadow:0 18px 55px rgba(0,0,0,.25)}.ipam-dialog-wide{width:min(1240px,100%)}.ipam-dialog>header{display:flex;align-items:flex-start;justify-content:space-between;padding:17px 20px;border-bottom:1px solid var(--ipam-border)}.ipam-dialog h2{margin:0;font-size:18px}.ipam-dialog header p{margin:5px 0 0;color:var(--ipam-muted)}.ipam-close{border:0;background:transparent;font-size:25px;line-height:1;cursor:pointer;color:#65747e}.ipam-dialog form{display:grid;gap:15px;padding:20px}.ipam-dialog form>label{display:grid;gap:6px;font-weight:600}.ipam-dialog footer{display:flex;justify-content:flex-end;gap:8px;padding-top:5px}.ipam-switch-row{display:flex!important;align-items:center;justify-content:space-between;padding:12px;border-radius:6px;background:#f5f7f9}.ipam-switch-row span{display:grid;gap:3px}.ipam-switch-row small{font-weight:400;color:var(--ipam-muted)}.ipam-switch-row input{width:20px;height:20px}.ipam-legend{display:flex;gap:18px;padding:12px 20px;color:var(--ipam-muted)}.ipam-legend span{display:flex;align-items:center;gap:6px}.ipam-legend i{width:12px;height:12px;border-radius:2px}.ipam-legend .alive{background:#55c43a}.ipam-legend .dead{background:#aeb5bb}.ipam-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(58px,1fr));gap:6px;max-height:64vh;overflow:auto;padding:6px 20px 22px}.ip-cell{display:flex;min-height:50px;align-items:center;justify-content:center;flex-direction:column;border-radius:3px;color:#fff}.ip-cell.is-alive{background:#55c43a}.ip-cell.is-dead{background:#aeb5bb}.ip-cell strong{font-size:15px}.ip-cell span{font-size:9px;opacity:.82}.ipam-grid-loading{grid-column:1/-1;padding:45px;text-align:center;color:var(--ipam-muted)}.ipam-toast{position:fixed;z-index:10020;right:24px;bottom:24px;max-width:420px;padding:12px 16px;border-radius:6px;color:#fff;box-shadow:0 7px 24px rgba(0,0,0,.2)}.ipam-toast.is-ok{background:#287d50}.ipam-toast.is-error{background:#b43737}.task-summary{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:12px;margin-bottom:16px}.task-summary>div{display:flex;flex-direction:column;padding:16px;border:1px solid var(--ipam-border);border-radius:7px;background:#fff}.task-summary strong{font-size:24px}.task-summary span{color:var(--ipam-muted)}.task-badge{display:inline-block;padding:4px 9px;border-radius:12px;background:#eef1f3;font-size:12px;font-weight:700}.task-progress{width:180px;height:7px;margin-bottom:6px;border-radius:5px;background:#e5eaee;overflow:hidden}.task-progress i{display:block;height:100%;background:var(--ipam-blue);transition:width .3s}.ipam-modal-open{overflow:hidden}@media(max-width:760px){.ipam{padding:0 8px}.ipam-head{align-items:flex-start;flex-direction:column}.ipam-head-actions{width:100%}.ipam-help{flex-direction:column}.task-summary{grid-template-columns:repeat(2,1fr)}.ipam-actions{white-space:normal}.ipam-grid{grid-template-columns:repeat(auto-fill,minmax(48px,1fr));padding:6px 12px 16px}.ip-cell{min-height:44px}.ip-cell span{display:none}} diff --git a/zabbix_ipam/assets/js/ipam.js.php b/zabbix_ipam/assets/js/ipam.js.php new file mode 100644 index 0000000..f9e0273 --- /dev/null +++ b/zabbix_ipam/assets/js/ipam.js.php @@ -0,0 +1,237 @@ + +(function () { + 'use strict'; + + const root = document.querySelector('.ipam'); + if (!root) { + return; + } + + const endpoint = 'zabbix.php?action=ip.ajax'; + const call = data => fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: new URLSearchParams(data) + }).then(async response => { + const body = await response.text(); + try { + return JSON.parse(body); + } + catch (error) { + throw new Error(root.dataset.requestFailed || 'Request failed.'); + } + }); + + const toast = (message, error = false) => { + const element = document.getElementById('ipam-toast'); + if (!element) { + return; + } + element.textContent = message; + element.className = 'ipam-toast '+(error ? 'is-error' : 'is-ok'); + element.hidden = false; + setTimeout(() => element.hidden = true, 3200); + }; + + const open = id => { + const element = document.getElementById(id); + if (element) { + element.hidden = false; + document.body.classList.add('ipam-modal-open'); + } + }; + + const close = element => { + element.hidden = true; + document.body.classList.remove('ipam-modal-open'); + }; + + document.addEventListener('click', async event => { + const close_button = event.target.closest('[data-close]'); + if (close_button) { + close(close_button.closest('.ipam-modal')); + return; + } + + if (event.target.closest('.js-add')) { + const form = document.getElementById('range-form'); + form.reset(); + form.elements.id.value = ''; + form.elements.scan_interval.value = '60'; + form.elements.enabled.checked = true; + document.getElementById('range-modal-title').textContent = event.target.closest('.js-add').textContent; + open('range-modal'); + return; + } + + const edit = event.target.closest('.js-edit'); + if (edit) { + const data = JSON.parse(edit.dataset.range); + const form = document.getElementById('range-form'); + form.elements.id.value = data.id; + form.elements.name.value = data.name; + form.elements.range.value = data.range; + form.elements.scan_interval.value = data.scan_interval; + form.elements.enabled.checked = !!data.enabled; + document.getElementById('range-modal-title').textContent = edit.textContent; + open('range-modal'); + return; + } + + const save = event.target.closest('.js-save-range'); + if (save) { + event.preventDefault(); + const form = document.getElementById('range-form'); + if (!form.reportValidity()) { + return; + } + save.disabled = true; + const data = Object.fromEntries(new FormData(form)); + data.op = 'save_range'; + data.enabled = form.elements.enabled.checked ? '1' : '0'; + try { + const response = await call(data); + if (!response.ok) { + throw new Error(response.message); + } + location.href = 'zabbix.php?action=ip.manager'; + } + catch (error) { + toast(error.message, true); + save.disabled = false; + } + return; + } + + const scan = event.target.closest('.js-scan'); + if (scan) { + scan.disabled = true; + scan.textContent = root.dataset.loading || 'Loading...'; + try { + const response = await call({op: 'start', rangeid: scan.dataset.rangeId}); + if (!response.ok) { + throw new Error(response.message); + } + location.href = 'zabbix.php?action=ip.scan&taskid='+encodeURIComponent(response.task.id); + } + catch (error) { + toast(error.message, true); + scan.disabled = false; + } + return; + } + + const remove = event.target.closest('.js-delete'); + if (remove) { + if (!confirm(root.dataset.confirmDelete || 'Delete?')) { + return; + } + try { + const response = await call({op: 'delete_range', rangeid: remove.dataset.rangeId}); + if (!response.ok) { + throw new Error(response.message); + } + location.reload(); + } + catch (error) { + toast(error.message, true); + } + return; + } + + const count = event.target.closest('.ipam-count'); + if (count) { + const grid = document.getElementById('ip-grid'); + grid.innerHTML = '
'+(root.dataset.loading || 'Loading...')+'
'; + open('matrix-modal'); + try { + const response = await call({op: 'range_ips', rangeid: count.dataset.rangeId}); + if (!response.ok) { + throw new Error(response.message); + } + document.getElementById('matrix-subtitle').textContent = response.range.name+' · '+response.range.range; + const fragment = document.createDocumentFragment(); + response.ips.forEach(item => { + const linked = item.alive && item.host_url; + const cell = document.createElement(linked ? 'a' : 'div'); + cell.className = 'ip-cell '+(item.alive ? 'is-alive' : 'is-dead')+(linked ? ' is-linked' : ''); + cell.title = item.host_name ? item.ip+' · '+item.host_name : item.ip; + if (linked) { + cell.href = item.host_url; + } + const parts = item.ip.split('.'); + cell.innerHTML = ''+parts[parts.length - 1]+''+item.ip+''; + fragment.appendChild(cell); + }); + grid.innerHTML = ''; + grid.appendChild(fragment); + } + catch (error) { + grid.innerHTML = '
'+error.message+'
'; + } + return; + } + + const stop = event.target.closest('.js-stop'); + if (stop) { + stop.disabled = true; + try { + const response = await call({op: 'stop', taskid: stop.dataset.taskId}); + if (!response.ok) { + throw new Error(response.message); + } + location.reload(); + } + catch (error) { + toast(error.message, true); + stop.disabled = false; + } + } + }); + + const range_form = document.getElementById('range-form'); + if (range_form) { + range_form.addEventListener('submit', event => { + event.preventDefault(); + range_form.querySelector('.js-save-range').click(); + }); + } + + const active_rows = [...document.querySelectorAll('[data-task-row]')] + .filter(row => row.querySelector('.status-running,.status-pending')); + + if (active_rows.length) { + const poll = async () => { + let active = false; + for (const row of active_rows) { + try { + const response = await call({op: 'status', taskid: row.dataset.taskRow}); + if (!response.ok) { + continue; + } + const task = response.task; + const percent = task.total ? Math.round(task.scanned / task.total * 100) : 0; + const badge = row.querySelector('.task-badge'); + badge.className = 'task-badge status-'+task.status; + badge.textContent = root.getAttribute('data-label-'+task.status) || task.status; + row.querySelector('.task-progress i').style.width = percent+'%'; + row.querySelector('[data-progress-text]').textContent = task.scanned+' / '+task.total+' · '+percent+'%'; + row.querySelector('[data-alive]').textContent = task.alive; + row.querySelector('[data-shards]').textContent = task.completed_shards+' / '+task.shards.length; + if (['pending', 'running'].includes(task.status)) { + active = true; + } + } + catch (error) { + } + } + if (active) { + setTimeout(poll, 1800); + } + }; + poll(); + } +})(); diff --git a/zabbix_ipam/cli/scan_cron.php b/zabbix_ipam/cli/scan_cron.php new file mode 100644 index 0000000..00e4d97 --- /dev/null +++ b/zabbix_ipam/cli/scan_cron.php @@ -0,0 +1,8 @@ +#!/usr/bin/env php +root().'/scan_tasks/'.$taskId.'.worker.lock','c+');if(!$lock||!flock($lock,LOCK_EX|LOCK_NB)){fwrite(STDERR,"Task worker already running\n");exit(0);}$task=$storage->task($taskId);if(!$task||($task['status']??'')!=='pending'){flock($lock,LOCK_UN);fclose($lock);exit(0);}(new IpScanner($storage))->run($taskId);flock($lock,LOCK_UN);fclose($lock);exit(0);} +foreach($manager->due() as $range){try{$task=$manager->start($range['id']);if(($task['status']??'')==='pending'&&empty($task['started_at']))(new IpScanner($storage))->run($task['id']);}catch(\Throwable $e){fwrite(STDERR,'IPAM: '.$e->getMessage()."\n");}} diff --git a/zabbix_ipam/data/ip_ranges.json b/zabbix_ipam/data/ip_ranges.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/zabbix_ipam/data/ip_ranges.json @@ -0,0 +1 @@ +[] diff --git a/zabbix_ipam/data/results/.gitkeep b/zabbix_ipam/data/results/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/zabbix_ipam/data/results/.gitkeep @@ -0,0 +1 @@ + diff --git a/zabbix_ipam/data/scan_tasks/.gitkeep b/zabbix_ipam/data/scan_tasks/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/zabbix_ipam/data/scan_tasks/.gitkeep @@ -0,0 +1 @@ + diff --git a/zabbix_ipam/lib/HostMatcher.php b/zabbix_ipam/lib/HostMatcher.php new file mode 100644 index 0000000..d89ac47 --- /dev/null +++ b/zabbix_ipam/lib/HostMatcher.php @@ -0,0 +1,55 @@ +get([ + 'output' => ['hostid', 'host', 'name', 'status'], + 'selectInterfaces' => ['interfaceid', 'ip', 'dns', 'useip', 'main', 'type'], + 'preservekeys' => false + ]); + + foreach ($hosts as $host) { + foreach ($host['interfaces'] ?? [] as $interface) { + $ip = trim((string) ($interface['ip'] ?? '')); + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) { + continue; + } + + $host_id = (string) $host['hostid']; + $key = $host_id.'@'.$ip; + $map[$ip][$key] = [ + 'hostid' => $host_id, + 'name' => (string) ($host['name'] ?: $host['host']), + 'host' => (string) $host['host'], + 'status' => (int) $host['status'], + 'url' => self::hostUrl($host_id) + ]; + } + } + } + catch (\Throwable $exception) { + error_log('IPAM host matching: '.$exception->getMessage()); + } + + foreach ($map as $ip => $hosts) { + $map[$ip] = array_values($hosts); + } + + return self::$cache = $map; + } + + public static function hostUrl(string $host_id): string { + return 'zabbix.php?action=host.edit&hostid='.rawurlencode($host_id); + } +} diff --git a/zabbix_ipam/lib/IpScanner.php b/zabbix_ipam/lib/IpScanner.php new file mode 100644 index 0000000..31d642e --- /dev/null +++ b/zabbix_ipam/lib/IpScanner.php @@ -0,0 +1,63 @@ +storage = $storage ?: new IpStorage(); } + /** Compatibility placeholder: this module performs ICMP checks only. */ + public static function ports($ports): array { return []; } + public static function parseRange(string $value): array { + $value=trim($value); if(strpos($value,'/')!==false){[$ip,$bits]=array_pad(explode('/',$value,2),2,null);if(filter_var($ip,FILTER_VALIDATE_IP,FILTER_FLAG_IPV4)===false||filter_var($bits,FILTER_VALIDATE_INT,['options'=>['min_range'=>0,'max_range'=>32]])===false)throw new \InvalidArgumentException('Invalid IPv4 CIDR');$mask=$bits==0?0:(0xffffffff << (32-(int)$bits));$start=ip2long($ip)&$mask;$end=$start+(2**(32-(int)$bits))-1;} else {[$a,$b]=array_pad(preg_split('/\s*-\s*/',$value),2,null);if(filter_var($a,FILTER_VALIDATE_IP,FILTER_FLAG_IPV4)===false||filter_var($b,FILTER_VALIDATE_IP,FILTER_FLAG_IPV4)===false)throw new \InvalidArgumentException('Invalid IPv4 range');$start=ip2long($a);$end=ip2long($b);if($start>$end)throw new \InvalidArgumentException('Range start is after end');} + $count=$end-$start+1;if($count>self::MAX_HOSTS)throw new \InvalidArgumentException('Range exceeds maximum size');return ['start'=>(int)$start,'end'=>(int)$end,'count'=>(int)$count]; + } + public static function shards(array $range, int $size=64): array { $size=max(1,min(1024,$size));$out=[];for($s=$range['start'];$s<=$range['end'];$s+=$size)$out[]=['start'=>$s,'end'=>min($range['end'],$s+$size-1)];return $out; } + public function run(string $taskId): void { + $task=$this->storage->task($taskId);if(!$task)return;$task['status']='running';$task['started_at']=gmdate('c');$this->storage->saveTask($task); + $rows=[];$started=microtime(true); + try { + if(function_exists('pcntl_fork')&&PHP_SAPI==='cli')$this->runParallel($taskId,$task,$rows,$started,4); + else foreach($task['shards'] as $index=>$shard){if($this->stopped($taskId))return;$this->mergeShard($taskId,$index,$this->scanShard($shard,$task['ports']),$task,$rows,$started);} + $task['status']='completed';$task['finished_at']=gmdate('c');$task['updated_at']=gmdate('c');$this->storage->saveResults($taskId,$rows);$this->storage->saveTask($task);$range=$this->storage->range($task['range_id']);if($range){$range['last_scan']=$task['finished_at'];$range['scan_status']='completed';$this->storage->saveRange($range);} + } catch(\Throwable $e){$task['status']='failed';$task['error']=$e->getMessage();$task['updated_at']=gmdate('c');$this->storage->saveTask($task);} + } + private function runParallel(string $taskId,array &$task,array &$rows,float $started,int $workers):void{ + $active=[]; + foreach($task['shards'] as $index=>$shard){ + if($this->stopped($taskId)){if(function_exists('posix_kill'))foreach(array_keys($active) as $pid)posix_kill($pid,SIGTERM);return;} + while(count($active)>=$workers)$this->collectWorker($taskId,$active,$task,$rows,$started); + $pid=pcntl_fork(); + if($pid===0){$this->writeShard($taskId,$index,$shard,$task['ports']);exit(0);} + if($pid<0)throw new \RuntimeException('pcntl_fork failed'); + $active[$pid]=$index; + } + while($active)$this->collectWorker($taskId,$active,$task,$rows,$started); + } + private function collectWorker(string $taskId,array &$active,array &$task,array &$rows,float $started):void{ + $pid=pcntl_wait($status);if($pid<=0||!isset($active[$pid]))throw new \RuntimeException('Unable to wait for scan worker.');$index=$active[$pid];unset($active[$pid]); + if(!pcntl_wifexited($status)||pcntl_wexitstatus($status)!==0)throw new \RuntimeException('Scan worker failed for shard '.($index+1).'.'); + $part=$this->loadShard($taskId,$index);if($part===null)throw new \RuntimeException('Missing result for shard '.($index+1).'.'); + $this->mergeShard($taskId,$index,$part,$task,$rows,$started); + } + private function mergeShard(string $taskId,int $index,array $part,array &$task,array &$rows,float $started):void{ + foreach($part as $row){$rows[]=$row;$task['scanned']++;if($row['alive'])$task['alive']++;}$task['completed_shards']++;$task['current_shard']=$index+1;$task['updated_at']=gmdate('c');$task['estimated_remaining']=max(0,(microtime(true)-$started)/max(1,$task['scanned'])*($task['total']-$task['scanned']));$this->storage->saveResults($taskId,$rows);$this->storage->saveTask($task); + } + private function stopped(string $taskId):bool{$fresh=$this->storage->task($taskId);return($fresh['status']??'')==='stopped';} + private function scanShard(array $shard,array $ports): array { $rows=[];for($n=$shard['start'];$n<=$shard['end'];$n++)$rows[]=$this->probe(long2ip($n),$ports);return $rows; } + private function shardFile(string $task,int $index):string{return $this->storage->root().'/results/'.$task.'.shard-'.$index.'.json';} + private function writeShard(string $task,int $index,array $shard,array $ports):void{file_put_contents($this->shardFile($task,$index),json_encode($this->scanShard($shard,$ports)),LOCK_EX);} + private function loadShard(string $task,int $index):?array{$f=$this->shardFile($task,$index);if(!is_file($f))return null;$data=json_decode((string)@file_get_contents($f),true);@unlink($f);return is_array($data)?$data:null;} + private function probe(string $ip,array $ports): array { $then=microtime(true);$alive=$this->ping($ip); + return ['ip'=>$ip,'alive'=>$alive,'response_ms'=>round((microtime(true)-$then)*1000,2),'ports'=>[],'scanned_at'=>gmdate('c'),'host'=>null]; + } + private function ping(string $ip): bool { + foreach(['/usr/sbin/fping','/usr/bin/fping','/sbin/fping'] as $fping) { + if (!is_executable($fping)) continue; + // $ip originates only from long2ip() after range validation; escaping is retained as a hard boundary. + @exec(escapeshellcmd($fping).' -c 1 -t 350 '.escapeshellarg($ip).' 2>&1', $ignore, $code); + return $code === 0; + } + return false; + } +} diff --git a/zabbix_ipam/lib/IpStorage.php b/zabbix_ipam/lib/IpStorage.php new file mode 100644 index 0000000..cd67eeb --- /dev/null +++ b/zabbix_ipam/lib/IpStorage.php @@ -0,0 +1,23 @@ +root = $root ?: dirname(__DIR__) . '/data'; $this->ensure(); } + public function root(): string { return $this->root; } + private function ensure(): void { foreach ([$this->root, $this->root.'/scan_tasks', $this->root.'/results'] as $d) if (!is_dir($d)) @mkdir($d, 0770, true); } + private function file(string $name): string { return $this->root.'/'.$name; } + private function read(string $file, $default = []) { $raw = @file_get_contents($file); $data = $raw === false ? null : json_decode($raw, true); return is_array($data) ? $data : $default; } + private function write(string $file, $value): void { $tmp = $file.'.'.getmypid().'.tmp'; file_put_contents($tmp, json_encode($value, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT), LOCK_EX); rename($tmp, $file); } + public function ranges(): array { return $this->read($this->file('ip_ranges.json')); } + public function range(string $id): ?array { foreach ($this->ranges() as $r) if (($r['id'] ?? '') === $id) return $r; return null; } + public function saveRange(array $range): array { $lock=fopen($this->file('ip_ranges.lock'),'c+'); if(!$lock)throw new \RuntimeException('Data directory is not writable.');flock($lock,LOCK_EX);$all=$this->ranges();$range['id']=!empty($range['id'])?$range['id']:bin2hex(random_bytes(8));$range['updated_at']=gmdate('c');$found=false;foreach($all as &$r)if($r['id']===$range['id']){$range=array_merge($r,$range);$r=$range;$found=true;}unset($r);if(!$found){$range['created_at']=gmdate('c');$all[]=$range;}$this->write($this->file('ip_ranges.json'),$all);flock($lock,LOCK_UN);fclose($lock);return $range;} + public function deleteRange(string $id): void { $this->write($this->file('ip_ranges.json'), array_values(array_filter($this->ranges(), fn($r)=>($r['id']??'')!==$id))); } + public function task(string $id): ?array { $f=$this->file('scan_tasks/'.$id.'.json'); return is_file($f)?$this->read($f,null):null; } + public function saveTask(array $task): void { $this->write($this->file('scan_tasks/'.$task['id'].'.json'),$task); } + public function tasks(): array { $out=[]; foreach(glob($this->file('scan_tasks/*.json'))?:[] as $f){$t=$this->read($f,null);if($t)$out[]=$t;} usort($out,fn($a,$b)=>strcmp($b['created_at']??'', $a['created_at']??''));return $out; } + public function resultFile(string $taskId): string { return $this->file('results/'.$taskId.'.json'); } + public function saveResults(string $id,array $rows):void{$this->write($this->resultFile($id),$rows);} + public function results(string $id):array{return $this->read($this->resultFile($id));} +} diff --git a/zabbix_ipam/lib/LanguageManager.php b/zabbix_ipam/lib/LanguageManager.php new file mode 100644 index 0000000..4f1e1ad --- /dev/null +++ b/zabbix_ipam/lib/LanguageManager.php @@ -0,0 +1,139 @@ + 'IP 管理', + 'IP Management' => 'IP 管理', + 'IP Details' => 'IP 详情', + 'Task Management' => '任务管理', + 'Manage IPv4 ranges and run ICMP-only availability scans.' => '集中管理 IPv4 地址段并执行仅 ICMP 的存活扫描。', + 'Track ICMP scan progress and review completed background tasks.' => '跟踪 ICMP 扫描进度并查看后台任务结果。', + 'Browse every address, its latest ICMP status, and its matched Zabbix host.' => '查看每个 IP、最近一次 ICMP 状态及其匹配的 Zabbix 主机。', + 'Quick guide' => '使用说明', + 'Add a range, start an ICMP scan, then click the IP count to view the availability matrix. Green means alive; gray means unreachable or not scanned.' => '添加 IP 段后启动 ICMP 扫描;点击 IP 数量可查看使用矩阵。绿色表示存活,灰色表示不可达或尚未扫描。', + 'Association rules' => '关联说明', + 'A host is associated automatically when one of its Zabbix interfaces uses the same IPv4 address. Click the host name to open its Zabbix configuration page.' => '当 Zabbix 主机接口使用相同 IPv4 地址时自动建立关联。点击主机名称可打开 Zabbix 主机详情页。', + 'Add IP range' => '添加 IP 段', + 'Edit IP range' => '修改 IP 段', + 'All IP ranges' => '全部 IP 段', + 'IP' => 'IP', + 'IP range' => 'IP 段', + 'IP range name' => 'IP 段名称', + 'IP status' => 'IP 状态', + 'Name' => '名称', + 'Example: Office network' => '例如:办公网络', + 'Search' => '搜索', + 'Search name or CIDR' => '搜索名称或 CIDR', + 'Search task ID or range name' => '搜索任务 ID 或 IP 段名称', + 'Search IP, range, or host name' => '搜索 IP、IP 段或主机名称', + 'Status' => '状态', + 'Filter' => '筛选', + 'Reset' => '重置', + 'All statuses' => '全部状态', + 'All IP statuses' => '全部 IP 状态', + 'Enabled' => '已启用', + 'Disabled' => '已停用', + 'Pending' => '等待中', + 'Running' => '扫描中', + 'Completed' => '已完成', + 'Failed' => '失败', + 'Stopped' => '已停止', + 'Never' => '尚未扫描', + 'Alive' => '存活', + 'Unreachable' => '不可达', + 'Not scanned' => '未扫描', + 'Alive IPs / Total' => '存活 IP / IP 总数', + 'Host association' => '主机关联状态', + 'Associated host' => '关联主机名称', + 'All associations' => '全部关联状态', + 'Associated' => '已关联', + 'Unassociated' => '未关联', + 'No associated host' => '无关联主机', + 'Actions' => '操作', + 'Start scan' => '开始扫描', + 'View task' => '查看扫描任务', + 'Edit' => '修改', + 'Delete' => '删除', + 'No IP ranges found.' => '没有符合条件的 IP 段。', + 'No IP addresses found.' => '没有符合条件的 IP。', + 'Scan interval (minutes)' => '扫描间隔(分钟)', + 'Enable scheduled scans' => '启用定时扫描', + 'The cron entry scans this range when its interval is due.' => '到达扫描间隔后,由 cron 自动扫描此 IP 段。', + 'Cancel' => '取消', + 'Save' => '保存', + 'Close' => '关闭', + 'IP availability matrix' => 'IP 使用矩阵', + 'Unreachable / not scanned' => '不可达 / 未扫描', + 'Loading...' => '正在加载…', + 'Request failed.' => '请求失败,请稍后重试。', + 'Delete this IP range?' => '确定删除这个 IP 段吗?', + 'Tasks' => '任务总数', + 'Task / IP range' => '任务 / IP 段', + 'Progress' => '进度', + 'Shards' => '分片', + 'Created' => '创建时间', + 'Stop' => '停止', + 'View range' => '查看 IP 段', + 'No scan tasks found.' => '没有符合条件的扫描任务。', + 'Previous' => '上一页', + 'Next' => '下一页', + 'Page %1$d of %2$d' => '第 %1$d / %2$d 页', + '%1$d IPs in total' => '共 %1$d 个 IP', + 'Range saved.' => 'IP 段已保存。', + 'Range deleted.' => 'IP 段已删除。', + 'Task started.' => '扫描任务已启动。', + 'Task stopped.' => '扫描任务已停止。', + 'Task not found.' => '未找到扫描任务。', + 'Range not found.' => '未找到 IP 段。', + 'Failed to start task.' => '无法启动扫描任务。', + 'Stop active tasks before deleting this range.' => '请先停止该 IP 段正在运行的扫描任务。', + 'Data directory is not writable.' => '数据目录不可写。' + ]; + + public static function detectLanguage(): string { + if (self::$language !== null) { + return self::$language; + } + + $language = $GLOBALS['USER_DETAILS']['lang'] + ?? $GLOBALS['ZBX_LOCALES']['selected'] + ?? $_SESSION['zbx_lang'] + ?? $_SESSION['lang'] + ?? 'en_US'; + + try { + if (class_exists('CWebUser') && method_exists('CWebUser', 'getLang')) { + $language = \CWebUser::getLang() ?: $language; + } + elseif (class_exists('CWebUser') && method_exists('CWebUser', 'get')) { + $language = \CWebUser::get('lang') ?: $language; + } + } + catch (\Throwable $exception) { + } + + if ($language === 'default') { + try { + if (class_exists('CSettingsHelper') && method_exists('CSettingsHelper', 'get')) { + $key = defined('CSettingsHelper::DEFAULT_LANG') + ? \CSettingsHelper::DEFAULT_LANG + : 'default_lang'; + $language = \CSettingsHelper::get($key) ?: $language; + } + } + catch (\Throwable $exception) { + } + $language = $GLOBALS['ZBX_LOCALES']['selected'] ?? $language; + } + + return self::$language = strpos((string) $language, 'zh') === 0 ? 'zh_CN' : 'en_US'; + } + + public static function t(string $text): string { + return self::detectLanguage() === 'zh_CN' ? (self::$zh[$text] ?? $text) : $text; + } +} diff --git a/zabbix_ipam/lib/TaskManager.php b/zabbix_ipam/lib/TaskManager.php new file mode 100644 index 0000000..20be849 --- /dev/null +++ b/zabbix_ipam/lib/TaskManager.php @@ -0,0 +1,25 @@ +storage=$storage?:new IpStorage();} + public function start(string $rangeId,int $shardSize=64): array { $range=$this->storage->range($rangeId);if(!$range)throw new \InvalidArgumentException('Range not found');foreach($this->storage->tasks() as $old)if(($old['range_id']??'')===$rangeId&&in_array($old['status']??'',['pending','running'],true))return $old;$parsed=IpScanner::parseRange($range['range']);$id=bin2hex(random_bytes(12));$task=['id'=>$id,'range_id'=>$rangeId,'status'=>'pending','created_at'=>gmdate('c'),'updated_at'=>gmdate('c'),'total'=>$parsed['count'],'scanned'=>0,'alive'=>0,'ports'=>IpScanner::ports($range['ports']??''),'shards'=>IpScanner::shards($parsed,$shardSize),'completed_shards'=>0,'current_shard'=>0,'estimated_remaining'=>null];$this->storage->saveTask($task);$range['scan_status']='pending';$this->storage->saveRange($range);return $task; } + public function dispatch(string $id): bool { + $cli=dirname(__DIR__).'/cli/scan_cron.php'; + if(!is_file($cli)||!preg_match('/^[a-f0-9]{24}$/',$id))return false; + $task=$this->storage->task($id);if(!$task||in_array($task['status']??'',['completed','failed','stopped'],true))return false; + if(($task['status']??'')==='running'||!empty($task['dispatched_at']))return true; + $php=$this->findCli(); + if($php===null){$this->fail($id,'PHP CLI is not installed. Install php-cli for background scans and cron.');return false;} + $task['dispatched_at']=gmdate('c');$task['updated_at']=$task['dispatched_at'];$this->storage->saveTask($task); + $log=$this->storage->root().'/scan_tasks/'.$id.'.log'; + $cmd='nohup '.escapeshellarg($php).' '.escapeshellarg($cli).' --task='.escapeshellarg($id).' >> '.escapeshellarg($log).' 2>&1 &'; + @exec($cmd,$unused,$code); + if($code!==0){$this->fail($id,'Unable to launch scan worker (exit '.$code.').');return false;} + return true; + } + private function findCli():?string{foreach(['/usr/bin/php','/usr/local/bin/php','/opt/remi/php83/root/usr/bin/php'] as $p)if(is_executable($p))return $p;if(PHP_SAPI==='cli'&&is_executable(PHP_BINARY)&&strpos(basename(PHP_BINARY),'fpm')===false)return PHP_BINARY;return null;} + private function fail(string $id,string $message):void{$t=$this->storage->task($id);if(!$t)return;$t['status']='failed';$t['error']=$message;$t['updated_at']=gmdate('c');$this->storage->saveTask($t);} + public function stop(string $id):void{$t=$this->storage->task($id);if(!$t)throw new \InvalidArgumentException('Task not found');if(in_array($t['status'],['pending','running'],true)){$t['status']='stopped';$t['updated_at']=gmdate('c');$this->storage->saveTask($t);}} + public function due():array{$out=[];$now=time();foreach($this->storage->ranges() as $r){if(empty($r['enabled']))continue;$last=strtotime($r['last_scan']??'')?:0;$interval=max(1,(int)($r['scan_interval']??60))*60;if($now-$last>=$interval)$out[]=$r;}return $out;} +} diff --git a/zabbix_ipam/lib/ViewRenderer.php b/zabbix_ipam/lib/ViewRenderer.php new file mode 100644 index 0000000..62d5a03 --- /dev/null +++ b/zabbix_ipam/lib/ViewRenderer.php @@ -0,0 +1,11 @@ +setTitle($title); $p->addItem($content); $p->show(); return; } + echo ''.htmlspecialchars($title).''.$content.''; + } +} diff --git a/zabbix_ipam/lib/ZabbixVersion.php b/zabbix_ipam/lib/ZabbixVersion.php new file mode 100644 index 0000000..794af96 --- /dev/null +++ b/zabbix_ipam/lib/ZabbixVersion.php @@ -0,0 +1,6 @@ +=')); } + public static function csrfMethod(): string { return self::isModern() ? 'disableCsrfValidation' : 'disableSIDvalidation'; } +} diff --git a/zabbix_ipam/manifest.json b/zabbix_ipam/manifest.json new file mode 100644 index 0000000..c840d90 --- /dev/null +++ b/zabbix_ipam/manifest.json @@ -0,0 +1,16 @@ +{ + "manifest_version": 2.0, + "id": "zabbix-ipam", + "name": "IPAM", + "author": "Zabbix IPAM contributors", + "url": "https://github.com/X-Mars/zabbix_modules", + "version": "1.2.0", + "namespace": "ZabbixIpam", + "description": "IP address management and asynchronous network discovery for Zabbix", + "actions": { + "ip.manager": {"class": "IpManager", "view": "ip.manager"}, + "ip.detail": {"class": "IpDetail", "view": "ip.detail"}, + "ip.scan": {"class": "IpScan", "view": "ip.scan"}, + "ip.ajax": {"class": "IpAjax"} + } +} diff --git a/zabbix_ipam/views/ip.detail.php b/zabbix_ipam/views/ip.detail.php new file mode 100644 index 0000000..2d6859b --- /dev/null +++ b/zabbix_ipam/views/ip.detail.php @@ -0,0 +1,83 @@ + htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); + +$range_options = ''; +foreach ($data['ranges'] as $range) { + $selected = $data['filters']['rangeid'] === $range['id'] ? ' selected' : ''; + $range_options .= ''; +} + +$status_options = ''; +foreach (['all' => 'All IP statuses', 'alive' => 'Alive', 'down' => 'Unreachable', 'unscanned' => 'Not scanned'] as $value => $label) { + $selected = $data['filters']['status'] === $value ? ' selected' : ''; + $status_options .= ''; +} + +$association_options = ''; +foreach (['all' => 'All associations', 'yes' => 'Associated', 'no' => 'Unassociated'] as $value => $label) { + $selected = $data['filters']['associated'] === $value ? ' selected' : ''; + $association_options .= ''; +} + +$rows = ''; +foreach ($data['rows'] as $row) { + $host_links = []; + foreach ($row['hosts'] as $host) { + $host_links[] = ''.$e($host['name']).''; + } + + $rows .= '' + .''.$e($row['ip']).'' + .''.$e($row['range_name']).'' + .''.$e($row['range']).'' + .''.$t($row['state'] === 'alive' ? 'Alive' : ($row['state'] === 'down' ? 'Unreachable' : 'Not scanned')).'' + .''.$t($row['associated'] ? 'Associated' : 'Unassociated').'' + .''.($host_links ? implode('
', $host_links) : ''.$t('No associated host').'').'' + .''; +} + +$filters = $data['filters']; +$page_url = static function (int $page) use ($filters): string { + return 'zabbix.php?'.http_build_query([ + 'action' => 'ip.detail', + 'search' => $filters['search'], + 'rangeid' => $filters['rangeid'], + 'status' => $filters['status'], + 'associated' => $filters['associated'], + 'page' => $page + ]); +}; + +$pagination = '
'.sprintf($t('%1$d IPs in total'), $data['pagination']['total']).'
'; + +$html = '' + .'

'.$t('IP Details').'

'.$t('Browse every address, its latest ICMP status, and its matched Zabbix host.').'

' + .'
' + .'
'.$t('Association rules').''.$t('A host is associated automatically when one of its Zabbix interfaces uses the same IPv4 address. Click the host name to open its Zabbix configuration page.').'
' + .'
' + .'' + .'' + .'' + .'' + .''.$t('Reset').'
' + .'
' + .'' + .''.($rows ?: '').'
'.$t('IP').''.$t('IP range name').''.$t('IP range').''.$t('IP status').''.$t('Host association').''.$t('Associated host').'
'.$t('No IP addresses found.').'
'.$pagination.'
'; + +ViewRenderer::render($data['title'], $html); diff --git a/zabbix_ipam/views/ip.manager.php b/zabbix_ipam/views/ip.manager.php new file mode 100644 index 0000000..66c0b2c --- /dev/null +++ b/zabbix_ipam/views/ip.manager.php @@ -0,0 +1,76 @@ + htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); + +$range_options = ''; +foreach ($data['all_ranges'] as $range) { + $selected = $data['filters']['rangeid'] === $range['id'] ? ' selected' : ''; + $range_options .= ''; +} + +$status_options = ''; +foreach (['all' => 'All statuses', 'enabled' => 'Enabled', 'disabled' => 'Disabled', 'pending' => 'Pending', 'running' => 'Running', 'completed' => 'Completed', 'failed' => 'Failed'] as $value => $label) { + $selected = $data['filters']['status'] === $value ? ' selected' : ''; + $status_options .= ''; +} + +$rows = ''; +foreach ($data['rows'] as $row) { + $range = $row['range']; + $task = $row['task']; + $status = $row['task_status']; + $edit_data = [ + 'id' => $range['id'], + 'name' => $range['name'], + 'range' => $range['range'], + 'scan_interval' => (int) ($range['scan_interval'] ?? 60), + 'enabled' => !empty($range['enabled']) + ]; + $task_url = $task + ? 'zabbix.php?action=ip.scan&taskid='.$e($task['id']) + : 'zabbix.php?action=ip.scan&search='.rawurlencode((string) $range['name']); + + $rows .= '
'.$e($range['range']).'
' + .'
'.$e($range['name']).' '.$t(!empty($range['enabled']) ? 'Enabled' : 'Disabled').'
' + .'' + .'
'.$t(ucfirst($status)).'
' + .'' + .''.$t('View task').'' + .'' + .''; +} + +$html = '' + .'
' + .'

'.$t('IPAM').'

'.$t('Manage IPv4 ranges and run ICMP-only availability scans.').'

' + .'
' + .'
'.$t('Quick guide').''.$t('Add a range, start an ICMP scan, then click the IP count to view the availability matrix. Green means alive; gray means unreachable or not scanned.').'
' + .'
' + .'' + .'' + .'' + .''.$t('Reset').'
' + .'
' + .''.($rows ?: '').'
'.$t('IP range').''.$t('Alive IPs / Total').''.$t('Actions').'
'.$t('No IP ranges found.').'
'; + +$html .= ''; + +$html .= '' + .''; + +ViewRenderer::render($data['title'], $html); diff --git a/zabbix_ipam/views/ip.scan.php b/zabbix_ipam/views/ip.scan.php new file mode 100644 index 0000000..ae2c4b1 --- /dev/null +++ b/zabbix_ipam/views/ip.scan.php @@ -0,0 +1,62 @@ + htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); + +$counts = ['pending' => 0, 'running' => 0, 'completed' => 0, 'failed' => 0]; +foreach ($data['tasks'] as $task) { + if (isset($counts[$task['status']])) { + $counts[$task['status']]++; + } +} + +$status_options = ''; +foreach (['all' => 'All statuses', 'pending' => 'Pending', 'running' => 'Running', 'completed' => 'Completed', 'failed' => 'Failed', 'stopped' => 'Stopped'] as $value => $label) { + $selected = $data['filters']['status'] === $value ? ' selected' : ''; + $status_options .= ''; +} + +$rows = ''; +foreach ($data['tasks'] as $task) { + $percent = $task['total'] ? min(100, round($task['scanned'] / $task['total'] * 100)) : 0; + $action = in_array($task['status'], ['pending', 'running'], true) + ? '' + : ''.$t('View range').''; + + $rows .= '' + .''.$e($task['id']).'
'.$e($task['range_name']).'
' + .''.$t(ucfirst($task['status'])).'' + .'
'.(int) $task['scanned'].' / '.(int) $task['total'].' · '.$percent.'%' + .''.(int) $task['alive'].'' + .''.(int) $task['completed_shards'].' / '.count($task['shards']).'' + .''.$e($task['created_at']).'' + .''.$action.''; +} + +$root_attributes = 'data-loading="'.$e($t('Loading...')).'" ' + .'data-request-failed="'.$e($t('Request failed.')).'" ' + .'data-label-pending="'.$e($t('Pending')).'" ' + .'data-label-running="'.$e($t('Running')).'" ' + .'data-label-completed="'.$e($t('Completed')).'" ' + .'data-label-failed="'.$e($t('Failed')).'" ' + .'data-label-stopped="'.$e($t('Stopped')).'"'; + +$html = '' + .'

'.$t('Task Management').'

'.$t('Track ICMP scan progress and review completed background tasks.').'

' + .'
' + .'
'.count($data['tasks']).''.$t('Tasks').'
' + .'
'.$counts['running'].''.$t('Running').'
'.$counts['completed'].''.$t('Completed').'
'.$counts['failed'].''.$t('Failed').'
' + .'
' + .'' + .''.$t('Reset').'
' + .'
' + .''.($rows ?: '').'
'.$t('Task / IP range').''.$t('Status').''.$t('Progress').''.$t('Alive').''.$t('Shards').''.$t('Created').''.$t('Actions').'
'.$t('No scan tasks found.').'
' + .''; + +ViewRenderer::render($data['title'], $html);