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 = ''.$t('All IP ranges').' ';
+foreach ($data['ranges'] as $range) {
+ $selected = $data['filters']['rangeid'] === $range['id'] ? ' selected' : '';
+ $range_options .= ''.$e($range['name'].' · '.$range['range']).' ';
+}
+
+$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 .= ''.$t($label).' ';
+}
+
+$association_options = '';
+foreach (['all' => 'All associations', 'yes' => 'Associated', 'no' => 'Unassociated'] as $value => $label) {
+ $selected = $data['filters']['associated'] === $value ? ' selected' : '';
+ $association_options .= ''.$t($label).' ';
+}
+
+$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 = '';
+
+$html = ' '
+ .''
+ .'
'.$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('IP').' '.$t('IP range name').' '.$t('IP range').' '.$t('IP status').' '.$t('Host association').' '.$t('Associated host').' '
+ .' '.($rows ?: ''.$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 = ''.$t('All IP ranges').' ';
+foreach ($data['all_ranges'] as $range) {
+ $selected = $data['filters']['rangeid'] === $range['id'] ? ' selected' : '';
+ $range_options .= ''.$e($range['name'].' · '.$range['range']).' ';
+}
+
+$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 .= ''.$t($label).' ';
+}
+
+$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').'
'
+ .''.(int) $row['alive'].' / '.(int) $row['total'].' '
+ .''.$t(ucfirst($status)).'
'
+ .''.$t('Start scan').' '
+ .''.$t('View task').' '
+ .''.$t('Edit').' '
+ .''.$t('Delete').' ';
+}
+
+$html = ' '
+ .''
+ .'
'
+ .'
'.$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('IP range').' '.$t('Alive IPs / Total').' '.$t('Actions').' '
+ .''.($rows ?: ''.$t('No IP ranges found.').' ').'
';
+
+$html .= '';
+
+$html .= '
'
+ .''.$t('IP availability matrix').'
× '
+ .' '.$t('Alive').' '.$t('Unreachable / not scanned').'
'
+ .'
';
+
+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 .= ''.$t($label).' ';
+}
+
+$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('Stop').' '
+ : ''.$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 = ' '
+ .''
+ .'
'.count($data['tasks']).' '.$t('Tasks').'
'
+ .'
'.$counts['running'].' '.$t('Running').'
'.$counts['completed'].' '.$t('Completed').'
'.$counts['failed'].' '.$t('Failed').'
'
+ .'
'
+ .'
'.$t('Task / IP range').' '.$t('Status').' '.$t('Progress').' '.$t('Alive').' '.$t('Shards').' '.$t('Created').' '.$t('Actions').' '
+ .''.($rows ?: ''.$t('No scan tasks found.').' ').'
'
+ .'
';
+
+ViewRenderer::render($data['title'], $html);