Skip to content
Merged

Dev #47

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions zabbix_ipam/Module.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php
namespace Modules\ZabbixIpam;

require_once __DIR__ . '/lib/LanguageManager.php';
use Modules\ZabbixIpam\Lib\LanguageManager;

if (class_exists('Zabbix\\Core\\CModule')) {
class ModuleBase extends \Zabbix\Core\CModule {}
} elseif (class_exists('Core\\CModule')) {
class ModuleBase extends \Core\CModule {}
} else {
class ModuleBase { public function init(): void {} }
}

/** Registers one Inventory submenu on Zabbix 6.0 through 8.0. */
class Module extends ModuleBase {
public function init(): void {
try {
if (class_exists('APP') && method_exists('APP', 'Component') && class_exists('CMenuItem')) {
$menu = \APP::Component()->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()); }
}
}
33 changes: 33 additions & 0 deletions zabbix_ipam/README.md
Original file line number Diff line number Diff line change
@@ -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 建立关联。任务管理页集中显示后台任务状态、扫描进度、存活数及已完成分片数;页面会自动轮询运行中的任务,无需手动刷新。
3 changes: 3 additions & 0 deletions zabbix_ipam/README_en.md
Original file line number Diff line number Diff line change
@@ -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.
153 changes: 153 additions & 0 deletions zabbix_ipam/actions/IpAjax.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php

namespace Modules\ZabbixIpam\Actions;

use CController;
use Modules\ZabbixIpam\Lib\HostMatcher;
use Modules\ZabbixIpam\Lib\IpScanner;
use Modules\ZabbixIpam\Lib\IpStorage;
use Modules\ZabbixIpam\Lib\LanguageManager;
use Modules\ZabbixIpam\Lib\TaskManager;

require_once dirname(__DIR__).'/lib/HostMatcher.php';
require_once dirname(__DIR__).'/lib/IpScanner.php';
require_once dirname(__DIR__).'/lib/IpStorage.php';
require_once dirname(__DIR__).'/lib/LanguageManager.php';
require_once dirname(__DIR__).'/lib/TaskManager.php';

class IpAjax extends CController {
public function init(): void {
if (method_exists($this, 'disableCsrfValidation')) {
$this->disableCsrfValidation();
}
elseif (method_exists($this, 'disableSIDvalidation')) {
$this->disableSIDvalidation();
Comment on lines +20 to +24
}
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;
Comment on lines +80 to +87

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];
}
}
139 changes: 139 additions & 0 deletions zabbix_ipam/actions/IpDetail.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

namespace Modules\ZabbixIpam\Actions;

use CController;
use CControllerResponseData;
use Modules\ZabbixIpam\Lib\HostMatcher;
use Modules\ZabbixIpam\Lib\IpScanner;
use Modules\ZabbixIpam\Lib\IpStorage;
use Modules\ZabbixIpam\Lib\LanguageManager;

require_once dirname(__DIR__).'/lib/HostMatcher.php';
require_once dirname(__DIR__).'/lib/IpScanner.php';
require_once dirname(__DIR__).'/lib/IpStorage.php';
require_once dirname(__DIR__).'/lib/LanguageManager.php';

class IpDetail extends CController {
private const PAGE_SIZE = 50;

public function init(): void {
if (method_exists($this, 'disableCsrfValidation')) {
$this->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
]
]));
}
}
11 changes: 11 additions & 0 deletions zabbix_ipam/actions/IpManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php
namespace Modules\ZabbixIpam\Actions;
use CController, CControllerResponseData;
require_once dirname(__DIR__).'/lib/IpStorage.php';require_once dirname(__DIR__).'/lib/IpScanner.php';require_once dirname(__DIR__).'/lib/LanguageManager.php';
use Modules\ZabbixIpam\Lib\{IpStorage,IpScanner,LanguageManager};
class IpManager extends CController {
public function init():void{if(method_exists($this,'disableCsrfValidation'))$this->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]]));}
}
11 changes: 11 additions & 0 deletions zabbix_ipam/actions/IpScan.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php
namespace Modules\ZabbixIpam\Actions;
use CController, CControllerResponseData;
require_once dirname(__DIR__).'/lib/IpStorage.php';require_once dirname(__DIR__).'/lib/LanguageManager.php';
use Modules\ZabbixIpam\Lib\IpStorage;use Modules\ZabbixIpam\Lib\LanguageManager;
class IpScan extends CController {
public function init():void{if(method_exists($this,'disableCsrfValidation'))$this->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]]));}
}
Loading