diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml
new file mode 100644
index 0000000..bdcafae
--- /dev/null
+++ b/.github/workflows/package.yml
@@ -0,0 +1,45 @@
+name: Collector packaging
+on:
+ push:
+ tags:
+ - '*'
+jobs:
+ release:
+ name: Prepare release
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout collector-base
+ uses: actions/checkout@v4
+ with:
+ repository: Combodo/itop-data-collector-base
+ ref: 1.3.0
+ sparse-checkout: |
+ conf
+ core
+ data
+ toolkit
+ path: ${{ github.repository }}
+ - name: Checkout current collector
+ uses: actions/checkout@v4
+ with:
+ path: ${{ github.repository }}/collectors
+ - name: Create package
+ uses: thedoctor0/zip-release@0.7.1
+ with:
+ filename: ../${{ github.repository }}-${{ github.ref_name }}.zip
+ path: '*'
+ directory: ${{ github.repository_owner }}
+ exclusions: '*.git* */composer.json */exclude.txt */Jenkinsfile'
+ - name: Create draft release
+ uses: ncipollo/release-action@v1
+ with:
+ allowUpdates: true
+ artifacts: ${{ github.repository }}-${{ github.ref_name }}.zip
+ artifactErrorsFailBuild: true
+ draft: true
+ generateReleaseNotes: true
+ omitNameDuringUpdate: true
+ omitBodyDuringUpdate: true
+ omitPrereleaseDuringUpdate: true
+ updateOnlyUnreleased: true
+ token: ${{ secrets.ACCESS_TOKEN }}
diff --git a/json/SnmpDiscoveryCollector.json b/json/SnmpDiscoveryCollector.json
new file mode 100644
index 0000000..61b5d87
--- /dev/null
+++ b/json/SnmpDiscoveryCollector.json
@@ -0,0 +1,283 @@
+{
+ "name": "$prefix$SNMP Discovery - $uuid$",
+ "description": "SNMP Discovery v$version$",
+ "status": "$synchro_status$",
+ "user_id": "$synchro_user$",
+ "notify_contact_id": "$contact_to_notify$",
+ "scope_class": "NetworkDevice",
+ "database_table_name": "",
+ "scope_restriction": "",
+ "full_load_periodicity": "$full_load_interval$",
+ "reconciliation_policy": "use_attributes",
+ "action_on_zero": "create",
+ "action_on_one": "update",
+ "action_on_multiple": "error",
+ "delete_policy": "update",
+ "delete_policy_update": "responds_to_snmp:no",
+ "delete_policy_retention": "0",
+ "attribute_list": [
+ {
+ "attcode": "applicationsolution_list",
+ "update": "0",
+ "reconcile": "0",
+ "update_policy": "master_unlocked",
+ "row_separator": "|",
+ "attribute_separator": ";",
+ "value_separator": ":",
+ "attribute_qualifier": "'",
+ "finalclass": "SynchroAttLinkSet"
+ },
+ {
+ "attcode": "asset_number",
+ "update": "0",
+ "reconcile": "0",
+ "update_policy": "master_unlocked",
+ "finalclass": "SynchroAttribute"
+ },
+ {
+ "attcode": "brand_id",
+ "update": "0",
+ "reconcile": "0",
+ "update_policy": "master_unlocked",
+ "reconciliation_attcode": "",
+ "finalclass": "SynchroAttExtKey"
+ },
+ {
+ "attcode": "business_criticity",
+ "update": "0",
+ "reconcile": "0",
+ "update_policy": "master_unlocked",
+ "finalclass": "SynchroAttribute"
+ },
+ {
+ "attcode": "clusternetwork_id",
+ "update": "0",
+ "reconcile": "0",
+ "update_policy": "master_unlocked",
+ "reconciliation_attcode": "",
+ "finalclass": "SynchroAttExtKey"
+ },
+ {
+ "attcode": "clusternetwork_role",
+ "update": "0",
+ "reconcile": "0",
+ "update_policy": "master_unlocked",
+ "finalclass": "SynchroAttribute"
+ },
+ {
+ "attcode": "connectablecis_list",
+ "update": "0",
+ "reconcile": "0",
+ "update_policy": "master_unlocked",
+ "row_separator": "|",
+ "attribute_separator": ";",
+ "value_separator": ":",
+ "attribute_qualifier": "'",
+ "finalclass": "SynchroAttLinkSet"
+ },
+ {
+ "attcode": "contacts_list",
+ "update": "0",
+ "reconcile": "0",
+ "update_policy": "master_unlocked",
+ "row_separator": "|",
+ "attribute_separator": ";",
+ "value_separator": ":",
+ "attribute_qualifier": "'",
+ "finalclass": "SynchroAttLinkSet"
+ },
+ {
+ "attcode": "description",
+ "update": "0",
+ "reconcile": "0",
+ "update_policy": "master_unlocked",
+ "finalclass": "SynchroAttribute"
+ },
+ {
+ "attcode": "documents_list",
+ "update": "0",
+ "reconcile": "0",
+ "update_policy": "master_unlocked",
+ "row_separator": "|",
+ "attribute_separator": ";",
+ "value_separator": ":",
+ "attribute_qualifier": "'",
+ "finalclass": "SynchroAttLinkSet"
+ },
+ {
+ "attcode": "end_of_warranty",
+ "update": "0",
+ "reconcile": "0",
+ "update_policy": "master_unlocked",
+ "finalclass": "SynchroAttribute"
+ },
+ {
+ "attcode": "iosversion_id",
+ "update": "0",
+ "reconcile": "0",
+ "update_policy": "master_unlocked",
+ "reconciliation_attcode": "",
+ "finalclass": "SynchroAttExtKey"
+ },
+ {
+ "attcode": "location_id",
+ "update": "0",
+ "reconcile": "0",
+ "update_policy": "master_unlocked",
+ "reconciliation_attcode": "",
+ "finalclass": "SynchroAttExtKey"
+ },
+ {
+ "attcode": "managementip_id",
+ "update": "1",
+ "reconcile": "1",
+ "update_policy": "master_unlocked",
+ "reconciliation_attcode": "",
+ "finalclass": "SynchroAttExtKey"
+ },
+ {
+ "attcode": "model_id",
+ "update": "0",
+ "reconcile": "0",
+ "update_policy": "master_unlocked",
+ "reconciliation_attcode": "",
+ "finalclass": "SynchroAttExtKey"
+ },
+ {
+ "attcode": "move2production",
+ "update": "0",
+ "reconcile": "0",
+ "update_policy": "master_unlocked",
+ "finalclass": "SynchroAttribute"
+ },
+ {
+ "attcode": "name",
+ "update": "1",
+ "reconcile": "0",
+ "update_policy": "write_if_empty",
+ "finalclass": "SynchroAttribute"
+ },
+ {
+ "attcode": "nb_u",
+ "update": "0",
+ "reconcile": "0",
+ "update_policy": "master_unlocked",
+ "finalclass": "SynchroAttribute"
+ },
+ {
+ "attcode": "networkdevice_list",
+ "update": "0",
+ "reconcile": "0",
+ "update_policy": "master_unlocked",
+ "row_separator": "|",
+ "attribute_separator": ";",
+ "value_separator": ":",
+ "attribute_qualifier": "'",
+ "finalclass": "SynchroAttLinkSet"
+ },
+ {
+ "attcode": "networkdevicetype_id",
+ "update": "1",
+ "reconcile": "0",
+ "update_policy": "write_if_empty",
+ "reconciliation_attcode": "",
+ "finalclass": "SynchroAttExtKey"
+ },
+ {
+ "attcode": "org_id",
+ "update": "1",
+ "reconcile": "1",
+ "update_policy": "write_if_empty",
+ "reconciliation_attcode": "",
+ "finalclass": "SynchroAttExtKey"
+ },
+ {
+ "attcode": "purchase_date",
+ "update": "0",
+ "reconcile": "0",
+ "update_policy": "master_unlocked",
+ "finalclass": "SynchroAttribute"
+ },
+ {
+ "attcode": "ram",
+ "update": "0",
+ "reconcile": "0",
+ "update_policy": "master_unlocked",
+ "finalclass": "SynchroAttribute"
+ },
+ {
+ "attcode": "replacement_date",
+ "update": "0",
+ "reconcile": "0",
+ "update_policy": "master_locked",
+ "finalclass": "SynchroAttribute"
+ },
+ {
+ "attcode": "responds_to_snmp",
+ "update": "1",
+ "reconcile": "0",
+ "update_policy": "master_locked",
+ "finalclass": "SynchroAttribute"
+ },
+ {
+ "attcode": "serialnumber",
+ "update": "1",
+ "reconcile": "0",
+ "update_policy": "master_unlocked",
+ "finalclass": "SynchroAttribute"
+ },
+ {
+ "attcode": "snmp_last_discovery",
+ "update": "1",
+ "reconcile": "0",
+ "update_policy": "master_locked",
+ "finalclass": "SynchroAttribute"
+ },
+ {
+ "attcode": "snmp_sysContact",
+ "update": "1",
+ "reconcile": "0",
+ "update_policy": "master_locked",
+ "finalclass": "SynchroAttribute"
+ },
+ {
+ "attcode": "snmp_sysDescr",
+ "update": "1",
+ "reconcile": "0",
+ "update_policy": "master_locked",
+ "finalclass": "SynchroAttribute"
+ },
+ {
+ "attcode": "snmp_sysLocation",
+ "update": "1",
+ "reconcile": "0",
+ "update_policy": "master_locked",
+ "finalclass": "SynchroAttribute"
+ },
+ {
+ "attcode": "snmp_sysName",
+ "update": "1",
+ "reconcile": "0",
+ "update_policy": "master_locked",
+ "finalclass": "SynchroAttribute"
+ },
+ {
+ "attcode": "snmpcredentials_id",
+ "update": "1",
+ "reconcile": "0",
+ "update_policy": "master_unlocked",
+ "reconciliation_attcode": "",
+ "finalclass": "SynchroAttExtKey"
+ },
+ {
+ "attcode": "status",
+ "update": "1",
+ "reconcile": "0",
+ "update_policy": "write_if_empty",
+ "finalclass": "SynchroAttribute"
+ }
+ ],
+ "user_delete_policy": "administrators",
+ "url_icon": "",
+ "url_application": ""
+}
diff --git a/main.php b/main.php
new file mode 100644
index 0000000..255de8e
--- /dev/null
+++ b/main.php
@@ -0,0 +1,13 @@
+
+
+
+
+ 0123-4567-89AB-CDEF
+
+
+
+ -
+
+ .1.3.6.1.4.1.2011.2
+
+ .1.3.6.1.2.1.47.1.1.1.1.11
+
+ getNextNonEmpty
+ yes
+
+ -
+
+ /.*/
+
+ .1.3.6.1.2.1.2.2.1.6
+
+ getNextValidMAC
+ no
+
+
+
+
+ implementation
+
+
+
+ $discovery_application_uuid$
+ implementation
+ 3600
+
+
diff --git a/src/SnmpCredentials.class.inc.php b/src/SnmpCredentials.class.inc.php
new file mode 100644
index 0000000..066d9be
--- /dev/null
+++ b/src/SnmpCredentials.class.inc.php
@@ -0,0 +1,33 @@
+name = $aSnmpCredentials['name'];
+ $oSelf->community = $aSnmpCredentials['community'];
+ $oSelf->securityLevel = $aSnmpCredentials['security_level'];
+ $oSelf->securityName = $aSnmpCredentials['security_name'];
+ $oSelf->authenticationProtocol = is_null($aSnmpCredentials['auth_protocol']) ? strtoupper($aSnmpCredentials['auth_protocol']) : null;
+ $oSelf->authenticationPassphrase = $aSnmpCredentials['auth_passphrase'];
+ $oSelf->privacyProtocol = is_null($aSnmpCredentials['priv_protocol']) ? strtoupper($aSnmpCredentials['priv_protocol']) : null;
+ $oSelf->privacyPassphrase = $aSnmpCredentials['priv_passphrase'];
+ $oSelf->contextName = $aSnmpCredentials['context_name'];
+ return $oSelf;
+ }
+}
diff --git a/src/SnmpDiscoveryCollector.class.inc.php b/src/SnmpDiscoveryCollector.class.inc.php
new file mode 100644
index 0000000..fb38033
--- /dev/null
+++ b/src/SnmpDiscoveryCollector.class.inc.php
@@ -0,0 +1,495 @@
+ List of subnets with their configured parameters
+ */
+ protected array $aSubnets = [];
+ /** @var array List of all the IP addresses to discover with their subnet IP */
+ protected array $aIPAddresses = [];
+ /** @var array Cache of potential SNMP credentials */
+ protected array $aSnmpCredentials = [];
+ /** @var int Number of IPs that didn't respond to any SNMP request */
+ protected int $iFailedIPs = 0;
+
+ /**
+ * @inheritDoc
+ * @return void
+ * @throws Exception
+ */
+ public function Init(): void
+ {
+ parent::Init();
+
+ // Check if modules are installed
+ static::CheckModuleInstallation('sv-snmp-discovery', true);
+
+ // Load SNMP discovery application settings
+ $this->LoadApplicationSettings();
+ }
+
+ /**
+ * @return bool
+ * @throws Exception
+ */
+ public function Prepare(): bool
+ {
+ // Load all responding IP addresses
+ $this->LoadAllIPAddresses();
+
+ // Load extra device info
+ $this->LoadDevices();
+ //$this->LoadAdditionalDevices();
+
+ return parent::Prepare();
+ }
+
+ /**
+ * @return array|false
+ * @throws Exception
+ */
+ protected function Fetch(): array|false
+ {
+ while ($iKey = key($this->aIPAddresses)) {
+ next($this->aIPAddresses);
+
+ // Discover IP addresses as network device
+ if ($aData = $this->DiscoverDeviceByIP($iKey)) return $aData;
+ else $this->iFailedIPs++;
+ }
+
+ return false;
+ }
+
+ /**
+ * @return void
+ * @throws Exception
+ */
+ protected function Cleanup(): void
+ {
+ Utils::Log(LOG_NOTICE, $this->iFailedIPs . ' non responding devices.');
+ parent::Cleanup();
+ }
+
+ /**
+ * @param array $aPlaceHolders
+ * @return string|false
+ * @throws Exception
+ */
+ public function GetSynchroDataSourceDefinition($aPlaceHolders = []): string|false
+ {
+ $aPlaceHolders['$uuid$'] = Utils::GetConfigurationValue('discovery_application_uuid');
+
+ return parent::GetSynchroDataSourceDefinition($aPlaceHolders);
+ }
+
+ /**
+ * Workaround needed until PR merged in data-collector-base
+ * @link https://github.com/Combodo/itop-data-collector-base/pull/37
+ * @param string[] $aHeaders
+ * @return void
+ * @throws Exception
+ */
+ protected function AddHeader($aHeaders): void
+ {
+ $this->aCSVHeaders = array();
+ foreach ($aHeaders as $sHeader) {
+ if (($sHeader != 'primary_key') && !$this->HeaderIsAllowed($sHeader)) {
+ if (!$this->AttributeIsOptional($sHeader)) {
+ Utils::Log(LOG_WARNING, "Invalid column '$sHeader', will be ignored.");
+ }
+ } else {
+ $this->aCSVHeaders[] = $sHeader;
+ }
+ }
+ fputcsv($this->aCSVFile[$this->iFileIndex], $this->aCSVHeaders, $this->sSeparator);
+ }
+
+ /**
+ * Workaround needed until PR merged in iTop
+ * @link https://github.com/Combodo/iTop/pull/541
+ * @param string $sHeader
+ * @return bool
+ */
+ protected function HeaderIsAllowed(string $sHeader): bool
+ {
+ if (in_array($sHeader, [
+ 'snmp_sysname',
+ 'snmp_sysdescr',
+ 'snmp_syslocation',
+ 'snmp_syscontact',
+ ])) return true;
+
+ /**
+ * Workaround needed until PR merged in data-collector-base
+ * @link https://github.com/Combodo/itop-data-collector-base/pull/37
+ * @example return parent::HeaderIsAllowed($sHeader);
+ */
+ return array_key_exists($sHeader, $this->aFields);
+ }
+
+ /**
+ * Check if the given module is installed in iTop
+ * @param string $sName Name of the module to be found
+ * @param bool $bRequired Whether to throw exceptions when module not found
+ * @return bool True when the given module is installed, false otherwise
+ * @throws Exception When the module is required but could not be found
+ */
+ protected static function CheckModuleInstallation(string $sName, bool $bRequired = false): bool
+ {
+ $oRestClient = new RestClient();
+ try {
+ $aResults = $oRestClient->Get('ModuleInstallation', ['name' => $sName], 'name,version');
+ if ($aResults['code'] != 0 || empty($aResults['objects'])) {
+ throw new Exception($aResults['message'], $aResults['code']);
+ }
+ $aObject = current($aResults['objects']);
+ Utils::Log(LOG_DEBUG, sprintf('iTop module %s version %s is installed.', $aObject['fields']['name'], $aObject['fields']['version']));
+ } catch (Exception $e) {
+ $sMessage = sprintf('%s iTop module %s is considered as not installed due to: %s', $bRequired ? 'Required' : 'Optional', $sName, $e->getMessage());
+ if ($bRequired) throw new Exception($sMessage, 0, $e);
+ else {
+ Utils::Log(LOG_INFO, $sMessage);
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Load the SNMP discovery application settings (ID and subnet to discover)
+ * @return void
+ * @throws Exception
+ */
+ protected function LoadApplicationSettings(): void
+ {
+ $sUUID = Utils::GetConfigurationValue('discovery_application_uuid');
+ $oRestClient = new RestClient();
+
+ try {
+ $aResults = $oRestClient->Get('SNMPDiscovery', ['uuid' => $sUUID], 'ipv4subnets_list,ipv6subnets_list');
+ if ($aResults['code'] != 0 || empty($aResults['objects'])) {
+ throw new Exception($aResults['message'], $aResults['code']);
+ }
+
+ $aDiscovery = current($aResults['objects']);
+ $this->iApplicationID = (int)$aDiscovery['key'];
+
+ Utils::Log(LOG_INFO, sprintf('An SNMP discovery application with UUID %s has been found in iTop.', $sUUID));
+ } catch (Exception $e) {
+ throw new Exception(sprintf('An SNMP discovery application with UUID %s could not be found: %s', $sUUID, $e->getMessage()), 0, $e);
+ }
+
+ // Prepare IPv4 subnet info
+ foreach ($aDiscovery['fields']['ipv4subnets_list'] as $aSubnet) {
+ $this->LoadSubnet($aSubnet);
+ }
+
+ // Prepare IPv6 subnet info
+ foreach ($aDiscovery['fields']['ipv6subnets_list'] as $aSubnet) {
+ $this->LoadSubnet($aSubnet);
+ }
+
+ Utils::Log(LOG_INFO, count($this->aSubnets) . ' subnets to discover.');
+ }
+
+ /**
+ * Prepare the subnet parameters list by the given subnet
+ * @param array{ip: string, org_id: string, default_networkdevicetype_id: string, snmpcredentials_list: int[], dhcp_range_discovery_enabled: string} $aSubnet
+ * @return void
+ * @throws Exception
+ */
+ protected function LoadSubnet(array $aSubnet): void
+ {
+ if ($aSubnet['ipdiscovery_enabled'] == 'yes') {
+ $this->aSubnets[$aSubnet['ip']] = [
+ 'default_org_id' => (int)$aSubnet['org_id'],
+ 'default_networkdevicetype_id' => (int)$aSubnet['default_networkdevicetype_id'],
+ 'snmpcredentials_list' => array_reduce($aSubnet['snmpcredentials_list'], function ($aCredentials, $aListItem) {
+ $aCredentials[] = (int)$aListItem['snmpcredentials_id'];
+ return $aCredentials;
+ }, []),
+ 'dhcp_range_discovery_enabled' => $aSubnet['dhcp_range_discovery_enabled'],
+ ];
+
+ if (empty($aSubnet['default_networkdevicetype_id'])) {
+ Utils::Log(LOG_WARNING, sprintf('No default networkdevicetype_id set for subnet %s, creation of new devices might fail.', $aSubnet['ip']));
+ }
+ }
+ }
+
+ /**
+ * Load all IP addresses to discover from the subnet linked to the current SNMP discovery application
+ * @return void
+ * @throws Exception
+ */
+ protected function LoadAllIPAddresses(): void
+ {
+ // Load IPv4 addresses to discover
+ $aIPv4Addresses = static::LoadIPAddresses('IPv4Address', sprintf(<<iApplicationID));
+
+ // Load IPv6 addresses to discover
+ $aIPv6Addresses = static::LoadIPAddresses('IPv6Address', sprintf(<<iApplicationID));
+
+ $this->aIPAddresses = $aIPv4Addresses + $aIPv6Addresses;
+ Utils::Log(LOG_INFO, count($this->aIPAddresses) . ' addresses to process.');
+ }
+
+ /**
+ * Load IP addresses to discover by the given class and query
+ * @param 'IPv4Address'|'IPv6Address' $sClass The IP class to query
+ * @param string $sKeySpec The OQL to select addresses to discover
+ * @return array
+ * @throws Exception
+ */
+ protected static function LoadIPAddresses(string $sClass, string $sKeySpec): array
+ {
+ $aIPAddresses = [];
+ try {
+ $oRestClient = new RestClient();
+
+ $aResults = $oRestClient->Get($sClass, $sKeySpec, 'ip,subnet_ip,responds_to_ping');
+ if ($aResults['code'] != 0) {
+ throw new Exception($aResults['message'], $aResults['code']);
+ }
+
+ if (!empty($aResults['objects'])) foreach ($aResults['objects'] as $aIPAddress) {
+ // Skip non responding IPs
+ if ($aIPAddress['fields']['responds_to_ping'] != 'no') {
+ $aIPAddresses[(int)$aIPAddress['key']] = $aIPAddress['fields'];
+ } else Utils::Log(LOG_DEBUG, sprintf('Skipping non responding IP %s.', $aIPAddress['fields']['ip']));
+ }
+ } catch (Exception $e) {
+ throw new Exception(sprintf('Could not load %s: %s', $sClass, $e->getMessage()));
+ }
+
+ return $aIPAddresses;
+ }
+
+ /**
+ * Load known devices' snmp credentials so the collector doesn't need to figure out again.
+ * @return void
+ * @throws Exception
+ */
+ protected function LoadDevices(): void
+ {
+ try {
+ $oRestClient = new RestClient();
+
+ $aResults = $oRestClient->Get('NetworkDevice', sprintf('SELECT NetworkDevice WHERE snmpcredentials_id != 0 AND managementip_id IN(%s)', implode(',', array_keys($this->aIPAddresses))), 'managementip_id,snmpcredentials_id');
+ if ($aResults['code'] != 0) {
+ throw new Exception($aResults['message'], $aResults['code']);
+ }
+
+ if (!empty($aResults['objects'])) foreach ($aResults['objects'] as $aNetworkDevice) {
+ $this->aDeviceSnmpCredentials[(int) $aNetworkDevice['key']] = [
+ 'managementip_id' => (int) $aNetworkDevice['fields']['managementip_id'],
+ 'snmpcredentials_id' => (int) $aNetworkDevice['fields']['snmpcredentials_id'],
+ ];
+ }
+ } catch (Exception $e) {
+ throw new Exception(sprintf('Could not load device credentials: %s', $e->getMessage()));
+ }
+ }
+
+ /**
+ * @param integer $iKey
+ * @return SnmpCredentials
+ * @throws Exception
+ */
+ protected function LoadSnmpCredentials(int $iKey): SnmpCredentials
+ {
+ if (!isset($this->aSnmpCredentials[$iKey])) {
+ $oRestClient = new RestClient();
+ $aResults = $oRestClient->Get('SnmpCredentials', $iKey, 'name,community,security_level,security_name,auth_protocol,auth_passphrase,priv_protocol,priv_passphrase,context_name');
+
+ if ($aResults['code'] != 0 || empty($aResults['objects'])) throw new Exception($aResults['message'], $aResults['code']);
+
+ $aCredentials = current($aResults['objects']);
+ $this->aSnmpCredentials[$iKey] = SnmpCredentials::fromArray($aCredentials['fields']);
+ }
+
+ return $this->aSnmpCredentials[$iKey];
+ }
+
+ /**
+ * @param int $iKey ID of the IPAddress
+ * @return array{
+ * primary_key: string,
+ * org_id: int,
+ * name: string,
+ * networkdevicetype_id: int,
+ * managementip_id: int,
+ * snmpcredentials_id: int,
+ * status: string,
+ * serialnumber: ?string,
+ * responds_to_snmp: 'yes',
+ * snmp_last_discovery: string,
+ * snmp_sysname: string,
+ * snmp_sysdescr: string,
+ * snmp_syscontact: string,
+ * snmp_syslocation: string,
+ * }|null
+ * @throws Exception
+ */
+ protected function DiscoverDeviceByIP(int $iKey): ?array
+ {
+ ['ip' => $sIP, 'subnet_ip' => $sSubnetIP] = $this->aIPAddresses[$iKey];
+ $aSubnet = $this->aSubnets[$sSubnetIP];
+ Utils::Log(LOG_DEBUG, sprintf('Discovering IP %s...', $sIP));
+
+ // Prepare known credentials
+ $aDeviceCredentials = [];
+ foreach ($this->aDeviceSnmpCredentials as $aDevice) {
+ if ($aDevice['managementip_id'] == $iKey) $aDeviceCredentials[] = $aDevice['snmpcredentials_id'];
+ }
+
+ // Try SNMP connection with each known credential
+ foreach (array_unique(array_merge($aDeviceCredentials, $aSubnet['snmpcredentials_list'])) as $iCredentialsKey) {
+ $oCredentials = $this->LoadSnmpCredentials($iCredentialsKey);
+
+ Utils::Log(LOG_DEBUG, sprintf('Trying credential %s...', $oCredentials->name));
+ $oSNMP = static::LoadSNMPConnection($sIP, $oCredentials);
+
+ $sysObjectID = @$oSNMP->get(/* SNMPv2-MIB::sysObjectID */ '.1.3.6.1.2.1.1.2.0');
+
+ if ($sysObjectID === false) Utils::Log(LOG_DEBUG, $oSNMP->getError());
+ else {
+ Utils::Log(LOG_INFO, sprintf('IP %s responds to %s.', $sIP, $oCredentials->name));
+ Utils::Log(LOG_DEBUG, 'Device sysObjectID: '. $sysObjectID);
+ $sPrimaryKey = $sysObjectID . ' - ';
+
+ // Find device serial number
+ ['serial' => $sSerial, 'load' => $bLoadSerial] = static::FindDeviceSerial($oSNMP, $sysObjectID);
+ if (!is_null($sSerial)) {
+ Utils::Log(LOG_DEBUG, 'Device serial: ' . $sSerial);
+ $sPrimaryKey .= $sSerial;
+ } else $sPrimaryKey .= $sIP;
+
+ // Find device type
+ // ToDo: mapping table on oid?
+
+ // Load system table info
+ [
+ '.1.3.6.1.2.1.1.1.0' => $sSysDescr,
+ '.1.3.6.1.2.1.1.4.0' => $sSysContact,
+ '.1.3.6.1.2.1.1.5.0' => $sSysName,
+ '.1.3.6.1.2.1.1.6.0' => $sSysLocation,
+ ] = @$oSNMP->get([
+ /* SNMPv2-MIB::sysDescr */ '.1.3.6.1.2.1.1.1.0',
+ /* SNMPv2-MIB::sysContact */ '.1.3.6.1.2.1.1.4.0',
+ /* SNMPv2-MIB::sysName */ '.1.3.6.1.2.1.1.5.0',
+ /* SNMPv2-MIB::sysLocation */ '.1.3.6.1.2.1.1.6.0',
+ ]);
+
+ // Return device
+ return [
+ 'primary_key' => $sPrimaryKey,
+ 'org_id' => $aSubnet['default_org_id'],
+ 'name' => $sSysName,
+ 'networkdevicetype_id' => $aSubnet['default_networkdevicetype_id'],
+ 'managementip_id' => $iKey,
+ 'snmpcredentials_id' => $iCredentialsKey,
+ 'status' => Utils::GetConfigurationValue('default_status'),
+ 'serialnumber' => $bLoadSerial ? $sSerial : null,
+ 'responds_to_snmp' => 'yes',
+ 'snmp_last_discovery' => date('Y-m-d H:i:s'),
+ 'snmp_sysname' => $sSysName,
+ 'snmp_sysdescr' => trim($sSysDescr),
+ 'snmp_syscontact' => $sSysContact,
+ 'snmp_syslocation' => $sSysLocation,
+ ];
+ }
+ }
+
+ Utils::Log(LOG_INFO, sprintf('IP %s does not respond.', $sIP));
+ return null;
+ }
+
+ /**
+ * @param SNMP $oSNMP
+ * @param string $sSysObjectID
+ * @return array{serial: string, load: bool}|null
+ * @throws Exception
+ */
+ protected static function FindDeviceSerial(SNMP $oSNMP, string $sSysObjectID): ?array
+ {
+ /** @var array $aSerialDetectionOptions */
+ $aSerialDetectionOptions = Utils::GetConfigurationValue('serial_detection', []);
+
+ foreach ($aSerialDetectionOptions as $aDetectionOption) {
+ $sSysObjectIDMatch = $aDetectionOption['system_oid_match'];
+ if (($sSysObjectIDMatch[0] == '/' && preg_match($sSysObjectIDMatch, $sSysObjectID)) || substr_compare($sSysObjectID, $sSysObjectIDMatch, 0, strlen($sSysObjectIDMatch)) === 0) {
+ Utils::Log(LOG_DEBUG, 'sysObjectID matches with ' . $sSysObjectIDMatch);
+
+ $bFound = false;
+ $sSerial = null;
+ $bLoadSerial = filter_var($aDetectionOption['use_as_serialnumber'], FILTER_VALIDATE_BOOLEAN);
+ $sSerialOid = $aDetectionOption['serial_oid'];
+
+ if ($aDetectionOption['method'] == 'get') {
+ $sSerial = $oSNMP->get($sSerialOid);
+ $bFound = ($sSerial !== false);
+ } else do {
+ $aResult = $oSNMP->getnext([$sSerialOid]);
+ if (is_array($aResult)) {
+ $sSerial = current($aResult);
+ $sSerialOid = key($aResult);
+ switch ($aDetectionOption['method']) {
+ case 'getNextNonEmpty':
+ $bFound = !empty($sSerial);
+ break;
+ case 'getNextValidMAC':
+ $sPhysAddress = bin2hex($sSerial);
+ $bFound = (strlen($sPhysAddress) == 12 && !empty(hexdec($sPhysAddress)));
+ if ($bFound) $sSerial = implode(':', str_split($sPhysAddress, 2));
+ break;
+ }
+ }
+ } while (!$bFound && substr_count($sSerialOid, $aDetectionOption['serial_oid']));
+ if ($bFound) {
+ return ['serial' => $sSerial, 'load' => $bLoadSerial];
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * @param string $sHostname
+ * @param SnmpCredentials $oCredentials
+ * @return SNMP
+ */
+ protected static function LoadSNMPConnection(string $sHostname, SnmpCredentials $oCredentials): SNMP
+ {
+ if (!empty($oCredentials->securityLevel)) {
+ $oSNMP = new SNMP(SNMP::VERSION_3, $sHostname, $oCredentials->securityName);
+ $oSNMP->setSecurity(
+ $oCredentials->securityLevel,
+ $oCredentials->authenticationProtocol,
+ $oCredentials->authenticationPassphrase,
+ $oCredentials->privacyProtocol,
+ $oCredentials->privacyPassphrase,
+ $oCredentials->contextName
+ );
+ } else $oSNMP = new SNMP(SNMP::VERSION_2c, $sHostname, $oCredentials->community);
+
+ // Plain value retrieval
+ $oSNMP->valueretrieval = SNMP_VALUE_PLAIN;
+ // Numeric OID output format
+ $oSNMP->oid_output_format = SNMP_OID_OUTPUT_NUMERIC;
+
+ return $oSNMP;
+ }
+}