From 8f5569e2fbd7f490913abcfea00cb8cb0db7a883 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 01:20:37 -0600 Subject: [PATCH 1/2] test: let the unit bootstrap answer db_fetch_assoc and stub the auth include Signed-off-by: Thomas Vincent --- tests/bootstrap-unit.php | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index 87a17ef..3c1c2f5 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -108,7 +108,13 @@ function db_execute_prepared($sql, $params = array()) { } if (!function_exists('db_fetch_assoc')) { - function db_fetch_assoc($sql) { + function db_fetch_assoc($sql, $log = true, $db_conn = false) { + foreach (($GLOBALS['__test_db_results']['db_fetch_assoc'] ?? array()) as $match => $result) { + if (strpos($sql, (string) $match) !== false) { + return $result; + } + } + return array(); } } @@ -249,6 +255,12 @@ function cacti_log($message, $also_print = false, $log_type = '', $level = 0) { } } +if (!function_exists('cacti_count')) { + function cacti_count($array) { + return is_array($array) ? count($array) : 0; + } +} + if (!function_exists('cacti_sizeof')) { function cacti_sizeof($array) { return is_array($array) ? count($array) : 0; @@ -373,6 +385,21 @@ function plugin_test_read_source($relative_file) { } } +/** + * Make require_once('include/auth.php') resolve to an empty stub, ahead of + * the real Cacti file that the working directory would otherwise supply. + */ +function npc_test_stub_cacti_includes() { + static $dir = null; + + if ($dir === null) { + $dir = sys_get_temp_dir() . '/npc-inc-' . uniqid(); + mkdir($dir . '/include', 0777, true); + file_put_contents($dir . '/include/auth.php', " Date: Sat, 24 Oct 2026 01:21:44 -0600 Subject: [PATCH 2/2] fix: match IPv6 addresses when finding an existing Cacti device Signed-off-by: Thomas Vincent --- CHANGELOG.md | 5 + controllers/sync.php | 152 ++++++++++++++- phpunit.xml | 1 + tests/Unit/SyncAddressMapTest.php | 300 ++++++++++++++++++++++++++++++ 4 files changed, 449 insertions(+), 9 deletions(-) create mode 100644 tests/Unit/SyncAddressMapTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e4afea3..97ddfea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ --- develop --- +* issue: Match the Cacti devices of a Nagios host import by IPv4 and IPv6 address. + The address map now holds every A and AAAA address of a device in one + canonical text form, accepts address literals as device host names without a + lookup, and compares the imported address in the same form. The cache file + keeps its layout; files written by earlier versions are rebuilt once * dev: Relocate the schema provisioning function `npc_setup_tables()` from `setup.php` into `includes/database.php` (loaded via `require_once`) to align with the fleet schema/includes convention; behavior is unchanged diff --git a/controllers/sync.php b/controllers/sync.php index c24b839..4f5373c 100644 --- a/controllers/sync.php +++ b/controllers/sync.php @@ -167,6 +167,22 @@ function listHostgroups() { return($this->jsonOutput($output)); } + /** + * The file holding the address map. It is a property so a test can point + * it at a temporary file. + * + * @var string + */ + var $addressCacheFile = '/tmp/npc_address_cache.php'; + + /** + * The DNS and system lookups, as callables so a test can replace them. + * + * @var callable + */ + var $dnsLookup = 'dns_get_record'; + var $nameLookup = 'gethostbyname'; + /** * checkHostExists * @@ -175,19 +191,24 @@ function listHostgroups() { * found the Cacti host ID is returned. * * The first time this method is called a cache of - * id to ip address mappings will be built. + * id to ip address mappings will be built. Every address of a host is + * stored, IPv4 and IPv6, in the canonical text form of + * normalizeAddress(). The file layout is the one earlier versions wrote; + * the added $cacheVersion makes older files rebuild, because they only + * hold the first IPv4 address of each device. * * @return int The Cacti host ID */ function checkHostExists($ip, $cache_id) { - $cacheFile = '/tmp/npc_address_cache.php'; + $cacheFile = $this->addressCacheFile; $buildCache = 0; + $cacheVersion = 1; if (file_exists($cacheFile)) { include($cacheFile); - if ($cacheKey != $cache_id) { + if ($cacheKey != $cache_id || $cacheVersion < 2) { $buildCache = 1; } } else { @@ -202,14 +223,17 @@ function checkHostExists($ip, $cache_id) { $string = "getHostnames(); for ($i = 0; $i < count($results); $i++) { - $address = gethostbyname($results[$i]['hostname']); - $string .= "'" . $address . "' => '" . $results[$i]['id'] . "',\n"; - $hosts[$address] = $results[$i]['id']; + foreach ($this->resolveAddresses($results[$i]['hostname']) as $address) { + $string .= "'" . $address . "' => '" . $results[$i]['id'] . "',\n"; + $hosts[$address] = $results[$i]['id']; + } } $string .= ");"; @@ -219,11 +243,121 @@ function checkHostExists($ip, $cache_id) { fclose($fh); } - if (isset($hosts[$ip])) { - return($hosts[$ip]); + foreach ($this->resolveAddresses($ip) as $address) { + if (isset($hosts[$address])) { + return($hosts[$address]); + } } return(null); } + /** + * normalizeAddress + * + * Returns the canonical text form of an IPv4 or IPv6 address, or null when + * the text is not an address. The same address written in different ways + * (2001:DB8::1, 2001:db8:0:0:0:0:0:1, [2001:db8::1], fe80::1%eth0) gives + * the same text, and an IPv4-mapped IPv6 address gives the IPv4 text. + * + * @param string $address + * @return string|null + */ + function normalizeAddress($address) { + $address = trim((string) $address); + + if (strlen($address) > 1 && $address[0] == '[' && substr($address, -1) == ']') { + $address = substr($address, 1, -1); + } + + /* A scope id names the interface, not the host. */ + $address = preg_replace('/%.*$/', '', $address); + + if (filter_var($address, FILTER_VALIDATE_IP) === false) { + return(null); + } + + $packed = inet_pton($address); + + if (strlen($packed) == 16 && strncmp($packed, "\0\0\0\0\0\0\0\0\0\0\xff\xff", 12) == 0) { + $packed = substr($packed, 12); + } + + return(inet_ntop($packed)); + } + + /** + * resolveAddresses + * + * Returns every address, in canonical form, that a host name or address + * stands for. An address is returned as it is without a lookup. A name is + * looked up for A and AAAA records, and for names the resolver does not + * return records for, the system lookup that earlier versions used. + * + * @param string $name host name or address + * @return array canonical addresses, empty when nothing resolves + */ + function resolveAddresses($name) { + $name = trim((string) $name); + + $literal = $this->normalizeAddress($name); + + if ($literal !== null) { + return(array($literal)); + } + + if ($name === '') { + return(array()); + } + + $found = array(); + + foreach ($this->dnsRecords($name) as $record) { + $address = $this->normalizeAddress(isset($record['ipv6']) ? $record['ipv6'] : (isset($record['ip']) ? $record['ip'] : '')); + + if ($address !== null && !in_array($address, $found, true)) { + $found[] = $address; + } + } + + if (!count($found)) { + $address = $this->normalizeAddress($this->legacyLookup($name)); + + if ($address !== null) { + $found[] = $address; + } + } + + return($found); + } + + /** + * dnsRecords + * + * The A and AAAA records of a name. A failed lookup gives an empty list. + * dns_get_record() reports a failed query as a warning, which the empty + * list already says. + * + * @param string $name + * @return array + */ + function dnsRecords($name) { + $records = @call_user_func($this->dnsLookup, $name, DNS_A | DNS_AAAA); + + return(is_array($records) ? $records : array()); + } + + /** + * legacyLookup + * + * The system resolver lookup earlier versions used. It returns the name + * unchanged when the name does not resolve. + * + * @param string $name + * @return string + */ + function legacyLookup($name) { + return(call_user_func($this->nameLookup, $name)); + } + } diff --git a/phpunit.xml b/phpunit.xml index bace346..8aa3f37 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -29,6 +29,7 @@ setup.php + controllers/sync.php diff --git a/tests/Unit/SyncAddressMapTest.php b/tests/Unit/SyncAddressMapTest.php new file mode 100644 index 0000000..49c9e2c --- /dev/null +++ b/tests/Unit/SyncAddressMapTest.php @@ -0,0 +1,300 @@ + list of A / AAAA records + * @param array $legacy name => address answered by the system lookup + */ +function npc_address_controller(array $dns = array(), array $legacy = array(), ?string $cache = null) { + $controller = new NpcSyncController(); + + $GLOBALS['npc_address_lookups'] = array('dns' => 0, 'name' => 0); + + $controller->dnsLookup = function ($name) use ($dns) { + $GLOBALS['npc_address_lookups']['dns']++; + + return $dns[$name] ?? false; + }; + $controller->nameLookup = function ($name) use ($legacy) { + $GLOBALS['npc_address_lookups']['name']++; + + return $legacy[$name] ?? $name; + }; + + if ($cache !== null) { + $controller->addressCacheFile = $cache; + } + + return $controller; +} + +function npc_address_cache_file() { + return sys_get_temp_dir() . '/npc-address-' . uniqid() . '.php'; +} + +/** Records of one name, as dns_get_record() returns them. */ +function npc_address_records(array $addresses) { + return array_map(function ($address) { + return strpos($address, ':') === false ? array('type' => 'A', 'ip' => $address) : array('type' => 'AAAA', 'ipv6' => $address); + }, $addresses); +} + +it('writes an address in one canonical form', function (?string $input, ?string $expected) { + expect(npc_address_controller()->normalizeAddress($input))->toBe($expected); +})->with(array( + 'IPv4' => array('192.0.2.10', '192.0.2.10'), + 'IPv4 with spaces around it' => array(' 192.0.2.10 ', '192.0.2.10'), + 'IPv6 compressed' => array('2001:db8::1', '2001:db8::1'), + 'IPv6 upper case' => array('2001:DB8::1', '2001:db8::1'), + 'IPv6 expanded' => array('2001:0db8:0000:0000:0000:0000:0000:0001', '2001:db8::1'), + 'IPv6 partly expanded' => array('2001:db8:0:0:0:0:0:1', '2001:db8::1'), + 'IPv6 in brackets' => array('[2001:DB8::1]', '2001:db8::1'), + 'IPv6 with a scope id' => array('fe80::1%eth0', 'fe80::1'), + 'IPv6 loopback' => array('0:0:0:0:0:0:0:1', '::1'), + 'IPv6 unspecified' => array('0000:0000:0000:0000:0000:0000:0000:0000', '::'), + 'IPv4 mapped IPv6' => array('::FFFF:192.0.2.10', '192.0.2.10'), + 'IPv4 mapped IPv6 in hex' => array('::ffff:c000:20a', '192.0.2.10'), + 'host name' => array('web1.example', null), + 'empty string' => array('', null), + 'null' => array(null, null), + 'octet out of range' => array('256.1.1.1', null), + 'IPv4 with a leading zero' => array('192.000.002.010', null), + 'IPv6 with a bad digit' => array('2001:db8::g', null), + 'IPv6 with too many groups' => array('1:2:3:4:5:6:7:8:9', null), + 'brackets around a name' => array('[web1.example]', null), + 'only brackets' => array('[]', null), +)); + +it('returns an address as it is without a lookup', function (string $input, string $expected) { + $controller = npc_address_controller(); + + expect($controller->resolveAddresses($input))->toBe(array($expected)); + expect($GLOBALS['npc_address_lookups'])->toBe(array('dns' => 0, 'name' => 0)); +})->with(array( + 'IPv4' => array('192.0.2.10', '192.0.2.10'), + 'IPv6' => array('2001:DB8::1', '2001:db8::1'), + 'bracketed IPv6' => array('[2001:db8:0:0:0:0:0:1]', '2001:db8::1'), +)); + +it('returns every address of a name, IPv4 and IPv6, once', function () { + $controller = npc_address_controller(array('web1.example' => npc_address_records(array('192.0.2.10', '2001:DB8::10', '192.0.2.11', '2001:db8:0:0:0:0:0:10', '2001:db8::11')))); + + expect($controller->resolveAddresses('web1.example'))->toBe(array('192.0.2.10', '2001:db8::10', '192.0.2.11', '2001:db8::11')); + expect($GLOBALS['npc_address_lookups'])->toBe(array('dns' => 1, 'name' => 0)); +}); + +it('resolves a name that only has one kind of record', function (array $records, array $expected) { + $controller = npc_address_controller(array('web1.example' => npc_address_records($records))); + + expect($controller->resolveAddresses('web1.example'))->toBe($expected); +})->with(array( + 'IPv4 only' => array(array('192.0.2.10'), array('192.0.2.10')), + 'IPv6 only' => array(array('2001:db8::10'), array('2001:db8::10')), +)); + +it('ignores records that carry no usable address', function () { + $controller = npc_address_controller(array('web1.example' => array(array('type' => 'A'), array('type' => 'AAAA', 'ipv6' => 'not an address'), array('type' => 'A', 'ip' => '192.0.2.10')))); + + expect($controller->resolveAddresses('web1.example'))->toBe(array('192.0.2.10')); +}); + +it('falls back to the system lookup when DNS gives no records', function (array $dns) { + $controller = npc_address_controller($dns, array('old.example' => '192.0.2.50')); + + expect($controller->resolveAddresses('old.example'))->toBe(array('192.0.2.50')); + expect($GLOBALS['npc_address_lookups']['name'])->toBe(1); +})->with(array( + 'the query failed' => array(array()), + 'no records returned' => array(array('old.example' => array())), +)); + +it('returns nothing for a name that does not resolve', function (string $name) { + $controller = npc_address_controller(); + + expect($controller->resolveAddresses($name))->toBe(array()); +})->with(array('gone.example', '', ' ')); + +it('does not send a query for an empty name', function () { + $controller = npc_address_controller(); + + $controller->resolveAddresses(''); + + expect($GLOBALS['npc_address_lookups'])->toBe(array('dns' => 0, 'name' => 0)); +}); + +it('passes the record types to the DNS lookup', function () { + $seen = array(); + $controller = new NpcSyncController(); + + $controller->dnsLookup = function ($name, $type) use (&$seen) { + $seen = array($name, $type); + + return array(); + }; + + expect($controller->dnsRecords('web1.example'))->toBe(array()); + expect($seen)->toBe(array('web1.example', DNS_A | DNS_AAAA)); +}); + +it('uses the system lookup that earlier versions used', function () { + $controller = new NpcSyncController(); + $controller->nameLookup = function ($name) { + return 'looked-up:' . $name; + }; + + expect($controller->legacyLookup('web1.example'))->toBe('looked-up:web1.example'); +}); + +/** Cacti devices as getHostnames() returns them. */ +function npc_address_devices() { + $GLOBALS['__test_db_results']['db_fetch_assoc']['FROM host'] = array( + array('id' => '1', 'hostname' => 'web1.example'), + array('id' => '2', 'hostname' => 'v6only.example'), + array('id' => '3', 'hostname' => '198.51.100.7'), + array('id' => '4', 'hostname' => '[2001:DB8:0:0:0:0:0:99]'), + array('id' => '5', 'hostname' => 'gone.example'), + array('id' => '6', 'hostname' => 'old.example'), + ); + + return array( + 'web1.example' => npc_address_records(array('192.0.2.10', '2001:db8::10')), + 'v6only.example' => npc_address_records(array('2001:db8::20')), + ); +} + +it('finds the Cacti device that owns an address', function (string $ip, ?string $expected) { + $cache = npc_address_cache_file(); + $controller = npc_address_controller(npc_address_devices(), array('old.example' => '192.0.2.60'), $cache); + + try { + expect($controller->checkHostExists($ip, 'k1'))->toBe($expected); + } finally { + unlink($cache); + } +})->with(array( + 'IPv4 of a device with both kinds' => array('192.0.2.10', '1'), + 'IPv6 compressed' => array('2001:db8::10', '1'), + 'IPv6 mixed case' => array('2001:DB8::10', '1'), + 'IPv6 expanded' => array('2001:0db8:0000:0000:0000:0000:0000:0010', '1'), + 'IPv6 in brackets' => array('[2001:db8::10]', '1'), + 'IPv6 of a device with only IPv6' => array('2001:db8:0:0:0:0:0:20', '2'), + 'IPv4 literal stored as host name' => array('198.51.100.7', '3'), + 'IPv6 literal in brackets stored' => array('2001:db8::99', '4'), + 'device found by the system lookup' => array('192.0.2.60', '6'), + 'a host name that resolves to a device' => array('web1.example', '1'), + 'an address no device has' => array('192.0.2.99', null), + 'an IPv6 address no device has' => array('2001:db8::dead', null), + 'a name that does not resolve' => array('nobody.example', null), + 'an empty address' => array('', null), +)); + +it('lets the last device win when two devices share an address', function () { + $GLOBALS['__test_db_results']['db_fetch_assoc']['FROM host'] = array(array('id' => '1', 'hostname' => 'a.example'), array('id' => '2', 'hostname' => 'b.example')); + + $cache = npc_address_cache_file(); + $controller = npc_address_controller(array('a.example' => npc_address_records(array('192.0.2.10')), 'b.example' => npc_address_records(array('192.0.2.10'))), array(), $cache); + + try { + expect($controller->checkHostExists('192.0.2.10', 'k1'))->toBe('2'); + } finally { + unlink($cache); + } +}); + +it('stores the map in the earlier file layout with a version marker', function () { + $cache = npc_address_cache_file(); + $controller = npc_address_controller(npc_address_devices(), array(), $cache); + + try { + $controller->checkHostExists('192.0.2.10', 'k1'); + + $source = file_get_contents($cache); + + $cacheKey = null; + $cacheVersion = null; + $hosts = null; + + include $cache; + + expect($source)->toStartWith("toBe('k1'); + expect($cacheVersion)->toBe(2); + expect($hosts['192.0.2.10'])->toBe('1'); + expect($hosts['2001:db8::10'])->toBe('1'); + expect($hosts['2001:db8::20'])->toBe('2'); + expect($hosts['2001:db8::99'])->toBe('4'); + expect(array_keys($hosts))->not->toContain('gone.example'); + } finally { + unlink($cache); + } +}); + +it('reads a current map without asking Cacti or DNS again', function () { + $cache = npc_address_cache_file(); + $controller = npc_address_controller(npc_address_devices(), array(), $cache); + + try { + $controller->checkHostExists('192.0.2.10', 'k1'); + $GLOBALS['__test_db_results'] = array(); + $before = $GLOBALS['npc_address_lookups']; + + expect($controller->checkHostExists('2001:DB8::20', 'k1'))->toBe('2'); + expect($GLOBALS['npc_address_lookups'])->toBe($before); + } finally { + unlink($cache); + } +}); + +it('rebuilds a map with another key or one written before IPv6 support', function (string $content, string $key) { + $cache = npc_address_cache_file(); + file_put_contents($cache, $content); + + $controller = npc_address_controller(npc_address_devices(), array(), $cache); + + try { + expect($controller->checkHostExists('2001:db8::20', $key))->toBe('2'); + expect(file_get_contents($cache))->toContain('$cacheVersion = 2;'); + } finally { + unlink($cache); + } +})->with(array( + 'file written by an earlier version' => array(" '1',\n);", 'k1'), + 'current version, another key' => array(" '1',\n);", 'k1'), +)); + +it('still answers IPv4 lookups from a map written by an earlier version of the same key', function () { + $cache = npc_address_cache_file(); + file_put_contents($cache, " '1',\n);"); + + $controller = npc_address_controller(npc_address_devices(), array(), $cache); + + try { + expect($controller->checkHostExists('192.0.2.10', 'k1'))->toBe('1'); + } finally { + unlink($cache); + } +});