diff --git a/scripts/js/settings-dhcp.js b/scripts/js/settings-dhcp.js index 370396cdd4..86c3c53851 100644 --- a/scripts/js/settings-dhcp.js +++ b/scripts/js/settings-dhcp.js @@ -74,15 +74,23 @@ $(() => { }, rowCallback(row, data) { $(row).attr("data-id", data.ip); - const button = - '"; - $("td:eq(6)", row).html(button); + // Create buttons without data-* attributes in HTML + const $deleteBtn = $( + '' + ) + .attr("id", "deleteLease_" + data.ip) + .attr("data-del-ip", data.ip) + .attr("title", "Delete lease") + .attr("data-toggle", "tooltip"); + const $copyBtn = $( + '' + ) + .attr("title", "Copy to static leases") + .attr("data-toggle", "tooltip") + .data("hwaddr", data.hwaddr || "") + .data("ip", data.ip || "") + .data("hostname", data.name || ""); + $("td:eq(6)", row).empty().append($deleteBtn, " ", $copyBtn); }, select: { style: "multi", @@ -212,6 +220,8 @@ function delLease(ip) { function fillDHCPhosts(data) { $("#dhcp-hosts").val(data.value.join("\n")); + // Trigger input to update the table + $("#dhcp-hosts").trigger("input"); } function processDHCPConfig() { @@ -227,6 +237,301 @@ function processDHCPConfig() { }); } +function parseStaticDHCPLine(line) { + // Accepts: [hwaddr][,ipaddr][,hostname] (all optional, comma-separated, no advanced tokens) + // Returns null if advanced/invalid, or {hwaddr, ipaddr, hostname} + + // If the line is empty, return an object with empty fields + if (!line.trim()) + return { + hwaddr: "", + ipaddr: "", + hostname: "", + }; + + // Advanced if contains id:, set:, tag:, ignore + if (/id:|set:|tag:|ignore|lease_time|,\s*,/v.test(line)) return "advanced"; + + // Split the line by commas and trim whitespace + const parts = line.split(",").map(s => s.trim()); + + // If there are more than 3 parts or less than 2, it's considered advanced + if (parts.length > 3 || parts.length < 2) return "advanced"; + + // Check if first part is a valid MAC address + const haveMAC = parts.length > 0 && utils.validateMAC(parts[0]); + const hwaddr = haveMAC ? parts[0].trim() : ""; + + // Check if the first or second part is a valid IPv4 or IPv6 address + const hasSquareBrackets0 = parts[0][0] === "[" && parts[0].at(-1) === "]"; + const ipv60 = hasSquareBrackets0 ? parts[0].slice(1, -1) : parts[0]; + const hasSquareBrackets1 = parts.length > 1 && parts[1][0] === "[" && parts[1].at(-1) === "]"; + const ipv61 = hasSquareBrackets1 ? parts[1].slice(1, -1) : parts.length > 1 ? parts[1] : ""; + const firstIsValidIP = utils.validateIPv4(parts[0]) || utils.validateIPv6(ipv60); + const secondIsValidIP = + parts.length > 1 && (utils.validateIPv4(parts[1]) || utils.validateIPv6(ipv61)); + const ipaddr = firstIsValidIP ? parts[0].trim() : secondIsValidIP ? parts[1].trim() : ""; + const haveIP = ipaddr.length > 0; + + // Check if the second or third part is a valid hostname + let hostname = ""; + if (parts.length > 2 && parts[2].length > 0) hostname = parts[2].trim(); + else if (parts.length > 1 && parts[1].length > 0 && (!haveIP || !haveMAC)) + hostname = parts[1].trim(); + + return { + hwaddr, + ipaddr, + hostname, + }; +} + +// Save button for each row updates only that line in the textarea, if it doesn't contain the class ".disabled" +$(document).on("click", ".save-static-row:not(.disabled)", function () { + const rowIdx = Number.parseInt($(this).data("row"), 10); + const row = $(this).closest("tr"); + const hwaddr = row.find(".static-hwaddr").text().trim(); + const ipaddr = row.find(".static-ipaddr").text().trim(); + const hostname = row.find(".static-hostname").text().trim(); + + // Validate MAC and IP before saving + const macValid = !hwaddr || utils.validateMAC(hwaddr); + const ipValid = !ipaddr || utils.validateIPv4(ipaddr) || utils.validateIPv6(ipaddr); + if (!macValid || !ipValid) { + utils.showAlert( + "error", + "fa-times", + "Cannot save: Invalid MAC or IP address", + "Please correct the highlighted fields before saving." + ); + return; + } + + const lines = $("#dhcp-hosts").val().split(/\r?\n/v); + // Only update if at least one field is non-empty + lines[rowIdx] = + hwaddr || ipaddr || hostname ? [hwaddr, ipaddr, hostname].filter(Boolean).join(",") : ""; + $("#dhcp-hosts").val(lines.join("\n")); + // Optionally, re-render the table to reflect changes + renderStaticDHCPTable(); +}); + +// Delete button for each row removes that line from the textarea and updates the table +$(document).on("click", ".delete-static-row", function () { + const rowIdx = Number.parseInt($(this).data("row"), 10); + const lines = $("#dhcp-hosts").val().split(/\r?\n/v); + lines.splice(rowIdx, 1); + $("#dhcp-hosts").val(lines.join("\n")); + renderStaticDHCPTable(); +}); + +// Add button for each row inserts a new empty line after this row +$(document).on("click", ".add-static-row", function () { + const rowIdx = Number.parseInt($(this).data("row"), 10); + const lines = $("#dhcp-hosts").val().split(/\r?\n/v); + lines.splice(rowIdx + 1, 0, ""); + $("#dhcp-hosts").val(lines.join("\n")); + renderStaticDHCPTable(); + // Focus the new row after render + setTimeout(() => { + $("#StaticDHCPTable tbody tr") + .eq(rowIdx + 1) + .find("td:first") + .focus(); + }, 10); +}); + +// Update table on load and whenever textarea changes $(() => { processDHCPConfig(); + renderStaticDHCPTable(); + $("#dhcp-hosts").on("input", renderStaticDHCPTable); +}); + +// When editing a cell, disable all action buttons except the save button in the current row +$(document).on("focus input", "#StaticDHCPTable td[contenteditable]", function () { + const row = $(this).closest("tr"); + // Disable all action buttons in all rows + $( + "#StaticDHCPTable .save-static-row, #StaticDHCPTable .delete-static-row, #StaticDHCPTable .add-static-row" + ).prop("disabled", true); + // Enable only the save button in the current row + row.find(".save-static-row").prop("disabled", false); + // Show a hint below the current row if not already present + if (!row.next().hasClass("edit-hint-row")) { + row.next(".edit-hint-row").remove(); // Remove any existing hint + row.after( + 'Please confirm changes using the green button, then click "Save & Apply" before leaving the page.' + ); + } +}); + +// On save, re-enable all buttons (except buttons with class "disabled") and remove the hint +$(document).on("click", ".save-static-row", function () { + $( + "#StaticDHCPTable .save-static-row:not(.disabled), #StaticDHCPTable .delete-static-row, #StaticDHCPTable .add-static-row" + ).prop("disabled", false); + $(".edit-hint-row").remove(); +}); + +// On table redraw, ensure all buttons are enabled and hints are removed +function renderStaticDHCPTable() { + const tbody = $("#StaticDHCPTable tbody"); + tbody.empty(); + const lines = $("#dhcp-hosts").val().split(/\r?\n/v); + for (const [idx, line] of lines.entries()) { + const parsed = parseStaticDHCPLine(line); + + const saveBtn = $( + '' + ) + .attr("data-row", idx) + .attr("title", "Confirm changes to this line") + .attr("data-toggle", "tooltip"); + + const delBtn = $( + '' + ) + .attr("data-row", idx) + .attr("title", "Delete this line") + .attr("data-toggle", "tooltip"); + + const addBtn = $( + '' + ) + .attr("data-row", idx) + .attr("title", "Add new line after this") + .attr("data-toggle", "tooltip"); + + const tr = $(""); + + if (parsed === "advanced") { + tr.addClass("table-warning").append( + 'Advanced settings present in line ' + + (idx + 1) + + "" + ); + + // Keep the original data + tr.data("original-line", line); + + // Disable the save button on advanced rows + saveBtn.addClass("disabled").prop("disabled", true).attr("title", "Disabled"); + } else { + // Append 3 cells containing parsed values, with placeholder for empty hwaddr + tr.append($('').text(parsed.hwaddr)) + .append($('').text(parsed.ipaddr)) + .append( + $('').text(parsed.hostname) + ); + } + + // Append a last cell containing the buttons + tr.append($("").append(saveBtn, " ", delBtn, " ", addBtn)); + + tbody.append(tr); + } + + tbody.find(".save-static-row, .delete-static-row, .add-static-row").prop("disabled", false); + tbody.find(".edit-hint-row").remove(); +} + +// Copy button for each lease row copies the lease as a new static lease line +$(document).on("click", ".copy-to-static", function () { + const hwaddr = $(this).data("hwaddr") || ""; + const ip = $(this).data("ip") || ""; + const hostname = $(this).data("hostname") || ""; + const line = [hwaddr, ip, hostname].filter(Boolean).join(","); + const textarea = $("#dhcp-hosts"); + const val = textarea.val(); + textarea.val(val ? val + "\n" + line : line).trigger("input"); +}); + +// Add line numbers to the textarea for static DHCP hosts +document.addEventListener("DOMContentLoaded", function () { + const textarea = document.getElementById("dhcp-hosts"); + const linesElem = document.getElementById("dhcp-hosts-lines"); + let lastLineCount = 0; + + function updateLineNumbers(force) { + if (!textarea || !linesElem) return; + const lines = textarea.value.split("\n").length || 1; + if (!force && lines === lastLineCount) return; + lastLineCount = lines; + let html = ""; + for (let i = 1; i <= lines; i++) html += i + "
"; + linesElem.innerHTML = html; + // Apply the same styles to the lines element as the textarea + for (const property of [ + "fontFamily", + "fontSize", + "fontWeight", + "letterSpacing", + "lineHeight", + "padding", + "height", + ]) { + linesElem.style[property] = globalThis.getComputedStyle(textarea)[property]; + } + + // Match height and scroll + linesElem.style.height = textarea.offsetHeight > 0 ? textarea.offsetHeight + "px" : "auto"; + } + + function syncScroll() { + linesElem.scrollTop = textarea.scrollTop; + } + + if (textarea && linesElem) { + textarea.addEventListener("input", function () { + updateLineNumbers(false); + }); + textarea.addEventListener("scroll", syncScroll); + window.addEventListener("resize", function () { + updateLineNumbers(true); + }); + updateLineNumbers(true); + syncScroll(); + } +}); + +$(document).on("input blur paste", "#StaticDHCPTable td.static-hwaddr", function () { + const val = $(this).text().trim(); + if (val && !utils.validateMAC(val)) { + $(this).addClass("table-danger"); + $(this).removeClass("table-success"); + $(this).attr("title", "Invalid MAC address format"); + } else { + $(this).addClass("table-success"); + $(this).removeClass("table-danger"); + $(this).attr("title", ""); + } +}); + +$(document).on("input blur paste", "#StaticDHCPTable td.static-ipaddr", function () { + const val = $(this).text().trim(); + if (val && !(utils.validateIPv4(val) || utils.validateIPv6(val))) { + $(this).addClass("table-danger"); + $(this).removeClass("table-success"); + $(this).attr("title", "Invalid IP address format"); + } else { + $(this).addClass("table-success"); + $(this).removeClass("table-danger"); + $(this).attr("title", ""); + } +}); + +$(document).on("input blur paste", "#StaticDHCPTable td.static-hostname", function () { + const val = $(this).text().trim(); + // Hostnames must not contain spaces, commas, or characters invalid in DNS names + const hostnameValidator = + /^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*$/v; + if (val && !hostnameValidator.test(val)) { + $(this).addClass("table-danger"); + $(this).removeClass("table-success"); + $(this).attr("title", "Invalid hostname: only letters, digits, hyphens, and dots allowed"); + } else { + $(this).removeClass("table-danger table-success"); + $(this).attr("title", ""); + } }); diff --git a/scripts/js/utils.js b/scripts/js/utils.js index 2ab9edcb5b..c3c49aca7d 100644 --- a/scripts/js/utils.js +++ b/scripts/js/utils.js @@ -233,6 +233,13 @@ function validateIPv4CIDR(ip) { return ipv4validator.test(ip); } +function validateIPv4(ip) { + // Add pseudo-CIDR to the IPv4 + const ipv4WithCIDR = ip.includes("/") ? ip : ip + "/32"; + // Validate the IPv4/CIDR + return validateIPv4CIDR(ipv4WithCIDR); +} + // Pi-hole IPv6/CIDR validator by DL6ER, see regexr.com/50csn function validateIPv6CIDR(ip) { // One IPv6 element is 16bit: 0000 - FFFF @@ -249,14 +256,23 @@ function validateIPv6CIDR(ip) { return ipv6validator.test(ip); } +function validateIPv6(ip) { + // Add pseudo-CIDR to the IPv6 + const ipv6WithCIDR = ip.includes("/") ? ip : ip + "/128"; + // Validate the IPv6/CIDR + return validateIPv6CIDR(ipv6WithCIDR); +} + function validateMAC(mac) { - const macvalidator = /^([\da-fA-F]{2}:){5}([\da-fA-F]{2})$/v; - return macvalidator.test(mac); + // Format: xx:xx:xx:xx:xx:xx where each xx is 0-9 or a-f (case insensitive) + // Also allows dashes as separator, e.g. xx-xx-xx-xx-xx-xx + const macvalidator = /^([\da-f]{2}[:\-]){5}([\da-f]{2})$/iv; + return macvalidator.test(mac.trim()); } function validateHostname(name) { const namevalidator = /[^<>;"]/v; - return namevalidator.test(name); + return namevalidator.test(name.trim()); } // set bootstrap-select defaults @@ -709,7 +725,9 @@ globalThis.utils = (function () { disableAll, enableAll, validateIPv4CIDR, + validateIPv4, validateIPv6CIDR, + validateIPv6, setBsSelectDefaults, stateSaveCallback, stateLoadCallback, diff --git a/settings-dhcp.lp b/settings-dhcp.lp index 6ee2f8dfa1..6a1deba156 100644 --- a/settings-dhcp.lp +++ b/settings-dhcp.lp @@ -171,13 +171,35 @@ mg.include('scripts/lua/settings_header.lp','r')

Static DHCP configuration

+
+
+
+ + + + + + + + + + + + +
MAC addressIP addressHostnameActions
+
+
+

Specify per host parameters for the DHCP server. This allows a machine with a particular hardware address to be always allocated the same hostname, IP address and lease time. A hostname specified like this overrides any supplied by the DHCP client on the machine. It is also allowable to omit the hardware address and include the hostname, in which case the IP address and lease times will apply to any machine claiming that name.

-
  +
+ + +

Each entry should be on a separate line, and should be of the form:

[<hwaddr>][,id:<client_id>|*][,set:<tag>][,tag:<tag>][,<ipaddr>][,<hostname>][,<lease_time>][,ignore]
-

Only one entry per MAC address is allowed.

+

Only one entry per MAC address is allowed. IPv6 addresses must be enclosed in square brackets like [2001:db8::1].

Examples:

diff --git a/style/pi-hole.css b/style/pi-hole.css index ac8fdbec3a..f6ecf472d1 100644 --- a/style/pi-hole.css +++ b/style/pi-hole.css @@ -1390,6 +1390,9 @@ table.dataTable tbody > tr > .selected { .mb-3 { margin-bottom: 1rem !important; } +.mb-4 { + margin-bottom: 2rem !important; +} .ml-3 { margin-left: 1rem !important; } @@ -1580,6 +1583,34 @@ textarea.field-sizing-content { field-sizing: content; } +textarea.no-wrap { + white-space: pre; + overflow-x: auto; + overflow-y: auto; + resize: none; +} + +div.line-numbers { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + text-align: right; + color: #aaa; + border-right: 1px solid #ddd; + padding: 8px 4px 8px 0; + font-family: monospace; + font-size: 13px; + min-width: 2em; + overflow-x: hidden; + overflow-y: hidden; +} + +div.dhcp-hosts-wrapper { + position: relative; + display: flex; + gap: 0.5em; +} + /* Used in interfaces page */ .list-group-item { background: transparent; @@ -1604,6 +1635,20 @@ textarea.field-sizing-content { transition: opacity 200ms ease-in-out; } +#StaticDHCPTable td.table-danger { + background-color: #700000; + color: #fff; +} + +#StaticDHCPTable td.table-success { + background-color: #599900; + color: #fff; +} + +.save-static-row.disabled { + opacity: 0.15; +} + /* Used in query log page */ td.dnssec { padding-inline-start: 2.25em !important; diff --git a/style/themes/high-contrast-dark.css b/style/themes/high-contrast-dark.css index 175b302ce4..6f4df290e4 100644 --- a/style/themes/high-contrast-dark.css +++ b/style/themes/high-contrast-dark.css @@ -914,9 +914,12 @@ input[type="password"]::-webkit-caps-lock-indicator { } /* Tables and Datatables */ +#StaticDHCPTable .table-danger, .blocked-row td { background-color: rgba(56, 0, 40, 0.35); } + +#StaticDHCPTable .table-success, .allowed-row td { background-color: rgba(0, 64, 64, 0.35); } diff --git a/style/themes/high-contrast.css b/style/themes/high-contrast.css index dcc7fc8d6f..47a03e3944 100644 --- a/style/themes/high-contrast.css +++ b/style/themes/high-contrast.css @@ -386,9 +386,12 @@ select:-webkit-autofill { background-color: #fcfcfc; } +#StaticDHCPTable .table-danger, .blocked-row td { background-color: rgba(230, 160, 160, 0.3); } + +#StaticDHCPTable .table-success, .allowed-row td { background-color: rgba(236, 255, 246, 0.3); }