Files
TheWitness f79dc15c08 Port #8166 SNMP credential cache + procedural SNMPv3 (with #8171/#8172 follow-ups) to develop (#8174)
* Port #8166 SNMP credential cache + procedural SNMPv3 to develop

Ports the shared SNMPv3 credential cache and procedural SNMPv3 routing from the
1.2.x feature (merged as #8166) onto develop, adapted to develop's refactored
SNMP layer. Parts of the 1.2.x change that do not apply to develop are omitted:
the cacti_exec() idle-timeout rewrite (develop has no cacti_exec()), the PHP
floor bump and composer/doc changes (develop is already 8.3+), and the cmd.php
max_oids batching (develop's cmd.php already collects SNMP in-process through
persistent sessions, so the per-item spawn the batching removed does not occur).

- lib/cache.php: new cross-process \Cacti\Cache\SharedCache (file + POSIX
  shared-memory + process-local tiers, optional AES-256-GCM sealing). Loaded via
  lib/snmp.php (the Cacti\Cache\ namespace is not PSR-4 autoloaded). The crypto
  helpers it seals with already live in develop's lib/auth.php, loaded by
  global.php, so no duplicate was added.
- lib/snmp.php: snmp_get_method() now routes SNMPv3 get/getnext to the
  procedural snmp3_*() calls when the running PHP supports the configured
  auth/privacy tokens and no context/engine id is set (SHA-224/384 and
  AES-192/256[C] stay on the binary before PHP 8.6); the three single-get
  functions gain the procedural v3 path with \Throwable handling; adds the
  credential-cache subsystem, protocol-option/MD5-DES helpers,
  snmp_native_protocol(), snmp_format_agent() and cacti_snmp_get_multi(); the
  binary v3 auth builder consults the cache when enabled.
- poller.php / script_server.php: prime / load the shared cache.
- poller_maintenance.php: daily out-of-band cache rebuild on every collector
  (per-poller lastrun marker).
- lib/api_device.php / lib/utility.php: bump snmp_cred_version only on v3
  credential add/change (and propagate the token to the affected remote poller).
- include/global.php: resolve and create the private cache/ directory.
- include/global_settings.php: add 'Enable Credential Cache' and 'Enable MD5 and
  DES for SNMPv3' settings (both off by default); default new-device v3
  auth/privacy to SHA/AES instead of legacy MD5/DES.
- host.php / automation_snmp.php: drop MD5/DES from the pickers when disabled and
  default new records to SHA/AES on upgraded installs.
- include/config.php.dist: document $config['cache_dir'].
- tests/Unit/Core/Cache/SharedCacheTest.php: SharedCache behaviour coverage.
- tests/security/baselines: baseline the two new lib/cache.php sinks.

* Incorporate #8171: binary fallback for unsupported SNMPv3 protocols

Layers PR #8171 onto the develop port so an SNMPv3 device whose auth/privacy
protocols the running PHP's ext-snmp cannot service natively (e.g. AES-256
before PHP 8.6) no longer fatals the poller or device-edit screen.

- lib/snmp.php: add CactiSnmpBinarySession, a session stand-in exposing the
  get/getnext/walk/close interface the cacti_snmp_session_*() helpers use and
  delegating each call to cacti_snmp_*() -> the Net-SNMP binary.
  cacti_snmp_session() returns it when php-snmp is absent or the v3 protocol is
  not natively serviceable, so cmd.php/host.php collect these devices via the
  binary on pre-8.6 and switch to the in-process path on 8.6 with no caller
  change. v1/v2 snmpget/snmp2_get now go through snmp_format_agent(). The
  ERRNO_TIMEOUT check tolerates php-snmp being absent. Drop the vendored
  classSNMP.php compatibility shim and its include (and the Yandex attribution).
- cmd.php: open_snmp_session() returns the down sentinel when a host is already
  marked down this run, instead of returning an unset (null) []
  that cacti_snmp_session_get() then dereferenced (TypeError).
- lib/ping.php: ping_snmp() runs the availability check through the procedural
  cacti_snmp_get()/getnext() path so an unsupported protocol falls back to the
  binary instead of raising a ValueError in SNMP::setSecurity().

* Incorporate #8172: retire include/vendor/phpsnmp, relocate into lib

Follow-up to #8171 on the develop port. After classSNMP.php was removed, the
include/vendor/phpsnmp/ directory held only Cacti-owned files; this retires it.

- lib/snmp.php: the ext-snmp subclass that carried Cacti's bulk_walk_size /
  value_output_format (previously vendor/phpsnmp/extension.php) is now declared
  directly as CactiSnmpNativeSession, guarded by CACTI_PHP_SNMP; the include and
  the 'use phpsnmp\SNMP' alias are gone, and new SNMP(...) / SNMP::VERSION_*
  now reference the native \SNMP and the new subclass.
- lib/mib_parser.php: moved from include/vendor/phpsnmp/mib_parser.php;
  lib/mib_cache.php includes it via __DIR__.
- Remove include/vendor/phpsnmp/extension.php and index.php (directory retired).
- tests: SnmpCoverageTest uses \SNMP::ERRNO_TIMEOUT now that phpsnmp\SNMP is
  gone; drop the stale include/vendor/phpsnmp/ entry from the vendor-policy
  allowlist.

develop's sink baseline tracks constant-based includes differently than 1.2.x,
so no baseline refresh was needed here.

* Address PR #8174 review: snmp_get_method params, v3 walk, getnext agent, cache bump, CHANGELOG

- lib/snmp.php: restore $php_snmp as the 6th snmp_get_method() parameter and move
  the new $auth_proto/$priv_proto after it (callers pass them by name), so the
  positional $php_snmp used by SnmpCoverageTest and others keeps working.
- lib/snmp.php: keep every SNMPv3 operation except get/getnext on the Net-SNMP
  binary - the native walk branch implements only v1/v2, so a v3 walk reaching
  the procedural gate would have silently returned empty when path_snmpbulkwalk
  is missing.
- lib/snmp.php: cacti_snmp_getnext() v1/v2 branches now use snmp_format_agent()
  like get/get_raw, so an IPv6 literal / non-default port is bracketed.
- lib/api_device.php: bump snmp_cred_version only when the host's v3 membership
  or one of the cached credential fields actually changed, instead of on every
  save of a v3 device.
- CHANGELOG: move the #8171 entry into the issue block and the #8166 entries into
  the feature block, matching the established section ordering.

* Fix CI: unbracket IPv4/DNS SNMP targets, inline auth-cache SQL columns, apply phpcsfixer standard

* Refresh sink inventory baseline after short-array normalization

* Restore lib/snmp.php 100% coverage after aligning v3 routing to 1.2.x

- update the three SnmpCoverageTest assertions that still encoded develop's
  pre-port behavior: v3 snmp_get_method now returns PHP, an unsupported-
  protocol v3 session returns the binary-delegating session object, and the
  v3 cacti_snmp_get path runs natively
- add a v3 noAuthNoPriv user to the snmp-coverage snmpd fixture so native
  snmp3_get is exercised deterministically
- add tests covering the ported subsystems to 100%: shared auth cache,
  binary-delegating session, protocol pickers, v3 credential args,
  cacti_snmp_get_multi, and the cacti_get_snmpv3_auth cache path
- snmp_auth_cache_load uses a resettable process guard so its enabled and
  disabled branches are both coverable in a single process

* Resolve phpstan errors in the relocated SNMP MIB parser and ported snmp.php

- lib/mib_parser.php: type the MibParser properties, add parameter/return
  types, guard file_get_contents()/preg_split() against false, correct the
  parse_bracket_token delimiter params (string, not integer), and drop the
  unreachable return after trigger_error(E_USER_ERROR) - now phpstan clean
- lib/mib_cache.php: drop the redundant isset($mp->oids) guard (the typed
  property is always initialized; $mp->mib is the real check)
- lib/snmp.php: type the CactiSnmpBinarySession and CactiSnmpNativeSession
  properties/methods and pass bool to snmp_set_quick_print()
- .phpstan.neon: ignore greaterOrEqual.alwaysFalse alongside the existing
  greater.alwaysFalse (the PHP_VERSION_ID >= 80600 forward-compat gate)
- regenerate phpstan-baseline.neon for the irreducible ext-snmp stub
  strictness (snmp3_get literal-union protocols, int<-1,max> timeouts) in
  the ported procedural v3 path, the new lib/cache.php shmop stubs, and the
  $config array-shape offsets in include/global.php

* Make the v3 getter catch coverage net-snmp-build-independent

The DES-rejection trick that drove the procedural snmp3_get()/snmp3_getnext()
try/catch blocks only works on net-snmp builds compiled without DES; CI's
Debian net-snmp accepts DES, so those catches went uncovered (98.3%).

- extend the existing native_get injection seam (already on cacti_snmp_get)
  to cacti_snmp_get_raw, cacti_snmp_getnext, and cacti_snmp_get_multi, and
  drive the catch blocks with a throwing native getter in the test
- mark the seam-less cacti_snmp_session() setSecurity catch with
  @codeCoverageIgnore (it only throws for a protocol the running net-snmp
  rejects, which is not portably reproducible in the fixture)

* Exclude ext-snmp/PHP-8.6-gated defensive branches from snmp coverage

Two branches in lib/snmp.php are unreachable on the PHP 8.3 + ext-snmp
coverage runner and were only "covered" locally via opcache dead-code
elimination:

- snmp_get_method()'s !function_exists('snmp3_get') fallback (ext-snmp
  always provides snmp3_get)
- snmp_php_v3_protocols_supported()'s PHP_VERSION_ID >= 80600 block
  (SHA-224/384 and AES-192/256 tokens are only accepted on PHP 8.6+)

Mark both with @codeCoverageIgnore so the 100% gate is deterministic
across runners regardless of opcache settings.

* Account for the #8166 SNMPv3 credential-cache gate query in the batching test

poller_update_poller_cache_from_buffer() now runs one SELECT per flush to
decide whether the flush touched SNMPv3 poller items before bumping the shared
credential-cache version (matching 1.2.x). UpdateDataQueryCacheBatchingIntegrationTest
counted 4 statements per flush; update it to 5 (and 3 per-row flushes to 15,
1 batched flush to 5). The guard's intent - batching issues fewer statements
than per-row flushes - still holds.

* Port #8175: group SNMP options under an SNMP Behavior section

Mirror the 1.2.x settings reorganization (#8175) on develop. develop already
keeps the SNMP options in a dedicated SNMP tab, so add an "SNMP Behavior"
spacer there, order the behavior options as md5/des, credential cache, max
get size, bulkwalk fetch size, and move "Disable increasing OID Check" out of
the Poller tab to sit last in that group. Display-only; no settings added or
removed and no behavior change. Adds the "SNMP Behavior" heading to cacti.pot.

* fix: complete dependabot htmx 2.0.10 to 2.0.11 bump

Dependabot bumped package.json and package-lock.json to htmx.org 2.0.11
(PR 8179) but left the vendored include/js/htmx.js, its version marker,
and the pinned SRI constant at 2.0.10. The htmx-filters CI workflow runs
npm ci, which regenerates include/js/htmx.js from the 2.0.11 package, so
HtmxTest's runtime sha384 no longer matches the HTMX_2_0_10_SRI constant
and the integrity assertion fails.

Complete the bump: regenerate the vendored include/js/htmx.js to the
2.0.11 build, bump include/js/htmx.js.version to 2.0.11, rename
HTMX_2_0_10_SRI to HTMX_2_0_11_SRI with the recomputed sha384 hash, and
update the version/integrity assertions in tests/Unit/Ui/Html/HtmxTest.php
and tests/HandOff/HtmxLoaderHandOffTest.php. package.json and
package-lock.json match the values dependabot already landed on develop.

* settings: move SNMP Agent Support under the SNMP Behavior spacer

Relocate the enable_snmp_agent setting (SNMP Agent Support Enabled) so it
is the first item under the SNMP Behavior spacer instead of sitting in the
Poller General group, grouping it with the other SNMP behavior options.

* Address PR #8174 review: keep SHA-224/384 on the binary, honor port on transport targets

Resolves the two still-valid Copilot review findings on lib/snmp.php that were
left unresolved (only marked outdated after later commits shifted their lines).

- snmp_php_v3_protocols_supported(): drop SHA224/SHA384 from the native auth
  allowlist. ext-snmp's SNMP::setSecurity()/snmp3_*() only map MD5/SHA/SHA256/
  SHA512 - there is no SHA-224/SHA-384 mapping on any build, including PHP 8.6
  (verified against php-src ext/snmp/snmp.c), so routing those tokens to the
  procedural path raised a ValueError instead of using the binary. They now
  always fall back to the Net-SNMP binary. The AES-192/256 privacy tokens that
  ext-snmp does add on 8.6 stay gated behind PHP_VERSION_ID >= 80600.
- snmp_format_agent(): a udp/tcp transport-qualified target that omits a port
  now has the configured non-default port appended (udp6:[2001:db8::1] + 1161 ->
  udp6:[2001:db8::1]:1161), matching the Net-SNMP binary path. Targets that
  already carry a port, and unix: sockets (no port), are left untouched.
- SnmpCoverageTest: cover the new transport-target branches; lib/snmp.php stays
  at 100%.
2026-10-05 10:18:41 -04:00

2089 lines
68 KiB
PHP

<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU General Public License for more details. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
| This code is designed, written, and maintained by the Cacti Group. See |
| about.php and/or the AUTHORS file for specific developer information. |
+-------------------------------------------------------------------------+
| http://www.cacti.net/ |
+-------------------------------------------------------------------------+
*/
/**
* Select a reliable uptime value from sysUpTime and snmpEngineTime.
*
* Some agents, notably OpenBSD snmpd, return the current Unix timestamp for
* snmpEngineTime. That value is not an uptime and must not replace the real
* sysUpTime value. Legitimate engine time remains useful after the 32-bit
* TimeTicks value wraps, so retain the existing preference when it is at
* least the system uptime and does not resemble wall-clock time.
*
* @param mixed $system_uptime sysUpTime in hundredths of a second.
* @param mixed $engine_time snmpEngineTime in seconds.
* @param int|null $now Current Unix time, injectable for tests.
* @param bool $prefer_engine_time When true, skip BOTH the wall-clock rejection and
* the "prefer whichever is larger" comparison, and
* always use engine time once it is numeric and
* positive. Spine's own reindex assert re-check
* (poller.c) always prefers the engine OID whenever
* it is numeric - with no wall-clock awareness and no
* magnitude comparison of its own; the recache
* baseline stored for spine to compare against must
* use the exact same rule, or a device whose engine
* time is legitimately smaller than sysUpTime (e.g.
* the SNMP agent restarted more recently than the OS),
* or an OpenBSD-style agent returning the Unix clock
* as engine time, causes a permanent mismatch and an
* infinite RECACHE ASSERT loop.
*
* @return int|false Selected uptime in hundredths of a second.
*/
function cacti_snmp_select_uptime(mixed $system_uptime, mixed $engine_time, ?int $now = null, bool $prefer_engine_time = false) : int|false {
$system_uptime = is_numeric($system_uptime) && $system_uptime >= 0 ? (int) $system_uptime : false;
if (!is_numeric($engine_time) || $engine_time <= 0) {
return $system_uptime;
}
$engine_time = (int) $engine_time;
$engine_uptime = $engine_time * 100;
// spine's own reindex re-check has no wall-clock awareness at all - it
// unconditionally prefers any numeric engine time. Paths that must agree
// with spine's live comparison value have to replicate that exactly,
// including on OpenBSD-style agents that return the Unix clock as engine
// time, or the stored baseline and spine's re-check permanently disagree
// and the RECACHE ASSERT loop persists for those devices too.
if ($prefer_engine_time) {
return $engine_uptime;
}
$now = $now ?? time();
$epoch_range = 5 * 366 * 86400;
if ($now > $epoch_range && abs($engine_time - $now) <= $epoch_range) {
return $system_uptime;
}
return $system_uptime === false || $engine_uptime >= $system_uptime ? $engine_uptime : $system_uptime;
}
// trim all but hex-string:, which will return 'hex-'
// define('REGEXP_SNMP_TRIM', '/(counter(32|64):|gauge:|gauge(32|64):|float:|ipaddress:|string:|integer:)$/i');
define('REGEXP_SNMP_TRIM', '/(counter(32|64)|gauge|gauge(32|64)|float|ipaddress|string|integer):/i');
define('SNMP_METHOD_PHP', 1);
define('SNMP_METHOD_BINARY', 2);
/* the shared SNMP credential cache (\Cacti\Cache\SharedCache) is not covered by
* the PSR-4 autoloader, so load it here wherever SNMP support is pulled in */
require_once(__DIR__ . '/cache.php');
if (!defined('SNMP_STRING_OUTPUT_GUESS')) {
define('SNMP_STRING_OUTPUT_GUESS', 1);
}
if (!defined('SNMP_STRING_OUTPUT_ASCII')) {
define('SNMP_STRING_OUTPUT_ASCII', 2);
}
if (!defined('SNMP_STRING_OUTPUT_HEX')) {
define('SNMP_STRING_OUTPUT_HEX', 3);
}
global $banned_snmp_strings;
$banned_snmp_strings = ['End of MIB', 'No Such', 'No more'];
/* ext-snmp's native \SNMP has no slot for Cacti's bulk_walk_size / value_output_format,
so subclass it to carry them. Only declarable when ext-snmp is loaded. */
if (CACTI_PHP_SNMP) {
class CactiSnmpNativeSession extends \SNMP {
public ?int $bulk_walk_size = null;
public int $value_output_format = SNMP_STRING_OUTPUT_GUESS;
}
}
/**
* Session stand-in for an SNMPv3 device whose auth/privacy protocols the running
* PHP's ext-snmp cannot service natively (e.g. AES-256 before PHP 8.6). It exposes
* the same get/getnext/walk/close interface the cacti_snmp_session_*() helpers use,
* delegating every call to cacti_snmp_*() which routes the request to the Net-SNMP
* binary. Parallels CactiSnmpNativeSession, the ext-snmp subclass used when the
* running PHP can service the device's auth/privacy protocols natively.
*/
class CactiSnmpBinarySession {
public array $info = [];
public int $bulk_walk_size = 10;
public int $value_output_format = SNMP_STRING_OUTPUT_GUESS;
private int $errno = 0;
private array $params = [];
public function __construct(array $params, int $bulk_walk_size) {
$this->params = $params;
$this->bulk_walk_size = (int) $bulk_walk_size;
$this->info = [
'hostname' => $params['hostname'],
'timeout' => $params['timeout'],
];
}
private function uniget(string $command, mixed $oid): mixed {
$p = $this->params;
$function = 'cacti_snmp_' . $command;
$is_array = is_array($oid);
$oids = $is_array ? $oid : [$oid];
$output = [];
foreach ($oids as $o) {
$output[$o] = $function($p['hostname'], $p['community'], $o, $p['version'],
$p['auth_user'], $p['auth_pass'], $p['auth_proto'], $p['priv_pass'], $p['priv_proto'],
$p['context'], $p['port'], $p['timeout'], $p['retries'], 'SNMP', $p['engineid'],
$this->value_output_format);
}
if (!$is_array) {
return cacti_sizeof($output) ? array_shift($output) : false;
}
return $output;
}
public function get(mixed $oid): mixed {
return $this->uniget('get', $oid);
}
public function getnext(mixed $oid): mixed {
return $this->uniget('getnext', $oid);
}
public function walk(mixed $oid, bool $dummy = false, int $max_repetitions = 10, int $non_repeaters = 0): array|false {
if (is_array($oid)) {
return false;
}
$p = $this->params;
$result = cacti_snmp_walk($p['hostname'], $p['community'], $oid, $p['version'],
$p['auth_user'], $p['auth_pass'], $p['auth_proto'], $p['priv_pass'], $p['priv_proto'],
$p['context'], $p['port'], $p['timeout'], $p['retries'], $max_repetitions, 'SNMP',
$p['engineid'], $this->value_output_format);
$output = [];
if (is_array($result)) {
foreach ($result as $item) {
$output[$item['oid']] = $item['value'];
}
}
return $output;
}
public function close(): bool {
return true;
}
public function getErrno(): int {
return $this->errno;
}
public function getError(): string {
return '';
}
}
function cacti_snmp_session(string $hostname, mixed $community, mixed $version, mixed $auth_user = '', mixed $auth_pass = '',
mixed $auth_proto = '', mixed $priv_pass = '', mixed $priv_proto = '', mixed $context = '', mixed $engineid = '',
mixed $port = 161, mixed $timeout_ms = 500, mixed $retries = 0, mixed $max_oids = 10, mixed $bulk_walk_size = 10) : mixed {
/* A device the native ext-snmp session cannot serve - php-snmp not installed,
or an SNMPv3 auth/privacy protocol this PHP rejects (e.g. AES-256 before PHP
8.6) - uses a binary-delegating session that routes to the Net-SNMP binary. */
if (!CACTI_PHP_SNMP ||
($version == 3 && !snmp_php_v3_protocols_supported($auth_proto, $priv_proto))) {
return new CactiSnmpBinarySession([
'hostname' => $hostname,
'community' => $community,
'version' => $version,
'auth_user' => $auth_user,
'auth_pass' => $auth_pass,
'auth_proto' => $auth_proto,
'priv_pass' => $priv_pass,
'priv_proto' => $priv_proto,
'context' => $context,
'engineid' => $engineid,
'port' => $port,
'timeout' => $timeout_ms,
'retries' => $retries,
], $bulk_walk_size);
}
switch ($version) {
case '1':
$version = \SNMP::VERSION_1;
break;
case '2':
$version = \SNMP::VERSION_2c;
break;
case '3':
$version = \SNMP::VERSION_3;
break;
}
$timeout_us = (int) ($timeout_ms * 1000);
try {
$session = new CactiSnmpNativeSession($version, $hostname . ':' . (is_numeric($port) ? (int) $port : 161), ($version == 3 ? $auth_user : $community), $timeout_us, $retries);
} catch (Throwable $e) {
return false;
}
if (defined('SNMP_OID_OUTPUT_NUMERIC')) {
$session->oid_output_format = SNMP_OID_OUTPUT_NUMERIC;
$session->valueretrieval = SNMP_VALUE_PLAIN;
}
$session->quick_print = false;
$session->max_oids = $max_oids;
$session->bulk_walk_size = $bulk_walk_size;
if (read_config_option('oid_increasing_check_disable') == 'on') {
$session->oid_increasing_check = false;
}
if ($version != \SNMP::VERSION_3) {
return $session;
}
if ($priv_proto == '[None]' || $priv_pass == '') {
if ($auth_pass == '' || $auth_proto == '[None]') {
$sec_level = 'noAuthNoPriv';
} else {
$sec_level = 'authNoPriv';
}
$priv_proto = '';
} else {
$sec_level = 'authPriv';
}
try {
$session->setSecurity($sec_level, $auth_proto, $auth_pass, $priv_proto, $priv_pass, $context, $engineid);
// @codeCoverageIgnoreStart
// setSecurity only throws for a protocol this net-snmp build rejects
// (e.g. DES on AES-only builds), which is not portably reproducible here.
} catch (Throwable) {
return false;
}
// @codeCoverageIgnoreEnd
return $session;
}
/**
* Gets a single SNMP value through the native extension or configured binary.
*
* @param callable|null $native_get Optional native getter override used by isolated callers and tests.
*
* @return string Formatted SNMP value, or `U` when the request fails.
*/
function cacti_snmp_get(string $hostname, mixed $community, string $oid, mixed $version, mixed $auth_user = '', mixed $auth_pass = '',
mixed $auth_proto = '', mixed $priv_pass = '', mixed $priv_proto = '', mixed $context = '',
mixed $port = 161, mixed $timeout_ms = 500, mixed $retries = 0, mixed $environ = 'SNMP',
mixed $engineid = '', int $value_output_format = SNMP_STRING_OUTPUT_GUESS, ?callable $native_get = null) : string {
global $snmp_error;
$max_oids = 1;
$snmp_error = '';
if (!cacti_snmp_options_sanitize($version, $community, $port, $timeout_ms, $retries, $max_oids)) {
return 'U';
}
if (snmp_get_method('get', $version, $context, $engineid, $value_output_format, auth_proto: $auth_proto, priv_proto: $priv_proto) == SNMP_METHOD_PHP) {
// make sure snmp* is verbose so we can see what types of data we are getting back
snmp_set_quick_print(false);
if (function_exists('snmp_set_enum_print')) {
snmp_set_enum_print(true);
}
$timeout_us = (int) ($timeout_ms * 1000);
$snmp_value = 'U';
try {
if ($native_get !== null) {
$snmp_value = $native_get();
} elseif ($version == '1') {
$snmp_value = @snmpget(snmp_format_agent($hostname, $port), $community, $oid, $timeout_us, $retries);
} elseif ($version == '2') {
$snmp_value = @snmp2_get(snmp_format_agent($hostname, $port), $community, $oid, $timeout_us, $retries);
} else {
if ($priv_proto == '[None]' || $priv_pass == '') {
$sec_level = ($auth_pass == '' || $auth_proto == '[None]') ? 'noAuthNoPriv' : 'authNoPriv';
$priv_proto = '';
} else {
$sec_level = 'authPriv';
}
$snmp_value = @snmp3_get(snmp_format_agent($hostname, $port), $auth_user, $sec_level, snmp_native_protocol($auth_proto), $auth_pass, snmp_native_protocol($priv_proto), $priv_pass, $oid, $timeout_us, $retries);
}
} catch (Throwable $ex) {
$snmp_error = $ex->getMessage();
}
if ($snmp_value === false) {
cacti_log("WARNING: SNMP Error:'$snmp_error', Device:'$hostname', OID:'$oid'", false, $environ);
$snmp_value = 'U';
} else {
$snmp_value = format_snmp_string($snmp_value, false, $value_output_format);
}
} else {
$snmp_value = '';
$hostname = cacti_format_ipv6_colon($hostname);
// net snmp want the timeout in seconds
$timeout_s = (int) ceil($timeout_ms / 1000);
if ($version == '1') {
$snmp_auth = '-c ' . snmp_escape_string($community); // v1/v2 - community string
} elseif ($version == '2') {
$snmp_auth = '-c ' . snmp_escape_string($community); // v1/v2 - community string
$version = '2c'; // ucd/net snmp prefers this over '2'
} elseif ($version == '3') {
$snmp_auth = cacti_get_snmpv3_auth($auth_proto, $auth_user, $auth_pass, $priv_proto, $priv_pass, $context, $engineid);
}
// no valid snmp version has been set, get out
if (empty($snmp_auth)) {
return 'U';
}
$command = cacti_escapeshellcmd(read_config_option('path_snmpget')) .
' -O fntevU' . ($value_output_format == SNMP_STRING_OUTPUT_HEX ? 'x ' : ' ') . $snmp_auth .
' -v ' . $version .
' -t ' . $timeout_s .
' -r ' . $retries .
' ' . cacti_escapeshellarg_cmd($hostname, true, true) . ':' . $port .
' ' . cacti_escapeshellarg($oid);
if (isset($_SESSION)) {
debug_log_insert('data_query', __esc('SNMP Command is: %s', $command));
}
$return_var = 0;
exec($command, $snmp_value, $return_var);
$snmp_value = trim(implode(' ', $snmp_value));
// a non-zero exit signals snmpget failure even when the output omits 'Timeout'
if (str_contains($snmp_value, 'Timeout') || $return_var != 0) {
$reason = str_contains($snmp_value, 'Timeout') ? 'Timeout' : "Exit Code $return_var, Output:'$snmp_value'";
cacti_log("WARNING: SNMP Error:'$reason', Device:'$hostname', OID:'$oid'", false, 'SNMP', POLLER_VERBOSITY_HIGH);
$snmp_value = 'U';
} else {
$snmp_value = format_snmp_string($snmp_value, false, $value_output_format);
}
}
return $snmp_value;
}
function cacti_snmp_get_raw(string $hostname, mixed $community, string $oid, mixed $version, mixed $auth_user = '', string $auth_pass = '',
mixed $auth_proto = '', mixed $priv_pass = '', mixed $priv_proto = '', mixed $context = '',
mixed $port = 161, mixed $timeout_ms = 500, mixed $retries = 0, mixed $environ = SNMP_POLLER,
string $engineid = '', int $value_output_format = SNMP_STRING_OUTPUT_GUESS, ?callable $native_get = null) : string {
global $snmp_error;
$max_oids = 1;
$snmp_error = '';
if (!cacti_snmp_options_sanitize($version, $community, $port, $timeout_ms, $retries, $max_oids)) {
return 'U';
}
if (snmp_get_method('get', $version, $context, $engineid, $value_output_format, auth_proto: $auth_proto, priv_proto: $priv_proto) == SNMP_METHOD_PHP) {
$snmp_value = false;
/* make sure snmp* is verbose so we can see what types of data
we are getting back */
snmp_set_quick_print(false);
$timeout_us = (int) ($timeout_ms * 1000);
if (function_exists('snmp_set_enum_print')) {
snmp_set_enum_print(true);
}
try {
if ($native_get !== null) {
$snmp_value = $native_get();
} elseif ($version == '1') {
$snmp_value = @snmpget(snmp_format_agent($hostname, $port), $community, $oid, $timeout_us, $retries);
} elseif ($version == '2') {
$snmp_value = @snmp2_get(snmp_format_agent($hostname, $port), $community, $oid, $timeout_us, $retries);
} else {
if ($priv_proto == '[None]' || $priv_pass == '') {
$sec_level = ($auth_pass == '' || $auth_proto == '[None]') ? 'noAuthNoPriv' : 'authNoPriv';
$priv_proto = '';
} else {
$sec_level = 'authPriv';
}
$snmp_value = @snmp3_get(snmp_format_agent($hostname, $port), $auth_user, $sec_level, snmp_native_protocol($auth_proto), $auth_pass, snmp_native_protocol($priv_proto), $priv_pass, $oid, $timeout_us, $retries);
}
} catch (Throwable $ex) {
$snmp_error = $ex->getMessage();
$snmp_value = false;
}
if ($snmp_value === false) {
cacti_log("WARNING: SNMP Error:'$snmp_error', Device:'$hostname', OID:'$oid'", false);
$snmp_value = 'U';
}
} else {
$snmp_value = '';
$hostname = cacti_format_ipv6_colon($hostname);
// net snmp want the timeout in seconds
$timeout_s = (int) ceil($timeout_ms / 1000);
if ($version == '1') {
$snmp_auth = '-c ' . snmp_escape_string($community); // v1/v2 - community string
} elseif ($version == '2') {
$snmp_auth = '-c ' . snmp_escape_string($community); // v1/v2 - community string
$version = '2c'; // ucd/net snmp prefers this over '2'
} elseif ($version == '3') {
$snmp_auth = cacti_get_snmpv3_auth($auth_proto, $auth_user, $auth_pass, $priv_proto, $priv_pass, $context, $engineid);
}
// no valid snmp version has been set, get out
if (empty($snmp_auth)) {
return 'U';
}
$command = cacti_escapeshellcmd(read_config_option('path_snmpget')) .
' -O fntev' . ($value_output_format == SNMP_STRING_OUTPUT_HEX ? 'x ' : ' ') . $snmp_auth .
' -v ' . $version .
' -t ' . $timeout_s .
' -r ' . $retries .
' ' . cacti_escapeshellarg_cmd($hostname, true, true) . ':' . $port .
' ' . cacti_escapeshellarg($oid);
if (isset($_SESSION)) {
debug_log_insert('data_query', __esc('SNMP Command is: %s', $command));
}
$return_var = 0;
exec($command, $snmp_value, $return_var);
// fix for multi-line snmp output
$snmp_value = trim(implode(' ', $snmp_value));
// a non-zero exit signals snmpget failure even when the output omits 'Timeout'
if (str_contains($snmp_value, 'Timeout') || $return_var != 0) {
$reason = str_contains($snmp_value, 'Timeout') ? 'Timeout' : "Exit Code $return_var, Output:'$snmp_value'";
cacti_log("WARNING: SNMP Error:'$reason', Device:'$hostname', OID:'$oid'", false, 'SNMP', POLLER_VERBOSITY_HIGH);
$snmp_value = 'U';
}
}
return $snmp_value;
}
function cacti_snmp_getnext(string $hostname, mixed $community, mixed $oid, mixed $version, mixed $auth_user = '', mixed $auth_pass = '',
mixed $auth_proto = '', mixed $priv_pass = '', mixed $priv_proto = '', mixed $context = '',
mixed $port = 161, mixed $timeout_ms = 500, mixed $retries = 0, mixed $environ = 'SNMP',
string $engineid = '', int $value_output_format = SNMP_STRING_OUTPUT_GUESS, ?callable $native_get = null) : string {
global $snmp_error;
$max_oids = 1;
$snmp_error = '';
if (!cacti_snmp_options_sanitize($version, $community, $port, $timeout_ms, $retries, $max_oids)) {
return 'U';
}
if (snmp_get_method('getnext', $version, $context, $engineid, $value_output_format, auth_proto: $auth_proto, priv_proto: $priv_proto) == SNMP_METHOD_PHP) {
$snmp_value = false;
// make sure snmp* is verbose so we can see what types of data we are getting back
snmp_set_quick_print(false);
$timeout_us = (int) ($timeout_ms * 1000);
try {
if ($native_get !== null) {
$snmp_value = $native_get();
} elseif ($version == '1') {
$snmp_value = @snmpgetnext(snmp_format_agent($hostname, $port), $community, $oid, $timeout_us, $retries);
} elseif ($version == '2') {
$snmp_value = @snmp2_getnext(snmp_format_agent($hostname, $port), $community, $oid, $timeout_us, $retries);
} else {
if ($priv_proto == '[None]' || $priv_pass == '') {
$sec_level = ($auth_pass == '' || $auth_proto == '[None]') ? 'noAuthNoPriv' : 'authNoPriv';
$priv_proto = '';
} else {
$sec_level = 'authPriv';
}
$snmp_value = @snmp3_getnext(snmp_format_agent($hostname, $port), $auth_user, $sec_level, snmp_native_protocol($auth_proto), $auth_pass, snmp_native_protocol($priv_proto), $priv_pass, $oid, $timeout_us, $retries);
}
} catch (Throwable $ex) {
$snmp_error = $ex->getMessage();
$snmp_value = false;
}
if ($snmp_value === false) {
cacti_log("WARNING: SNMP Error:'$snmp_error', Device:'$hostname', OID:'$oid'", false);
$snmp_value = 'U';
} else {
$snmp_value = format_snmp_string($snmp_value, false, $value_output_format);
}
} else {
$snmp_value = '';
$hostname = cacti_format_ipv6_colon($hostname);
// net snmp want the timeout in seconds
$timeout_s = (int) ceil($timeout_ms / 1000);
if ($version == '1') {
$snmp_auth = '-c ' . snmp_escape_string($community); // v1/v2 - community string
} elseif ($version == '2') {
$snmp_auth = '-c ' . snmp_escape_string($community); // v1/v2 - community string
$version = '2c'; // ucd/net snmp prefers this over '2'
} elseif ($version == '3') {
$snmp_auth = cacti_get_snmpv3_auth($auth_proto, $auth_user, $auth_pass, $priv_proto, $priv_pass, $context, $engineid);
}
// no valid snmp version has been set, get out
if (empty($snmp_auth)) {
return 'U';
}
$command = cacti_escapeshellcmd(read_config_option('path_snmpgetnext')) .
' -O fntevU' . ($value_output_format == SNMP_STRING_OUTPUT_HEX ? 'x ' : ' ') . $snmp_auth .
' -v ' . $version .
' -t ' . $timeout_s .
' -r ' . $retries .
' ' . cacti_escapeshellarg_cmd($hostname, true, true) . ':' . $port .
' ' . cacti_escapeshellarg($oid);
if (isset($_SESSION)) {
debug_log_insert('data_query', __esc('SNMP Command is: %s', $command));
}
$return_var = 0;
exec($command, $snmp_value, $return_var);
$snmp_value = trim(implode(' ', $snmp_value));
// a non-zero exit signals snmpgetnext failure even when the output omits 'Timeout'
if (str_contains($snmp_value, 'Timeout') || $return_var != 0) {
$reason = str_contains($snmp_value, 'Timeout') ? 'Timeout' : "Exit Code $return_var, Output:'$snmp_value'";
cacti_log("WARNING: SNMP Error:'$reason', Device:'$hostname', OID:'$oid'", false, 'SNMP', POLLER_VERBOSITY_HIGH);
}
// strip out non-snmp data
$snmp_value = format_snmp_string($snmp_value, false, $value_output_format);
}
return $snmp_value;
}
function cacti_get_snmpv3_auth(mixed $auth_proto, mixed $auth_user, mixed $auth_pass, mixed $priv_proto, mixed $priv_pass, mixed $context, mixed $engineid) : string {
global $snmp_priv_protocols, $snmp_auth_protocols;
/* When the shared SNMPv3 credential cache is enabled, reuse the pre-assembled
* credential arguments for this tuple instead of rebuilding them each call.
* The cached value is the credential-only arg list; the per-call context and
* engine id are still appended below. */
if (snmp_auth_cache_enabled()) {
$cred = snmp_auth_cache_cred_lookup('', $auth_user, $auth_pass, $auth_proto, $priv_pass, $priv_proto);
if ($cred === null) {
$cred = snmp_build_v3_cred_args($auth_proto, $auth_user, $auth_pass, $priv_proto, $priv_pass);
}
$count = cacti_sizeof($cred);
if ($count == 0) {
return '';
}
$sec_details = '';
for ($i = 0; $i + 1 < $count; $i += 2) {
$sec_details .= ' ' . $cred[$i] . ' ' . snmp_escape_string($cred[$i + 1]);
}
if ($context != '') {
$sec_details .= ' -n ' . snmp_escape_string($context);
}
if ($engineid != '') {
$sec_details .= ' -e ' . snmp_escape_string($engineid);
}
return trim($sec_details);
}
$sec_details = '';
if ($priv_proto == '[None]' || $priv_pass == '') {
if ($auth_pass == '' || $auth_proto == '[None]') {
$sec_level = 'noAuthNoPriv';
} else {
if (!array_key_exists($auth_proto, $snmp_auth_protocols)) {
return '';
}
$sec_level = 'authNoPriv';
$sec_details = ' -a ' . snmp_escape_string($snmp_auth_protocols[$auth_proto]) . ' -A ' . snmp_escape_string($auth_pass);
}
$priv_proto = '';
$priv_pass = '';
} else {
if (!array_key_exists($auth_proto, $snmp_auth_protocols) || !array_key_exists($priv_proto, $snmp_priv_protocols)) {
return '';
}
$sec_level = 'authPriv';
$sec_details = ' -a ' . snmp_escape_string($snmp_auth_protocols[$auth_proto]) . ' -A ' . snmp_escape_string($auth_pass);
$priv_proto = $snmp_priv_protocols[$priv_proto];
$priv_pass = '-X ' . snmp_escape_string($priv_pass) . ' -x ' . snmp_escape_string($priv_proto);
}
if ($context != '') {
$context = '-n ' . snmp_escape_string($context);
} else {
$context = '';
}
if ($engineid != '') {
$engineid = '-e ' . snmp_escape_string($engineid);
} else {
$engineid = '';
}
return trim('-u ' . snmp_escape_string($auth_user) .
' -l ' . snmp_escape_string($sec_level) .
' ' . $sec_details .
' ' . $priv_pass .
' ' . $context .
' ' . $engineid);
}
/**
* cacti_snmp_session_from_host - build an SNMP session from a device row.
*
* cacti_snmp_session() takes fifteen positional arguments. Callers across the
* poller, data queries, and automation each spelled out the same mapping from
* a $host row, which is where SNMPv3 credential handling drifted between them.
* This assembles the arguments once. Missing keys fall back to the same
* defaults cacti_snmp_session() already applies, so a partial row behaves as
* the explicit-argument calls did.
*
* @param array $host A device row with the snmp_* and ping_retries columns.
* @param mixed $bulk_walk_size Optional bulk walk size override.
*
* @return mixed The SNMP session object, or false on failure.
*/
function cacti_snmp_session_from_host(array $host, mixed $bulk_walk_size = 10) : mixed {
return cacti_snmp_session(
$host['hostname'] ?? '',
$host['snmp_community'] ?? '',
$host['snmp_version'] ?? '',
$host['snmp_username'] ?? '',
$host['snmp_password'] ?? '',
$host['snmp_auth_protocol'] ?? '',
$host['snmp_priv_passphrase'] ?? '',
$host['snmp_priv_protocol'] ?? '',
$host['snmp_context'] ?? '',
$host['snmp_engine_id'] ?? '',
$host['snmp_port'] ?? 161,
$host['snmp_timeout'] ?? 500,
$host['ping_retries'] ?? 0,
$host['max_oids'] ?? 10,
$bulk_walk_size
);
}
/**
* Calls a native SNMP session method and captures its suppressed warning.
*
* Some PHP SNMP failures emit their only useful diagnostic as a warning while
* leaving the session error number and message empty.
*
* @param object $session Native SNMP session wrapper.
* @param string $method Native SNMP method name.
* @param array $args Method arguments.
* @param string $warning Captured warning message.
* @param callable|false|null $fallback_handler Previous handler override for isolated callers and tests.
*
* @return mixed Native SNMP method result.
*/
function cacti_snmp_session_call(object $session, string $method, array $args, string &$warning, callable|false|null $fallback_handler = null) : mixed {
$warning = '';
$previous_handler = set_error_handler(function (int $level, string $message, string $file = '', int $line = 0, array $context = []) use (&$warning, &$previous_handler) : bool {
if (($level & (E_WARNING | E_USER_WARNING)) !== 0) {
if ($warning === '') {
$warning = $message;
}
return true;
}
if (is_callable($previous_handler)) {
return (bool) call_user_func($previous_handler, $level, $message, $file, $line, $context);
}
return false;
});
if ($fallback_handler !== null) {
$previous_handler = $fallback_handler;
}
try {
return @call_user_func_array([$session, $method], $args);
} finally {
restore_error_handler();
}
}
/**
* Logs the error reported by a native SNMP session operation.
*
* @param object $session Native SNMP session wrapper.
* @param array $info Session connection metadata.
* @param string|array $oid OID or OID list used by the failed operation.
* @param string $warning Warning captured while calling the operation.
*
* @return void
*/
function cacti_snmp_log_session_error(object $session, array $info, string|array $oid, string $warning = '') : void {
$error_number = $session->getErrno();
/* the native SNMP class constant is unavailable without php-snmp; its value is
stable and the binary session never reports a timeout errno */
if ($error_number == (class_exists('SNMP') ? \SNMP::ERRNO_TIMEOUT : 2)) {
$error = 'Timeout (' . round($info['timeout'] / 1000, 0) . ' ms)';
} else {
$error = trim((string) $session->getError());
if ($error === '') {
$error = trim($warning);
}
if ($error === '') {
$error = 'Error Number ' . $error_number;
}
}
$error = str_replace(["\r", "\n"], ' ', $error);
$oid = is_array($oid) ? implode(',', $oid) : $oid;
cacti_log("WARNING: SNMP Error:'$error', Device:'" . $info['hostname'] . "', OID:'$oid'", false, 'SNMP', POLLER_VERBOSITY_HIGH);
}
function cacti_snmp_session_walk(object $session, mixed $oid, bool $dummy = false, mixed $max_repetitions = null,
mixed $non_repeaters = null, int $value_output_format = SNMP_STRING_OUTPUT_GUESS) : mixed {
$info = $session->info;
$out = [];
if (is_array($oid) && cacti_sizeof($oid) == 0) {
cacti_log('Empty OID!', false);
return $out;
}
if (is_array($oid)) {
foreach ($oid as $index => $o) {
$oid[$index] = trim($o);
}
} else {
$oid = trim($oid);
}
$session->value_output_format = $value_output_format;
if ($non_repeaters === null) {
$non_repeaters = 0;
}
if ($max_repetitions === null) {
$max_repetitions = $session->bulk_walk_size;
}
if ($max_repetitions <= 0) {
$max_repetitions = 10;
}
$warning = '';
try {
$out = cacti_snmp_session_call($session, 'walk', [$oid, false, $max_repetitions, $non_repeaters], $warning);
} catch (Exception $e) {
$out = false;
if ($warning === '') {
$warning = $e->getMessage();
}
}
if ($out === false) {
if ($oid == '.1.3.6.1.2.1.47.1.1.1.1.2' ||
$oid == '.1.3.6.1.4.1.9.9.68.1.2.2.1.2' ||
$oid == '.1.3.6.1.4.1.9.9.46.1.6.1.1.5' ||
$oid == '.1.3.6.1.4.1.9.9.46.1.6.1.1.14' ||
$oid == '.1.3.6.1.4.1.9.9.23.1.2.1.1.6') {
// do nothing
} else {
cacti_snmp_log_session_error($session, $info, $oid, $warning);
}
return [];
}
if (cacti_sizeof($out)) {
foreach ($out as $oid => $value) {
if (is_array($value)) {
foreach ($value as $index => $sval) {
$out[$oid][$index] = format_snmp_string($sval, false, $value_output_format);
}
} elseif ($out[$oid] !== false) {
$out[$oid] = format_snmp_string($value, false, $value_output_format);
}
}
} else {
$out = format_snmp_string($oid, false, $value_output_format);
}
return $out;
}
function cacti_snmp_session_get(object $session, mixed $oid, bool $strip_alpha = false) : mixed {
$info = $session->info;
$out = [];
if (is_array($oid) && cacti_sizeof($oid) == 0) {
cacti_log('Empty OID!', false);
return $out;
}
if (is_array($oid)) {
foreach ($oid as $index => $o) {
$oid[$index] = trim($o);
}
} else {
$oid = trim($oid);
}
$warning = '';
try {
$out = cacti_snmp_session_call($session, 'get', [$oid], $warning);
} catch (Exception $e) {
$out = false;
if ($warning === '') {
$warning = $e->getMessage();
}
}
if (is_array($oid)) {
$oid = implode(',', $oid);
}
if ($out === false) {
cacti_snmp_log_session_error($session, $info, $oid, $warning);
return false;
}
if (is_array($out)) {
foreach ($out as $oid => $value) {
$out[$oid] = format_snmp_string($value, false, SNMP_STRING_OUTPUT_GUESS, $strip_alpha);
}
} else {
$out = format_snmp_string($out, false, SNMP_STRING_OUTPUT_GUESS, $strip_alpha);
}
return $out;
}
function cacti_snmp_session_getnext(object $session, mixed $oid) : mixed {
$info = $session->info;
$out = [];
if (is_array($oid) && cacti_sizeof($oid) == 0) {
cacti_log('Empty OID!', false);
return $out;
}
if (is_array($oid)) {
foreach ($oid as $index => $o) {
$oid[$index] = trim($o);
}
} else {
$oid = trim($oid);
}
$warning = '';
try {
$out = cacti_snmp_session_call($session, 'getnext', [$oid], $warning);
} catch (Exception $e) {
$out = false;
if ($warning === '') {
$warning = $e->getMessage();
}
}
if (is_array($oid)) {
$oid = implode(',', $oid);
}
if ($out === false) {
cacti_snmp_log_session_error($session, $info, $oid, $warning);
return false;
}
if (is_array($out)) {
foreach ($out as $oid => $value) {
$out[$oid] = format_snmp_string($value, false);
}
} else {
$out = format_snmp_string($out, false);
}
return $out;
}
function cacti_snmp_validate_oid(string $oid) : bool {
$oid = ltrim($oid, '.');
if ($oid === '') {
return false;
}
$validate = array_map('is_numeric', explode('.', $oid));
return !in_array(false, $validate, true);
}
function cacti_snmp_walk(string $hostname, mixed $community, string $oid, mixed $version, mixed $auth_user = '', mixed $auth_pass = '',
mixed $auth_proto = '', mixed $priv_pass = '', mixed $priv_proto = '', mixed $context = '',
mixed $port = 161, mixed $timeout_ms = 500, mixed $retries = 0, mixed $bulk_walk_size = 10, mixed $environ = 'SNMP',
mixed $engineid = '', int $value_output_format = SNMP_STRING_OUTPUT_GUESS) : array {
global $banned_snmp_strings, $snmp_error;
$snmp_error = '';
$snmp_oid_included = true;
$snmp_auth = '';
$snmp_array = [];
$temp_array = [];
if (!cacti_snmp_options_sanitize($version, $community, $port, $timeout_ms, $retries, $bulk_walk_size)) {
return $snmp_array;
}
$path_snmpbulkwalk = read_config_option('path_snmpbulkwalk');
if (snmp_get_method('walk', $version, $context, $engineid, $value_output_format) == SNMP_METHOD_PHP) {
/* make sure snmp* is verbose so we can see what types of data
we are getting back */
$timeout_us = (int) ($timeout_ms * 1000);
// force php to return numeric oid's
cacti_oid_numeric_format();
if (function_exists('snmprealwalk')) {
$snmp_oid_included = false;
}
snmp_set_quick_print(false);
if ($version == '1') {
$temp_array = snmprealwalk($hostname . ':' . $port, $community, $oid, $timeout_us, $retries);
} elseif ($version == 2) {
$temp_array = snmp2_real_walk($hostname . ':' . $port, $community, $oid, $timeout_us, $retries);
}
// check for bad entries
if ($temp_array !== false && cacti_sizeof($temp_array)) {
foreach ($temp_array as $key => $value) {
foreach ($banned_snmp_strings as $item) {
if (strstr($value, $item) != '') {
unset($temp_array[$key]);
continue 2;
}
}
}
$o = 0;
for (reset($temp_array); $i = key($temp_array); next($temp_array)) {
if ($temp_array[$i] != 'NULL') {
$snmp_array[$o]['oid'] = preg_replace('/^\./', '', $i);
$snmp_array[$o]['value'] = format_snmp_string($temp_array[$i], $snmp_oid_included, $value_output_format);
}
$o++;
}
}
} else {
// ucd/net snmp want the timeout in seconds
$timeout_s = (int) ceil($timeout_ms / 1000);
$hostname = cacti_format_ipv6_colon($hostname);
if ($version == '1') {
$snmp_auth = '-c ' . snmp_escape_string($community); // v1/v2 - community string
} elseif ($version == '2') {
$snmp_auth = '-c ' . snmp_escape_string($community); // v1/v2 - community string
$version = '2c'; // ucd/net snmp prefers this over '2'
} elseif ($version == '3') {
$snmp_auth = cacti_get_snmpv3_auth($auth_proto, $auth_user, $auth_pass, $priv_proto, $priv_pass, $context, $engineid);
}
// cacti_get_snmpv3_auth() returns '' for an unknown protocol. Without this
// the walk builds a credential-less snmpwalk -v 3 and fails with no log
// line, unlike cacti_snmp_get/get_raw/getnext which all bail out here.
if (empty($snmp_auth)) {
cacti_log("WARNING: SNMP Error:'Missing credentials', Device:'$hostname', OID:'$oid'", false, 'SNMP', POLLER_VERBOSITY_HIGH);
return [];
}
if (read_config_option('oid_increasing_check_disable') == 'on') {
$oidCheck = '-Cc';
} else {
$oidCheck = '';
}
$return_code = 0;
if (file_exists($path_snmpbulkwalk) && ($version > 1) && ($bulk_walk_size > 1)) {
$command = cacti_escapeshellcmd($path_snmpbulkwalk) .
' -O QnU' . ($value_output_format == SNMP_STRING_OUTPUT_HEX ? 'x ' : ' ') . $snmp_auth .
' -v ' . $version .
' -t ' . $timeout_s .
' -r ' . $retries .
' -Cr' . $bulk_walk_size .
' ' . $oidCheck . ' ' .
cacti_escapeshellarg_cmd($hostname, true, true) . ':' . $port . ' ' .
cacti_escapeshellarg($oid);
if (isset($_SESSION)) {
debug_log_insert('data_query', __esc('SNMP Command is: %s', $command));
}
$temp_array = exec_into_array($command, $return_code);
} else {
$command = cacti_escapeshellcmd(read_config_option('path_snmpwalk')) .
' -O QnU' . ($value_output_format == SNMP_STRING_OUTPUT_HEX ? 'x ' : ' ') . $snmp_auth .
' -v ' . $version .
' -t ' . $timeout_s .
' -r ' . $retries .
' ' . $oidCheck . ' ' .
' ' . cacti_escapeshellarg_cmd($hostname, true, true) . ':' . $port .
' ' . cacti_escapeshellarg($oid);
if (isset($_SESSION)) {
debug_log_insert('data_query', __esc('SNMP Command is: %s', $command));
}
$temp_array = exec_into_array($command, $return_code);
}
// net-snmp reports a timeout or an oversized response on stderr, which
// exec() does not capture, so the walk output can never contain either.
// Matching them against stdout only ever matched device data such as an
// ifAlias of 'Timeout Monitor', and discarded the whole walk. The exit
// code is the signal that actually distinguishes a failed walk.
if ($return_code != 0) {
cacti_log("WARNING: SNMP Error:'Exit Code $return_code', Device:'$hostname', OID:'$oid'", false, 'SNMP', POLLER_VERBOSITY_HIGH);
return [];
}
// check for bad entries
if (cacti_sizeof($temp_array)) {
foreach ($temp_array as $key => $value) {
foreach ($banned_snmp_strings as $item) {
if (strstr($value, $item) != '') {
unset($temp_array[$key]);
continue 2;
}
}
}
/**
* Using this technique to catch multi-line
* snmpwalk responses from net-snmp. This happens
* usually on sysDescr on Cisco devices.
*/
$i = 0;
foreach ($temp_array as $value) {
if (preg_match('/(.*) =.*/', $value)) {
$parts = explode('=', $value, 2);
$t_oid = trim($parts[0]);
$t_value = $parts[1];
if (!cacti_snmp_validate_oid($t_oid)) {
cacti_log(sprintf('WARNING: SNMP Agent exploit attempted on SNMP agent from host ip: %s with oid: %s', $hostname, $t_oid), false, 'SECURITY');
continue;
}
$snmp_array[$i]['oid'] = $t_oid;
$snmp_array[$i]['value'] = $t_value;
$i++;
} else {
$snmp_array[$i - 1]['value'] .= $value;
}
}
}
}
/**
* replay the array to escape value data in case of a multi-line exploit
*/
if (cacti_sizeof($snmp_array)) {
foreach ($snmp_array as $index => $data) {
$snmp_array[$index]['value'] = format_snmp_string($data['value'], false, $value_output_format);
}
}
return $snmp_array;
}
function format_snmp_string(string $string, bool $snmp_oid_included, int $value_output_format = SNMP_STRING_OUTPUT_GUESS, bool $strip_alpha = false) : string {
global $banned_snmp_strings;
$string = preg_replace(REGEXP_SNMP_TRIM, '', trim($string));
if ($snmp_oid_included) {
// strip off all leading junk (the oid and stuff)
$string_array = explode('=', (string) $string, 2);
if (cacti_sizeof($string_array) == 1) {
// trim excess first
$string = trim((string) $string);
} elseif ((str_starts_with((string) $string, '.')) || (str_contains((string) $string, '::'))) {
// drop the OID from the array
array_shift($string_array);
$string = trim(implode('=', $string_array));
} else {
$string = trim(implode('=', $string_array));
}
} else {
$string = trim((string) $string);
}
// remove quotes and extraneous data
$string = trim($string, " \n\r\v\"'");
// return the easiest value
if ($string == '') {
return $string;
}
// now check for the second most obvious
if (is_numeric($string)) {
return $string;
}
// remove ALL quotes, and other special delimiters
$string = str_replace(['"', "'", '>', '<', '\\', "\n", "\r"], '', $string);
// account for invalid MIB files
if (str_contains($string, 'Wrong Type')) {
$string = strrev($string);
if ($position = strpos($string, ':')) {
$string = trim(strrev(substr($string, 0, $position)));
} else {
$string = trim(strrev($string));
}
}
// Remove invalid chars, if the string output is to be numeric
if ($strip_alpha && $value_output_format == SNMP_STRING_OUTPUT_GUESS) {
$string = trim(str_ireplace('hex:', '', $string));
$len = strlen($string);
$pos = $len - 1;
while ($pos > 0) {
$value = ord($string[$pos]);
if (($value < 48 || $value > 57) && $value != 32) {
$string[$pos] = ' ';
} else {
break;
}
$pos--;
}
$string = trim($string);
$len = strlen($string);
$pos = 0;
while ($pos < $len) {
$value = ord($string[$pos]);
if (($value < 48 || $value > 57) && $value != 32) {
$string[$pos] = ' ';
} else {
break;
}
$pos++;
}
$string = trim($string);
if ($string == '') {
return 'U';
}
}
// Remove non-printable characters, allow UTF-8
if ($value_output_format == SNMP_STRING_OUTPUT_GUESS) {
$string = preg_replace('/[^[:print:]\r\n]/', '', $string);
}
// Trim the string of trailing and leading spaces
$string = trim((string) $string);
// convert hex strings to numeric values
if (is_hex_string($string)) {
/* the is_hex_string() function will remove the hex:
* and hex-string: from the passed value
*/
$output = '';
$parts = explode(' ', $string);
if (cacti_sizeof($parts) == 4) {
$ip_address = '';
// convert the hex string into an ascii string
foreach ($parts as $part) {
$decimal = hexdec($part);
$ip_address .= ($ip_address != '' ? '.' : '') . $decimal;
$output .= chr($decimal);
}
if (is_ipaddress($ip_address)) {
$string = $ip_address;
} else {
$string = $output;
}
// hex string is mac-address
} elseif (cacti_sizeof($parts) == 6) {
// convert the hex string into an ascii string
foreach ($parts as $part) {
$output .= ($output != '' ? ':' : '');
if ($part == '00') {
$output .= '00';
} else {
$output .= str_pad($part, 2, '0', STR_PAD_LEFT);
}
}
$string = $output;
}
} elseif (str_starts_with(cacti_strtolower($string), 'hex:')) {
// strip off the 'Hex:'
$string = trim(str_ireplace('hex:', '', $string));
// normalize some forms
$output = '';
$string = str_replace([' ', '-', '.'], ':', $string);
$parts = explode(':', $string);
if (!is_mac_address($string)) {
// convert the hex string into an ascii string
foreach ($parts as $part) {
$output .= ($output != '' ? ':' : '');
if ($part == '00') {
$output .= '00';
} else {
$output .= str_pad($part, 2, '0', STR_PAD_LEFT);
}
}
if (is_numeric($output)) {
$string = number_format((float) $output, 0, '', '');
} else {
$string = $output;
}
}
} elseif (preg_match('/Timeticks:\s\((\d+)\)\s/', $string, $matches)) {
$string = $matches[1];
}
foreach ($banned_snmp_strings as $item) {
if (str_contains($string, $item)) {
$string = '';
break;
}
}
return $string;
}
/**
* Escapes an SNMP command argument for the active server operating system.
*
* @param string $string Argument to escape.
* @param string $server_os Server operating system identifier.
*
* @return string Escaped command argument.
*/
function snmp_escape_string(string $string, string $server_os = CACTI_SERVER_OS) : string {
if ($server_os == 'win32') {
/* GHSA-rjvj-r52f-8v5q: cmd.exe ignores the \" escape and toggles
* quoting on every ", so wrapping cannot neutralize & | ^ < > ( ).
* SNMP values never legitimately contain these, so strip them. */
$string = str_replace(['"', '&', '|', '^', '<', '>', '(', ')'], '', $string);
}
return cacti_escapeshellarg($string);
}
/**
* Selects the native PHP extension or command-line SNMP implementation.
*
* @param string $type SNMP operation type.
* @param mixed $version SNMP protocol version.
* @param mixed $context SNMPv3 context.
* @param mixed $engineid SNMPv3 engine identifier.
* @param int $value_output_format Requested output format.
* @param mixed $auth_proto SNMPv3 authentication protocol token.
* @param mixed $priv_proto SNMPv3 privacy protocol token.
* @param bool $php_snmp Whether the PHP SNMP extension is available.
*
* @return int One of the `SNMP_METHOD_*` constants.
*/
function snmp_get_method(string $type = 'walk', mixed $version = 1, mixed $context = '', mixed $engineid = '',
int $value_output_format = SNMP_STRING_OUTPUT_GUESS, bool $php_snmp = CACTI_PHP_SNMP, mixed $auth_proto = '',
mixed $priv_proto = '') : int {
if (!$php_snmp) {
return SNMP_METHOD_BINARY;
}
if ($value_output_format == SNMP_STRING_OUTPUT_HEX) {
return SNMP_METHOD_BINARY;
}
if ($type == 'walk' && file_exists(read_config_option('path_snmpbulkwalk'))) {
return SNMP_METHOD_BINARY;
}
/* SNMPv3 get/getnext: prefer the procedural snmp3_*() calls (they reach
* libnetsnmp directly and avoid a per-call process spawn), but only when the
* running PHP can actually service the request. Only get/getnext have a
* procedural equivalent here (the native walk branch implements v1/v2 only),
* so every other v3 operation stays on the Net-SNMP binary. The procedural API
* has no context or engine-id parameters, and ext-snmp before PHP 8.6 rejects
* the SHA-224/SHA-384 auth and AES-192/256[C] privacy tokens with a ValueError
* (SHA256/SHA512 + DES/AES/AES128 are accepted from the 8.3 floor), so those
* requests fall back to the binary too. */
if ($version == 3) {
if ($type != 'get' && $type != 'getnext') {
return SNMP_METHOD_BINARY;
}
// @codeCoverageIgnoreStart ext-snmp always provides snmp3_get()
if (!function_exists('snmp3_get')) {
return SNMP_METHOD_BINARY;
}
// @codeCoverageIgnoreEnd
if ($context != '' || $engineid != '' || !snmp_php_v3_protocols_supported($auth_proto, $priv_proto)) {
return SNMP_METHOD_BINARY;
}
return SNMP_METHOD_PHP;
}
if (function_exists('snmpget') && $version == 1) {
return SNMP_METHOD_PHP;
}
if (function_exists('snmp2_get') && $version == 2) {
return SNMP_METHOD_PHP;
} else {
return SNMP_METHOD_BINARY;
}
}
function cacti_snmp_options_sanitize(mixed $version, mixed $community, mixed &$port, mixed &$timeout, mixed &$retries, mixed &$max_oids) : bool {
// determine default retries
if ($retries == 0 || !is_numeric($retries)) {
$retries = intval(read_config_option('snmp_retries'));
if (empty($retries)) {
$retries = 3;
}
}
$version = intval($version);
// determine default max_oids
if ($max_oids == 0 || !is_numeric($max_oids)) {
$max_oids = intval(read_config_option('max_get_size'));
if (empty($max_oids)) {
$max_oids = 10;
}
}
// determine default timeout
if ($timeout == 0 || !is_numeric($timeout)) {
$timeout = intval(read_config_option('snmp_timeout'));
if (empty($timeout)) {
$timeout = 500;
}
}
// determine default port, and force it to an integer. Every net-snmp exec
// path interpolates $port raw into the command line (hostname:port); an int
// can never carry shell metacharacters, so this holds even if a caller ever
// passes a request-derived port rather than the mediumint column value.
if (empty($port) || !is_numeric($port)) {
$port = 161;
} else {
$port = (int) $port;
}
// do not attempt to poll invalid combinations
if ($version == 0 || ($community == '' && $version != 3)) {
return false;
}
return true;
}
/**
* Report whether the legacy MD5 authentication and DES privacy SNMPv3
* algorithms are offered to operators. Both are weak and are absent from
* hardened (FIPS) PHP and Net-SNMP builds, so the setting lets an operator drop
* them from the Device and Automation SNMP Option pickers.
*
* @return bool True when MD5/DES may be selected, false when they are disabled.
*/
function snmp_md5_des_enabled() {
return read_config_option('snmp_md5_des_enabled') == 'on';
}
/**
* Return the SNMPv3 authentication protocol choices for a form picker, dropping
* the legacy MD5 entry when it has been disabled in Settings. An existing MD5
* selection ($current) is retained so editing a device/preset that already uses
* it does not silently reset the field to [None] on save.
*
* @param string $current The currently stored auth protocol for this form.
*
* @return array Map of protocol key => display label.
*/
function snmp_auth_protocol_options($current = '') {
global $snmp_auth_protocols;
$protocols = $snmp_auth_protocols;
if (!snmp_md5_des_enabled() && $current !== 'MD5') {
unset($protocols['MD5']);
}
return $protocols;
}
/**
* Return the SNMPv3 privacy protocol choices for a form picker, dropping the
* legacy DES entry when it has been disabled in Settings. An existing DES
* selection ($current) is retained so editing a device/preset that already uses
* it does not silently reset the field to [None] on save.
*
* @param string $current The currently stored privacy protocol for this form.
*
* @return array Map of protocol key => display label.
*/
function snmp_priv_protocol_options($current = '') {
global $snmp_priv_protocols;
$protocols = $snmp_priv_protocols;
if (!snmp_md5_des_enabled() && $current !== 'DES') {
unset($protocols['DES']);
}
return $protocols;
}
/**
* Normalize a Cacti SNMPv3 protocol token for the procedural php-snmp API, which
* passes it straight to libnetsnmp and expects the dash-less spelling (SHA256,
* AES256C) rather than the dashed form the Net-SNMP CLI accepts (SHA-256,
* AES-256-C). Used as part of Cacti's lib functionality.
*
* @param string $protocol Stored auth or priv protocol token.
*
* @return string The dash-less native token.
*/
function snmp_native_protocol($protocol) {
return str_replace('-', '', (string) $protocol);
}
/**
* Build the agent target for the procedural php-snmp calls. libnetsnmp needs the
* host bracketed once a non-default port is attached, and an IPv6 literal is
* always bracketed so the trailing :port is not read as another hextet. A target
* that already carries an explicit port (or a unix: socket, which has none) is
* left untouched; a transport-qualified target that omits the port still has the
* configured non-default port appended, matching the Net-SNMP binary path. Used
* as part of Cacti's lib functionality.
*
* @param string $hostname Device hostname or IP (may already carry a transport).
* @param mixed $port SNMP port.
*
* @return string The agent target, e.g. host, [2001:db8::1]:1161 or udp6:[host]:161.
*/
function snmp_format_agent($hostname, $port) {
$hostname = trim((string) $hostname);
// A unix: socket has no port; leave it untouched.
if (preg_match('/^unix:/i', $hostname)) {
return $hostname;
}
/* A udp/tcp transport target carries its own host[:port]. Keep an explicit
* port - [ipv6]:port or host:port - but append the configured non-default
* port when the target omits one, matching the Net-SNMP binary path. */
if (preg_match('/^(udp6?|tcp6?):/i', $hostname)) {
$has_port = preg_match('/\]:\d+$/', $hostname) ||
(strpos($hostname, ']') === false && preg_match('/:\d+$/', $hostname));
if ($has_port || (int) $port === 161) {
return $hostname;
}
return $hostname . ':' . $port;
}
/* Already bracketed (IPv6): keep an embedded port, but attach the configured
* one when the target is bracket-only, e.g. [2001:db8::1] -> [2001:db8::1]:1161. */
if (strpos($hostname, '[') !== false) {
return preg_match('/\]:\d+$/', $hostname) ? $hostname : $hostname . ':' . $port;
}
if (filter_var($hostname, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return '[' . $hostname . ']:' . $port;
}
if ((int) $port === 161) {
return $hostname;
}
/* only an IPv6 literal needs bracketing; an IPv4 or DNS target with a
non-default port is a plain host:port that net-snmp parses directly */
return $hostname . ':' . $port;
}
/**
* Whether the running PHP build's procedural snmp3_*() calls accept the given
* SNMPv3 auth and privacy protocol tokens. ext-snmp accepts MD5/SHA/SHA256/SHA512
* auth and DES/AES/AES128 privacy from PHP 8.2. It has no SHA-224/SHA-384 auth
* mapping at all (SNMP::setSecurity() only knows SHA/SHA256/SHA512), so those
* always use the Net-SNMP binary; the AES-192/256[C] privacy tokens were added in
* PHP 8.6. ext-snmp raises a ValueError for any token it cannot map, so those
* combinations must use the Net-SNMP binary.
*
* @param string $auth_proto Stored auth protocol token (e.g. SHA, SHA256).
* @param string $priv_proto Stored privacy protocol token (e.g. AES, AES256C).
*
* @return bool True when the procedural API accepts both tokens on this PHP.
*/
function snmp_php_v3_protocols_supported($auth_proto, $priv_proto) {
$auth = snmp_native_protocol($auth_proto);
$priv = snmp_native_protocol($priv_proto);
/* Allowlist of tokens the procedural snmp3_*() calls accept. MD5/SHA/SHA256/
* SHA512 auth and DES/AES/AES128 privacy are accepted from the PHP 8.2 floor.
* ext-snmp has no SHA-224/SHA-384 auth mapping at all, so those are never
* routed to snmp3_*() (they stay on the binary); the AES-192/256[C] privacy
* tokens were only added in PHP 8.6. An unknown token (automation SNMP protocol
* fields are stored without an allowlist) is never routed to snmp3_*(), which
* would otherwise raise an uncaught ValueError. */
$auth_ok = ['', '[None]', 'MD5', 'SHA', 'SHA256', 'SHA512'];
$priv_ok = ['', '[None]', 'DES', 'AES', 'AES128'];
// @codeCoverageIgnoreStart the AES-192/256 tokens are only accepted by ext-snmp on PHP 8.6+
if (PHP_VERSION_ID >= 80600) {
$priv_ok = array_merge($priv_ok, ['AES192', 'AES192C', 'AES256', 'AES256C']);
}
// @codeCoverageIgnoreEnd
if (!in_array($auth, $auth_ok, true) || !in_array($priv, $priv_ok, true)) {
return false;
}
/* SNMPv3 privacy requires authentication. A privacy protocol selected without
* an auth protocol is an invalid authPriv combination that would raise a
* ValueError in snmp3_*(), so route it to the binary instead. */
$auth_set = !in_array($auth, ['', '[None]'], true);
$priv_set = !in_array($priv, ['', '[None]'], true);
if ($priv_set && !$auth_set) {
return false;
}
return true;
}
/**
* The SNMP credential change token: a tiny opaque value in the settings table,
* bumped on device save and poller-cache flush whenever SNMP credentials may
* have changed. The poller compares it to the token stamped into the shared
* cache to decide whether a rebuild (and its DISTINCT scan) is needed, so normal
* polls never rescan. Seeded on first read so a fresh install builds once. Kept
* small so it always fits settings.value.
*
* @return string
*/
function snmp_cred_version(): string {
$version = read_config_option('snmp_cred_version');
if ($version === '' || $version === null || $version === false) {
$version = uniqid('', true);
set_config_option('snmp_cred_version', $version);
}
return (string) $version;
}
/**
* Return the process-wide shared SNMP authentication cache.
*
* poller.php builds a map of sha1(credential tuple) => pre-hardened SNMPv3
* credential arguments once per credential change and seals it into a
* cross-process cache. cmd.php and script_server.php decode it once at startup
* so in-flight SNMP calls reuse the pre-hardened arguments instead of rebuilding
* them on every request. The live hardening path remains as a fallback whenever
* the cache is absent.
*
* @return \Cacti\Cache\SharedCache
*/
function snmp_auth_cache(): \Cacti\Cache\SharedCache {
static $cache = null;
if ($cache === null) {
$cache = new \Cacti\Cache\SharedCache('snmp_auth', ['encrypted' => true]);
}
return $cache;
}
/**
* Canonical cache key: sha1 over the six credential columns, in the DISTINCT
* query column order, of the pre-hardened values.
*
* @param mixed $community The community.
* @param mixed $username The username.
* @param mixed $password The password.
* @param mixed $auth_proto The auth protocol.
* @param mixed $priv_pass The priv passphrase.
* @param mixed $priv_proto The priv protocol.
*
* @return string
*/
function snmp_auth_cache_key($community, $username, $password, $auth_proto, $priv_pass, $priv_proto): string {
return sha1(implode("\x1f", [
(string) $community, (string) $username, (string) $password,
(string) $auth_proto, (string) $priv_pass, (string) $priv_proto
]));
}
/**
* Build the credential-only portion of the SNMPv3 Net-SNMP argument vector
* (-u/-a/-A/-x/-X/-l), excluding the per-device context and engine id. This is
* the costly part of SNMPv3 argument hardening and the unit stored by the
* shared SNMP auth cache.
*
* @param mixed $auth_proto The auth protocol.
* @param mixed $auth_user The auth user.
* @param mixed $auth_pass The auth pass.
* @param mixed $priv_proto The priv protocol.
* @param mixed $priv_pass The priv pass.
*
* @return array Net-SNMP credential arguments, or array() when the protocols are invalid.
*/
function snmp_build_v3_cred_args($auth_proto, $auth_user, $auth_pass, $priv_proto, $priv_pass) {
global $snmp_priv_protocols, $snmp_auth_protocols;
$args = ['-u', (string) $auth_user];
if ($priv_proto == '[None]' || $priv_pass == '') {
if ($auth_pass == '' || $auth_proto == '[None]') {
$sec_level = 'noAuthNoPriv';
} else {
$sec_level = 'authNoPriv';
if (!isset($snmp_auth_protocols[$auth_proto])) {
return [];
}
$args = array_merge($args, ['-a', (string) $snmp_auth_protocols[$auth_proto], '-A', (string) $auth_pass]);
}
} else {
if (!isset($snmp_auth_protocols[$auth_proto], $snmp_priv_protocols[$priv_proto])) {
return [];
}
$sec_level = 'authPriv';
$args = array_merge($args, [
'-a', (string) $snmp_auth_protocols[$auth_proto], '-A', (string) $auth_pass,
'-x', (string) $snmp_priv_protocols[$priv_proto], '-X', (string) $priv_pass
]);
}
return array_merge($args, ['-l', $sec_level]);
}
/**
* Build the sha1(tuple) => pre-hardened SNMPv3 credential args map. v1/v2
* community hardening is trivial and resolved live, so only SNMPv3 tuples
* (those with a username) are stored.
*
* @param array $rows The credential rows.
*
* @return array
*/
function snmp_auth_cache_build_map(array $rows): array {
$map = [];
foreach ($rows as $row) {
$username = isset($row['snmp_username']) ? $row['snmp_username'] : '';
if ($username === '') {
continue;
}
$key = snmp_auth_cache_key(
$row['snmp_community'] ?? '', $username, $row['snmp_password'] ?? '',
$row['snmp_auth_protocol'] ?? '', $row['snmp_priv_passphrase'] ?? '', $row['snmp_priv_protocol'] ?? ''
);
if (isset($map[$key])) {
continue;
}
$map[$key] = snmp_build_v3_cred_args(
$row['snmp_auth_protocol'] ?? '', $username, $row['snmp_password'] ?? '',
$row['snmp_priv_protocol'] ?? '', $row['snmp_priv_passphrase'] ?? ''
);
}
return $map;
}
/**
* Distinct SNMPv3 credential tuples from host and poller_item. Only read when the
* credential version token has moved, so the DISTINCT scan runs on a credential
* change rather than on every poll. poller_item is included because a device can
* carry a per-data-source SNMP override (e.g. a second agent on another port)
* that the host row does not reflect. Only v3 rows are scanned because v1/v2
* community hardening is trivial and resolved live, so only v3 tuples are cached.
*
* @return array
*/
function snmp_auth_cache_rows(): array {
$hosts = db_fetch_assoc('SELECT DISTINCT snmp_community, snmp_username, snmp_password, snmp_auth_protocol, snmp_priv_passphrase, snmp_priv_protocol FROM host WHERE snmp_version = 3');
$items = db_fetch_assoc('SELECT DISTINCT snmp_community, snmp_username, snmp_password, snmp_auth_protocol, snmp_priv_passphrase, snmp_priv_protocol FROM poller_item WHERE snmp_version = 3');
return array_merge(is_array($hosts) ? $hosts : [], is_array($items) ? $items : []);
}
/**
* Build the SNMP auth map directly from the database (no cache involved).
*
* @return array
*/
function snmp_auth_cache_build(): array {
return snmp_auth_cache_build_map(snmp_auth_cache_rows());
}
/**
* Whether the shared SNMP credential cache is enabled (Console > Settings >
* Poller > Enable Credential Cache; disabled by default). When off, every call
* hardens its SNMPv3 arguments live.
*
* @return bool
*/
function snmp_auth_cache_enabled(): bool {
return read_config_option('snmp_credential_cache') == 'on';
}
/**
* Rebuild and reseal the shared SNMP auth cache, but only when the credential
* set has changed since the last build. Intended to be called once at
* poller.php startup.
*
* @return void
*/
function snmp_auth_cache_refresh(): void {
if (!snmp_auth_cache_enabled()) {
return;
}
$version = snmp_cred_version();
$cache = snmp_auth_cache();
// Nothing has changed since the cache was last built: skip the scan.
if ($cache->checksum() === $version) {
return;
}
$cache->store(snmp_auth_cache_build_map(snmp_auth_cache_rows()), $version);
}
/**
* Force a full rebuild of the shared SNMP auth cache from a fresh scan and
* reseal it at the current credential version. Unlike snmp_auth_cache_refresh()
* this skips the version check, so it can run out of band (poller maintenance)
* to sweep orphaned tuples that credential removals leave behind. Resealing at
* the current version means pollers keep using the cache with no startup rebuild.
*
* @return void
*/
function snmp_auth_cache_rebuild(): void {
if (!snmp_auth_cache_enabled()) {
return;
}
snmp_auth_cache()->store(snmp_auth_cache_build_map(snmp_auth_cache_rows()), snmp_cred_version());
}
/**
* Decode the shared SNMP auth cache into process memory exactly once. When no
* shared cache is available or populated, build a per-process copy from the
* database so lookups still succeed.
*
* @return void
*/
function snmp_auth_cache_load(): void {
if (!empty($GLOBALS['snmp_auth_cache_loaded'])) {
return;
}
$GLOBALS['snmp_auth_cache_loaded'] = true;
if (!snmp_auth_cache_enabled()) {
$GLOBALS['snmp_auth_cache_map'] = [];
return;
}
$data = snmp_auth_cache()->fetch();
if (!is_array($data)) {
$data = snmp_auth_cache_build();
}
$GLOBALS['snmp_auth_cache_map'] = $data;
}
/**
* Return the pre-hardened SNMPv3 credential args for a credential tuple, or null
* when the tuple is not cached (the caller then hardens live).
*
* @param mixed $community The community.
* @param mixed $username The username.
* @param mixed $password The password.
* @param mixed $auth_proto The auth protocol.
* @param mixed $priv_pass The priv passphrase.
* @param mixed $priv_proto The priv protocol.
*
* @return array|null
*/
function snmp_auth_cache_cred_lookup($community, $username, $password, $auth_proto, $priv_pass, $priv_proto): ?array {
if (!isset($GLOBALS['snmp_auth_cache_map'])) {
snmp_auth_cache_load();
}
$key = snmp_auth_cache_key($community, $username, $password, $auth_proto, $priv_pass, $priv_proto);
$map = $GLOBALS['snmp_auth_cache_map'] ?? [];
return (isset($map[$key]) && is_array($map[$key])) ? $map[$key] : null;
}
/**
* Fetch several OIDs in max_oids-sized batches through the procedural php-snmp
* API (one PDU per batch), which accepts an OID array and reaches libnetsnmp
* directly. This is the in-process counterpart to the SNMP-class session get and
* avoids a per-OID process spawn. Callers that must use the binary (hex output,
* or no ext-snmp) fall back to per-OID cacti_snmp_get(). Used as part of Cacti's
* lib functionality.
*
* @param string $hostname The hostname.
* @param mixed $community The community.
* @param array $oids The OIDs to fetch.
* @param mixed $version The version.
* @param mixed $auth_user The auth user.
* @param mixed $auth_pass The auth pass.
* @param mixed $auth_proto The auth protocol.
* @param mixed $priv_pass The priv passphrase.
* @param mixed $priv_proto The priv protocol.
* @param mixed $context The context.
* @param mixed $port The port.
* @param mixed $timeout_ms The timeout in milliseconds.
* @param mixed $retries The retries.
* @param mixed $max_oids The maximum OIDs per request.
* @param mixed $environ The environ.
* @param string $engineid The engine id.
* @param int $value_output_format The value output format.
*
* @return array Map of OID => formatted value ('U' on a per-OID failure).
*/
function cacti_snmp_get_multi($hostname, $community, $oids, $version, $auth_user = '', $auth_pass = '',
$auth_proto = '', $priv_pass = '', $priv_proto = '', $context = '',
$port = 161, $timeout_ms = 500, $retries = 0, $max_oids = 10, $environ = 'SNMP',
$engineid = '', $value_output_format = SNMP_STRING_OUTPUT_GUESS, ?callable $native_get = null) {
global $snmp_error;
$snmp_error = '';
if (!is_array($oids)) {
$oids = [$oids];
}
if (cacti_sizeof($oids) == 0) {
return [];
}
if (!cacti_snmp_options_sanitize($version, $community, $port, $timeout_ms, $retries, $max_oids)) {
return [];
}
/* The array-OID form only exists on the procedural ext-snmp path; anything
* that must use the binary (hex output, no ext-snmp) is served one OID at a
* time through the usual single get. */
if (snmp_get_method('get', $version, $context, $engineid, $value_output_format, auth_proto: $auth_proto, priv_proto: $priv_proto) != SNMP_METHOD_PHP || !function_exists('snmpget')) {
$results = [];
foreach ($oids as $oid) {
$results[$oid] = cacti_snmp_get($hostname, $community, $oid, $version, $auth_user, $auth_pass,
$auth_proto, $priv_pass, $priv_proto, $context, $port, $timeout_ms, $retries, $environ,
$engineid, $value_output_format);
}
return $results;
}
snmp_set_quick_print(false);
if (function_exists('snmp_set_enum_print')) {
snmp_set_enum_print(true);
}
$timeout_us = (int) ($timeout_ms * 1000);
$agent = snmp_format_agent($hostname, $port);
$auth_native = '';
$priv_native = '';
$sec_level = '';
if ($version == '3') {
if ($priv_proto == '[None]' || $priv_pass == '') {
$sec_level = ($auth_pass == '' || $auth_proto == '[None]') ? 'noAuthNoPriv' : 'authNoPriv';
$priv_proto = '';
} else {
$sec_level = 'authPriv';
}
$auth_native = snmp_native_protocol($auth_proto);
$priv_native = snmp_native_protocol($priv_proto);
}
$results = [];
foreach (array_chunk($oids, max(1, (int) $max_oids)) as $chunk) {
try {
if ($native_get !== null) {
$values = $native_get();
} elseif ($version == '1') {
$values = @snmpget($agent, $community, $chunk, $timeout_us, $retries);
} elseif ($version == '2') {
$values = @snmp2_get($agent, $community, $chunk, $timeout_us, $retries);
} else {
$values = @snmp3_get($agent, $auth_user, $sec_level, $auth_native, $auth_pass, $priv_native, $priv_pass, $chunk, $timeout_us, $retries);
}
} catch (\Throwable $e) {
$values = false;
$snmp_error = $e->getMessage();
}
if (!is_array($values)) {
foreach ($chunk as $oid) {
$results[$oid] = 'U';
}
continue;
}
/* php-snmp returns the values keyed by the requested OID; fall back to
* positional association when a key does not match verbatim. */
$positional = array_values($values);
$index = 0;
foreach ($chunk as $oid) {
if (array_key_exists($oid, $values)) {
$value = $values[$oid];
} elseif (isset($positional[$index])) {
$value = $positional[$index];
} else {
$value = false;
}
$results[$oid] = ($value === false) ? 'U' : format_snmp_string($value, false, $value_output_format);
$index++;
}
}
return $results;
}