diff --git a/agent/ajax.php b/agent/ajax.php index 74cc0836c..799c08f9c 100644 --- a/agent/ajax.php +++ b/agent/ajax.php @@ -913,6 +913,47 @@ if (isset($_GET['client_duplicate_check'])) { echo json_encode($response); } +/* + * Live check behind the IP address field on the add/edit network IP modals. + * + * Runs the exact same checkIpForNetwork() the POST handler runs, so what the + * field says while you type is what the save will do - no second copy of the + * rules to drift. It's advisory only; the handler still checks on submit. + */ +if (isset($_GET['network_ip_check'])) { + enforceUserPermission('module_support', 2); + + $network_id = intval($_GET['network_id'] ?? 0); + $ip_id = intval($_GET['ip_id'] ?? 0); + + $client_id = intval(getFieldById('networks', $network_id, 'network_client_id')); + + enforceClientAccess(); + + $ip = $_GET['ip'] ?? ''; + + $response = ['ok' => false, 'ip' => '', 'message' => '']; + + if (trim($ip) !== '') { + + $ip_error = checkIpForNetwork($ip, $network_id, $ip_id); + + if ($ip_error === '') { + $response['ok'] = true; + $response['ip'] = $ip; + $response['message'] = "" . escapeHtml($ip) . " is available"; + } else { + // checkIpForNetwork() builds its message for flashAlert(), which + // escapes at render - this lands in innerHTML instead, so it has + // to be escaped here + $response['message'] = "" . alertMessageHtml($ip_error); + } + + } + + echo json_encode($response); +} + if (isset($_GET['contact_email_check'])) { enforceUserPermission('module_client', 2); diff --git a/agent/modals/network/network_ip_add.php b/agent/modals/network/network_ip_add.php index 0f85b61ce..d4e504a9e 100644 --- a/agent/modals/network/network_ip_add.php +++ b/agent/modals/network/network_ip_add.php @@ -15,6 +15,11 @@ $client_id = intval($row['network_client_id']); enforceClientAccess(); +// The part of the address this subnet fixes. Empty for IPv6, a prefix under +// /8, or a subnet that won't parse - then the field takes a whole address. +$ip_fixed_octets = ipSubnetFixedOctets($row['network']); +$ip_host_octets = $ip_fixed_octets ? 4 - substr_count($ip_fixed_octets, '.') : 0; + ob_start(); ?> @@ -31,9 +36,19 @@ ob_start();
+ + + + maxlength="" required autofocus> + - + +
+
@@ -60,6 +75,12 @@ ob_start(); + + @@ -36,9 +45,19 @@ ob_start();
+ + + + maxlength="" required> + - + +
+
@@ -65,6 +84,12 @@ ob_start(); + + + +
@@ -95,30 +110,38 @@ if (mysqli_num_rows($sql) == 0) {
-
+ +
Network
+
+ -
VLAN
+
VLAN
+
-
+
Gateway
+
-
+
Assignable IP Range
+
-
+
Primary DNS
+
-
+
Secondary DNS
+
-
+
Location
+
- + +
Documented IPs
+
+
diff --git a/functions/network.php b/functions/network.php index c2d9a9b1e..d0cec3456 100644 --- a/functions/network.php +++ b/functions/network.php @@ -175,33 +175,127 @@ function isIpInSubnet($ip, $subnet) { } /* - * The two safeguards on a documented IP, in one place so add, edit and CSV - * import can't drift apart: the address must be valid and inside its network's - * subnet, and the network must not already have it. + * The part of an address a subnet fixes for every host in it, as a display + * string with its trailing dot - 192.168.1.0/24 gives "192.168.1.". * - * $ip is rewritten by reference to its canonical form, so callers store what - * was checked rather than what was typed. + * This is what the add/edit form prepends so only the host part is typeable. + * Whole octets only, so it tracks the prefix: a /16 fixes two, a /26 fixes + * three (the last octet is still typed, and the range check catches a host + * outside the block). + * + * Returns an empty string when there is nothing useful to fix - IPv6, a prefix + * under /8, a /32 with no host part at all, or a subnet that won't parse. The + * form falls back to a plain full-address input in those cases. + */ +function ipSubnetFixedOctets($subnet) { + + $parsed = parseSubnet($subnet); + + if ($parsed === false || $parsed['bytes'] !== 4) { + return ''; + } + + $fixed = intdiv($parsed['prefix'], 8); + + if ($fixed < 1 || $fixed > 3) { + return ''; + } + + $octets = explode('.', inet_ntop($parsed['network'])); + + return implode('.', array_slice($octets, 0, $fixed)) . '.'; + +} + +/* + * Turns what was typed into the host part of the form into a full address. + * + * A complete address is passed straight through, so pasting 192.168.1.10 into + * the box still works, as does a CSV that carries full addresses. + */ +function expandIpSuffix($input, $subnet) { + + $input = trim($input); + + if ($input === '') { + return $input; + } + + // Already complete - typed in full, pasted, or imported + if (normalizeIpAddress($input) !== false) { + return $input; + } + + $fixed = ipSubnetFixedOctets($subnet); + + if ($fixed === '') { + return $input; + } + + // The host part has to supply exactly the octets the prefix doesn't + if (count(explode('.', $input)) !== 4 - substr_count($fixed, '.')) { + return $input; + } + + return $fixed . $input; + +} + +/* + * The reverse, for the edit form: the host part of a stored address, or the + * whole address when the subnet fixes nothing. + */ +function ipSuffixForDisplay($ip, $subnet) { + + $fixed = ipSubnetFixedOctets($subnet); + + // The trailing dot matters - it stops 192.168.1. matching 192.168.10.5 + if ($fixed === '' || strpos($ip, $fixed) !== 0) { + return $ip; + } + + return substr($ip, strlen($fixed)); + +} + +/* + * The two safeguards on a documented IP, in one place so add, edit, CSV import + * and the live check behind the form can't drift apart: the address must be + * valid and inside its network's subnet, and the network must not already have + * it. + * + * $ip is rewritten by reference to its canonical, fully-expanded form, so + * callers store what was checked rather than what was typed. * * Returns an error message for the user, or an empty string when it's good. * Pass the row's own id as $ignore_ip_id when editing so a row doesn't collide * with itself. + * + * The message is built for flashAlert(), which escapes at render - anything + * putting it somewhere else has to run it through alertMessageHtml() first. */ function checkIpForNetwork(&$ip, $network_id, $ignore_ip_id = 0) { global $mysqli; + $network_id = intval($network_id); + $subnet = getFieldById('networks', $network_id, 'network'); + $original = trim($ip); - $normalized = normalizeIpAddress($original); + + // Host part only from the form, full address from a paste or a CSV + $expanded = expandIpSuffix($original, $subnet); + + $normalized = normalizeIpAddress($expanded); if ($normalized === false) { - return "$original is not a valid IP address"; + // Report the expanded attempt, not the raw input - in a /24 the form + // only takes the host part, so "abc" reads better as 192.168.1.abc + return "$expanded is not a valid IP address"; } $ip = $normalized; - $network_id = intval($network_id); - $subnet = getFieldById('networks', $network_id, 'network'); - if (!isIpInSubnet($ip, $subnet)) { return "$ip is outside $subnet"; } diff --git a/js/app.js b/js/app.js index e622f4d1e..835ddb079 100644 --- a/js/app.js +++ b/js/app.js @@ -883,3 +883,83 @@ function itflowToast(message, type) { delay: 5000 }).show(); } + +/* + * Live IP check for the network IP add/edit modals (agent/modals/network/). + * + * Calls the same checkIpForNetwork() the POST handler uses, via + * ajax.php?network_ip_check, so the field reports exactly the verdict the save + * will reach. Advisory only - the handler re-checks on submit, and this never + * blocks submission. + * + * Lives here rather than in the modal because includes/modal_footer.php + * re-executes this file on every ajax modal open, so the function is already + * defined by the time the modal's inline call runs. + */ +function itflowWatchNetworkIp(networkId, ipId) { + + var input = document.getElementById('networkIpAddress'); + var feedback = document.getElementById('networkIpFeedback'); + + if (!input || !feedback) { + return; + } + + var timer = null; + var lastChecked = null; + + function render(cls, html) { + feedback.className = 'small mt-1 ' + cls; + feedback.innerHTML = html; + } + + function check() { + + var value = input.value.trim(); + + if (value === '') { + render('', ''); + input.classList.remove('is-valid', 'is-invalid'); + lastChecked = null; + return; + } + + if (value === lastChecked) { + return; + } + + lastChecked = value; + + itflowGet( + 'ajax.php', + {network_ip_check: 'true', network_id: networkId, ip_id: ipId, ip: value}, + function (data) { + + var result; + + try { + result = JSON.parse(data); + } catch (e) { + return; + } + + // A slow earlier response must not paint over a newer one + if (input.value.trim() !== lastChecked) { + return; + } + + render(result.ok ? 'text-success' : 'text-danger', result.message); + input.classList.toggle('is-valid', result.ok); + input.classList.toggle('is-invalid', !result.ok); + } + ); + } + + input.addEventListener('input', function () { + clearTimeout(timer); + timer = setTimeout(check, 350); + }); + + input.addEventListener('blur', check); + +}