icon, tile tone, what it means]. * Shown as the tile on every list and page. */ const NETWORK_TYPES = [ 'LAN' => ['fa-network-wired', 'network', 'The main wired network - workstations, printers'], 'Wireless' => ['fa-wifi', 'wireless', 'Staff wireless'], 'Guest' => ['fa-user-friends', 'wireless', 'Visitors - kept apart from everything else'], 'Voice' => ['fa-phone', 'phone', 'Phones and the phone system'], 'Servers' => ['fa-server', 'server', 'Servers and hypervisors'], 'Storage' => ['fa-hdd', 'virtual', 'SAN, iSCSI and backup traffic'], 'Management' => ['fa-tools', 'peripheral', 'Switch, firewall and iDRAC / iLO management'], 'IoT' => ['fa-microchip', 'other', 'Cameras, door access, HVAC - what should not talk to the LAN'], 'DMZ' => ['fa-shield-alt', 'firewall', 'Public-facing servers'], 'VPN' => ['fa-lock', 'workstation', 'Remote access and site-to-site tunnels'], 'WAN' => ['fa-globe', 'firewall', 'An internet connection - the circuit, the provider and its public addresses'], 'Other' => ['fa-project-diagram', 'other', ''], ]; /** What a documented address is: [type => icon, tone, what it means, what it is called]. */ const NETWORK_IP_TYPES = [ 'Static' => ['fa-thumbtack', 'secondary', 'Set on the device itself', 'Fixed'], 'Reservation' => ['fa-bookmark', 'info', 'DHCP hands this device the same address every time', 'DHCP reservation'], 'Reserved' => ['fa-ban', 'warning', 'Held back - not in use, do not hand it out', 'Held back'], 'Virtual' => ['fa-clone', 'primary', 'A floating address - a cluster, VRRP / CARP or a load balancer', 'Floating'], ]; /** May this agent do this with networks? 1 is to see them, 2 to change them, 3 to delete them. */ function networkCan($level) { return lookupUserPermission('module_support') >= $level; } /** * The words that say what a network is for - [type => [pattern for the name, * pattern for the description (null: the name's)]], checked in order. The * same words database update 2.8.9 used to fill in the types of existing * networks; New Network uses them to pick the type as the name is typed * (agent/js/network_form.js). A description says "no internet access" as often * as it describes an uplink, so WAN takes stronger words there. */ const NETWORK_TYPE_RULES = [ 'Guest' => ['guest', null], 'WAN' => ['wan|internet|uplink|circuit|broadband|fib(er|re)|starlink|(^|[^a-z])(isp|dsl|lte|5g)([^a-z]|$)', '(^|[^a-z])wan([^a-z]|$)|circuit|broadband|starlink|(^|[^a-z])(isp|dsl)([^a-z]|$)'], 'Voice' => ['voip|voice|phone|pbx|telephon|(^|[^a-z])sip([^a-z]|$)', null], 'Management' => ['mgmt|manage|ipmi|idrac|(^|[^a-z])(oob|ilo|bmc)([^a-z]|$)', null], 'DMZ' => ['dmz', null], 'IoT' => ['camera|cctv|hvac|badge|access control|building|(^|[^a-z])(iot|nvr)([^a-z]|$)', null], 'Storage' => ['storage|iscsi|backup|(^|[^a-z])(san|nfs)([^a-z]|$)', null], 'VPN' => ['vpn|tunnel|ipsec|wireguard', null], 'Servers' => ['server|datacent|cluster|(^|[^a-z])srv', null], 'Wireless' => ['wireless|wifi|wi-fi|wlan|ssid', null], ]; /** The type a network's name (or failing that, its description) says it is - for a network saved without one. */ function networkGuessType($name, $description = '') { foreach ([0 => $name, 1 => $description] as $which => $text) { $text = mb_strtolower(html_entity_decode((string) $text, ENT_QUOTES, 'UTF-8')); if (trim($text) === '') { continue; } foreach (NETWORK_TYPE_RULES as $type => $patterns) { $pattern = $patterns[$which] ?? $patterns[0]; if (preg_match('/' . $pattern . '/', $text)) { return $type; } } } return 'LAN'; } /** A network's type - the one saved, or the one its name says. */ function networkType($row) { $type = (string) ($row['network_type'] ?? ''); return isset(NETWORK_TYPES[$type]) ? $type : networkGuessType($row['network_name'] ?? '', $row['network_description'] ?? ''); } /** The tile for a network type - the asset tile in that type's colour and icon. */ function networkTile($type, $size = '') { $style = NETWORK_TYPES[$type] ?? NETWORK_TYPES['LAN']; $size_class = in_array($size, ['xs', 'sm', 'lg'], true) ? " itflow-asset-icon-$size" : ''; return ''; } /** A network's type as a label - its icon in its colour, then the name. */ function networkTypeLabel($type) { $style = NETWORK_TYPES[$type] ?? NETWORK_TYPES['LAN']; return '' . escapeHtml($type) . ''; } /** Where the network page is - inside its client. */ function networkUrl($network_id, $client_id) { return 'network.php?client_id=' . intval($client_id) . '&network_id=' . intval($network_id); } /** * A network wherever a page mentions one: its tile and name, opening its page * - or its quick look with 'quick' => true. 'type' picks the tile; 'archived' * strikes it through. Takes the name raw or already escaped. */ function networkLink($network_id, $name, $client_id, $options = []) { $network_id = intval($network_id); $label = escapeHtml(html_entity_decode((string) $name, ENT_QUOTES, 'UTF-8')); $archived = !empty($options['archived']); $class = trim('text-truncate ' . ($options['class'] ?? '') . ($archived ? ' text-decoration-line-through text-body-secondary' : '')); if (!empty($options['quick'])) { $link = "$label"; } else { $link = '' . $label . ''; } return '' . networkTile($options['type'] ?? 'LAN', 'xs') . $link . ''; } /** One network by id, with its client, site and provider - raw, or null. */ function networkRow($network_id) { global $mysqli; $network_id = intval($network_id); if (!$network_id) { return null; } $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT networks.*, client_name, location_name, location_archived_at, vendor_name, vendor_phone, vendor_phone_country_code, vendor_extension, vendor_account_number, vendor_hours, vendor_archived_at FROM networks LEFT JOIN clients ON client_id = network_client_id LEFT JOIN locations ON location_id = network_location_id AND network_location_id > 0 LEFT JOIN vendors ON vendor_id = network_vendor_id AND network_vendor_id > 0 WHERE network_id = $network_id LIMIT 1")); return $row ?: null; } /** * For post handlers: the network a request names, after the module and client * checks - or back with an error if it is gone. Sets the global $client_id. */ function networkForRequest($network_id, $level) { global $client_id; enforceUserPermission('module_support', $level); $row = networkRow($network_id); if (!$row) { flashAlert('That network no longer exists.', 'error'); redirect(); } $client_id = intval($row['network_client_id']); enforceClientAccess(); return $row; } /** For modals: the same, or the modal's error. */ function networkForModal($network_id, $level = 1) { global $client_id; if (!networkCan($level)) { echo json_encode(['error' => 'Your role cannot ' . ($level > 1 ? 'change' : 'see') . ' networks.']); exit; } $row = networkRow($network_id); if (!$row) { echo json_encode(['error' => 'That network no longer exists.']); exit; } $client_id = intval($row['network_client_id']); if (!hasClientAccess($client_id)) { echo json_encode(['error' => 'You do not have access to that client.']); exit; } return $row; } /** The networks a bulk action names (network_ids[]) that this agent may change - [id => row]. */ function networksForBulk($level) { enforceUserPermission('module_support', $level); $rows = []; foreach (array_unique(array_map('intval', (array) ($_POST['network_ids'] ?? []))) as $network_id) { $row = networkRow($network_id); if ($row && hasClientAccess($row['network_client_id'])) { $rows[$network_id] = $row; } } return $rows; } /** Archive a network - its addresses stay, and the interfaces on it keep pointing at it. */ function networkArchive($row) { global $mysqli, $session_name; $network_id = intval($row['network_id']); mysqli_query($mysqli, "UPDATE networks SET network_archived_at = NOW() WHERE network_id = $network_id"); logAudit("Network", "Archive", "$session_name archived network " . escapeSql($row['network_name']), intval($row['network_client_id']), $network_id); } function networkRestore($row) { global $mysqli, $session_name; $network_id = intval($row['network_id']); mysqli_query($mysqli, "UPDATE networks SET network_archived_at = NULL WHERE network_id = $network_id"); logAudit("Network", "Restore", "$session_name restored network " . escapeSql($row['network_name']), intval($row['network_client_id']), $network_id); } /** Delete for good - its documented addresses go with it (ON DELETE CASCADE); the interfaces on it are left on no network. */ function networkDelete($row) { global $mysqli, $session_name; $network_id = intval($row['network_id']); mysqli_query($mysqli, "UPDATE asset_interfaces SET interface_network_id = 0 WHERE interface_network_id = $network_id"); mysqli_query($mysqli, "DELETE FROM networks WHERE network_id = $network_id"); logAudit("Network", "Delete", "$session_name deleted network " . escapeSql($row['network_name']), intval($row['network_client_id'])); } /** One documented address by id, with its network - raw, or null. */ function networkIpRow($ip_id) { global $mysqli; $ip_id = intval($ip_id); if (!$ip_id) { return null; } $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT network_ips.*, network_id, network_name, network, network_client_id, network_dhcp_range, network_gateway FROM network_ips JOIN networks ON network_id = ip_network_id WHERE ip_id = $ip_id LIMIT 1")); return $row ?: null; } /** For post handlers: the documented address a request names, after the checks. Sets the global $client_id. */ function networkIpForRequest($ip_id, $level) { global $client_id; enforceUserPermission('module_support', $level); $row = networkIpRow($ip_id); if (!$row) { flashAlert('That address is no longer documented.', 'error'); redirect(); } $client_id = intval($row['network_client_id']); enforceClientAccess(); return $row; } /** * The fields of a documented address besides the address itself - type, * hostname, MAC, description - checked, as a SET clause; '' with $error. A * reservation is for a MAC, so it needs one. */ function networkIpFieldsSet($post, &$error = '') { $type = isset(NETWORK_IP_TYPES[$post['type'] ?? '']) ? $post['type'] : 'Static'; $mac = normalizeMacAddress($post['mac'] ?? ''); if ($mac === false) { $error = '' . trim((string) $post['mac']) . ' is not a MAC address - it is six pairs, like 00:1A:2B:3C:4D:5E.'; return ''; } if ($type === 'Reservation' && $mac === '' && empty($post['allow_no_mac'])) { $error = 'A DHCP reservation is for a device - add the MAC address it is reserved for.'; return ''; } return "ip_type = '$type', ip_hostname = '" . escapeSql(mb_substr(trim((string) ($post['hostname'] ?? '')), 0, 200)) . "', ip_mac = " . ($mac === '' ? 'NULL' : "'$mac'") . ", ip_description = '" . escapeSql(mb_substr(trim((string) ($post['description'] ?? '')), 0, 200)) . "'"; } /** A documented address's type as a small pill - its icon and name. */ function networkIpTypePill($type) { $style = NETWORK_IP_TYPES[$type] ?? NETWORK_IP_TYPES['Static']; return '' . $style[3] . ''; } /* * ---------- subnet maths ---------- */ /** An IPv4 address as a number, or null. */ function ipv4ToInt($ip) { $bin = @inet_pton(trim((string) $ip)); return ($bin !== false && strlen($bin) === 4) ? unpack('N', $bin)[1] : null; } /** A number back to an IPv4 address. */ function intToIpv4($int) { return inet_ntop(pack('N', intval($int))); } /** * Everything worth knowing about a subnet: its canonical CIDR, the network * and last address, the first and last usable host, how many addresses and * hosts, and the dotted mask (IPv4). Null when it is not a subnet. * An IPv4 /31 is two usable hosts (RFC 3021), a /32 one. */ function networkSubnetInfo($cidr) { $parsed = parseSubnet((string) $cidr); if ($parsed === false) { return null; } $prefix = $parsed['prefix']; $network = $parsed['network']; $last = $network; for ($i = 0; $i < $parsed['bytes']; $i++) { $bits_left = $prefix - ($i * 8); if ($bits_left >= 8) { continue; } $host_mask = $bits_left <= 0 ? 0xFF : (0xFF >> $bits_left); $last[$i] = chr(ord($last[$i]) | $host_mask); } $info = [ 'family' => $parsed['bytes'] === 4 ? 4 : 6, 'prefix' => $prefix, 'cidr' => inet_ntop($network) . '/' . $prefix, 'network' => inet_ntop($network), 'last' => inet_ntop($last), 'network_bin' => $network, 'last_bin' => $last, 'mask' => '', 'size' => null, 'usable' => null, 'first_host' => inet_ntop($network), 'last_host' => inet_ntop($last), ]; if ($info['family'] === 4) { $start = unpack('N', $network)[1]; $end = unpack('N', $last)[1]; $info['size'] = $end - $start + 1; $info['mask'] = intToIpv4($prefix === 0 ? 0 : (0xFFFFFFFF << (32 - $prefix)) & 0xFFFFFFFF); if ($prefix <= 30) { $info['first_host'] = intToIpv4($start + 1); $info['last_host'] = intToIpv4($end - 1); $info['usable'] = $info['size'] - 2; } else { $info['usable'] = $info['size']; } } return $info; } /** "254 hosts · 255.255.255.0" - a subnet's size in words, or ''. */ function networkSizeText($info) { if (!$info) { return ''; } if ($info['family'] === 6) { return 'IPv6 /' . $info['prefix']; } return number_format($info['usable']) . ' host' . ($info['usable'] === 1 ? '' : 's') . ' · ' . $info['mask']; } /** * The DHCP / assignable range written on a network - "192.168.1.100-192.168.1.200", * "192.168.1.100 - 200", "192.168.1.100 to .200" - as [first, last] numbers * inside the subnet. IPv4 only; null when it is not a range (it can hold a * note, "Static addressing only"). */ function networkParseRange($text, $info) { $text = trim((string) $text); if ($text === '' || !$info || $info['family'] !== 4) { return null; } $parts = preg_split('/\s*(?:-|–|—|\bto\b)\s*/u', $text, 2); if (count($parts) !== 2) { return null; } $start = normalizeIpAddress($parts[0]); if ($start === false || ipv4ToInt($start) === null) { return null; } $end_text = ltrim(trim($parts[1]), '.'); if (normalizeIpAddress($end_text) === false || ipv4ToInt($end_text) === null) { // Only the host part - borrow the rest from the start address $start_octets = explode('.', $start); $end_octets = explode('.', $end_text); if (count($end_octets) > 3 || !ctype_digit(str_replace('.', '', $end_text))) { return null; } $end_text = implode('.', array_merge(array_slice($start_octets, 0, 4 - count($end_octets)), $end_octets)); } $first = ipv4ToInt($start); $last = ipv4ToInt($end_text); if ($first === null || $last === null || $first > $last) { return null; } if (!isIpInSubnet(intToIpv4($first), $info['cidr']) || !isIpInSubnet(intToIpv4($last), $info['cidr'])) { return null; } return [$first, $last]; } /** A run of addresses as written - "192.168.1.3 - 9" when only the last part differs, both in full otherwise. */ function networkRangeText($start, $end) { if ($start === $end) { return $start; } $cut = strrpos($start, '.'); if ($cut !== false && strpos($start, ':') === false && substr($start, 0, $cut) === substr($end, 0, strrpos($end, '.'))) { return $start . ' - ' . substr($end, $cut + 1); } return $start . ' - ' . $end; } /** Do two subnets share any address? */ function networkSubnetsOverlap($a, $b) { if (!$a || !$b || $a['family'] !== $b['family']) { return false; } $prefix = min($a['prefix'], $b['prefix']); return applyIpMask($a['network_bin'], $prefix) === applyIpMask($b['network_bin'], $prefix); } /** The client's other networks whose subnet overlaps this one - the same range at another site, or a mistake. */ function networkOverlaps(array $network) { global $mysqli; $info = networkSubnetInfo($network['network']); if (!$info) { return []; } $overlaps = []; $sql = mysqli_query($mysqli, "SELECT network_id, network_name, network, network_type, network_description, network_location_id, location_name FROM networks LEFT JOIN locations ON location_id = network_location_id AND network_location_id > 0 WHERE network_client_id = " . intval($network['network_client_id']) . " AND network_archived_at IS NULL AND network_id <> " . intval($network['network_id'] ?? 0)); while ($row = mysqli_fetch_assoc($sql)) { if (networkSubnetsOverlap($info, networkSubnetInfo($row['network']))) { $overlaps[] = $row; } } return $overlaps; } /** A MAC address in one form - 00:1A:2B:3C:4D:5E - from any of the usual ones; '' when empty, false when it is not one. */ function normalizeMacAddress($mac) { $mac = trim((string) $mac); if ($mac === '') { return ''; } if (!preg_match('/^[0-9a-f]{2}([-:. ]?[0-9a-f]{2}){5}$/i', $mac) && !preg_match('/^[0-9a-f]{4}\.[0-9a-f]{4}\.[0-9a-f]{4}$/i', $mac)) { return false; } $hex = preg_replace('/[^0-9a-f]/i', '', $mac); return strlen($hex) === 12 ? strtoupper(implode(':', str_split($hex, 2))) : false; } /* * ---------- what is on a network ---------- */ /** * The addresses on some networks: the documented ones and the interfaces of * assets (not archived) set to each - [network_id => ['documented' => rows, * 'interfaces' => rows]]. */ function networkAddresses(array $network_ids) { global $mysqli; $network_ids = array_values(array_filter(array_map('intval', $network_ids))); $out = []; foreach ($network_ids as $id) { $out[$id] = ['documented' => [], 'interfaces' => []]; } if (!$network_ids) { return $out; } $in = implode(',', $network_ids); $sql = mysqli_query($mysqli, "SELECT ip_id, ip_address, ip_type, ip_hostname, ip_mac, ip_description, ip_network_id, ip_created_at, ip_updated_at FROM network_ips WHERE ip_network_id IN ($in)"); while ($row = mysqli_fetch_assoc($sql)) { $out[intval($row['ip_network_id'])]['documented'][] = $row; } $sql = mysqli_query($mysqli, "SELECT interface_id, interface_name, interface_description, interface_type, interface_ip, interface_mac, interface_nat_ip, interface_ipv6, interface_vlans, interface_primary, interface_network_id, asset_id, asset_name, asset_type, asset_status, asset_client_id FROM asset_interfaces JOIN assets ON asset_id = interface_asset_id WHERE interface_network_id IN ($in) AND interface_archived_at IS NULL AND asset_archived_at IS NULL ORDER BY asset_name, interface_primary DESC, interface_name"); while ($row = mysqli_fetch_assoc($sql)) { $out[intval($row['interface_network_id'])]['interfaces'][] = $row; } return $out; } /** * How full some networks are: [network_id => used addresses, hosts (IPv4), * percent, documented count, devices]. Used counts each host address in the * subnet once - the gateway, documented, or on an interface. */ function networkUsage(array $networks, $addresses = null) { $addresses = $addresses ?? networkAddresses(array_keys($networks)); $usage = []; foreach ($networks as $id => $network) { $info = networkSubnetInfo($network['network']); $used = []; $assets = []; // A host address in the subnet - not its network or broadcast address $counts = function ($ip) use ($info) { if ($ip === false) { return false; } if (!$info) { return true; } if (!isIpInSubnet($ip, $info['cidr'])) { return false; } return !($info['family'] === 4 && $info['prefix'] <= 30 && ($ip === $info['network'] || $ip === $info['last'])); }; $values = [(string) ($network['network_gateway'] ?? '')]; foreach ($addresses[$id]['documented'] ?? [] as $row) { $values[] = $row['ip_address']; } foreach ($addresses[$id]['interfaces'] ?? [] as $row) { $assets[intval($row['asset_id'])] = true; $values[] = (string) $row['interface_ip']; $values[] = (string) $row['interface_ipv6']; } foreach ($values as $value) { $ip = normalizeIpAddress((string) $value); if ($counts($ip)) { $used[$ip] = true; } } $hosts = $info && $info['family'] === 4 ? $info['usable'] : null; $usage[$id] = [ 'used' => count($used), 'hosts' => $hosts, 'percent' => $hosts ? min(100, round(count($used) / $hosts * 100)) : null, 'documented' => count($addresses[$id]['documented'] ?? []), 'devices' => count($assets), ]; } return $usage; } /** How full a network is, as a small bar - "47 of 254" - or the count alone for IPv6 / an unparsed subnet. */ function networkUsageBar($usage) { if ($usage['percent'] === null) { return '' . number_format($usage['used']) . ' in use'; } $tone = $usage['percent'] >= 90 ? 'danger' : ($usage['percent'] >= 75 ? 'warning' : 'success'); return '