diff --git a/scripts/js/settings-dhcp.js b/scripts/js/settings-dhcp.js
index 370396cdd4..551c8cc11c 100644
--- a/scripts/js/settings-dhcp.js
+++ b/scripts/js/settings-dhcp.js
@@ -13,9 +13,7 @@ let dhcpLeaesTable = null;
const toasts = {};
// DHCP leases tooltips
-$(() => {
- $('[data-toggle="tooltip"]').tooltip({ html: true, container: "body" });
-});
+$("body").tooltip({ selector: '[data-toggle="tooltip"]', container: "body" });
function renderHostnameCLID(data, type) {
// Display and search content
@@ -74,15 +72,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 +218,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 +235,373 @@ 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 line contains id:, set:, tag, or "*":
+ if (/id:|set:|tag:\*/u.test(line)) return "advanced";
+
+ // Split the line by commas and trim whitespace
+ const parts = line.split(",").map(s => s.trim());
+
+ // Advanced if there are more than 3 parts
+ if (parts.length > 3) return "advanced";
+
+ let hwaddr = "";
+ let ipaddr = "";
+ let hostname = "";
+
+ for (const part of parts) {
+ // Advanced if the part is "infinite", "ignore" or a lease time value
+ if (/^(infinite|ignore|\d+(w|W|d|D|h|H|m|M|s|S))$/u.test(part)) return "advanced";
+
+ if (part.includes(":")) {
+ if (part.startsWith("[") && part.endsWith("]")) {
+ // Advanced if more than one IP was found
+ if (ipaddr) return "advanced";
+
+ // Potentially an IPv6 (we allow invalid values here. The table will highlight them)
+ ipaddr = part;
+ } else {
+ // Advanced if more than one MAC Address was found
+ if (hwaddr) return "advanced";
+
+ // Potentially a MAC Address (we allow invalid values here. The table will highlight them)
+ hwaddr = part;
+ }
+ } else if (/^[.0-9]+$/u.test(part)) {
+ // Advanced if more than one IP was found
+ if (ipaddr) return "advanced";
+
+ // Potentially an IPv4 (we allow invalid values here. The table will highlight them)
+ ipaddr = part;
+ } else {
+ // Advanced if more than one hostname was found
+ if (hostname) return "advanced";
+
+ // Potentially a hostname (we allow invalid values here. The table will highlight them)
+ hostname = part;
+ }
+ }
+
+ return {
+ hwaddr,
+ ipaddr,
+ hostname,
+ };
+}
+
+// Save button for each row updates only that line in the textarea
+$(document).on("click", ".save-static-row", function () {
+ const rowIdx = Number.parseInt($(this).data("row"), 10);
+ const row = $(`tr[data-row="${rowIdx}"]`);
+ 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.validateIPv6Brackets(ipaddr);
+ const nameValid = !hostname || utils.validateHostnameStrict(hostname);
+ if (!macValid || !ipValid || !nameValid) {
+ utils.showAlert(
+ "error",
+ "fa-times",
+ "Cannot save: Invalid value found on the table",
+ "Please correct the highlighted fields before saving."
+ );
+ return;
+ }
+
+ const lines = $("#dhcp-hosts").val().split(/\r?\n/u);
+ // 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"));
+
+ // Update "data-original-line" to containing the new saved values
+ row.attr("data-original-line", lines[rowIdx]);
+
+ // Hide the tooltips and remove Save and Cancel buttons
+ $(this).siblings(".cancel-static-row").tooltip("hide").remove();
+ $(this).tooltip("hide").remove();
+ // then remove highlight colors from all cells on this row
+ $("td", row).blur();
+
+ // Check if all rows were already saved (no rows are still being edited)
+ if ($("#StaticDHCPTable .save-static-row").length === 0) {
+ // Re-enable all table buttons
+ $("#StaticDHCPTable button").prop("disabled", false);
+ // and re-enable the textarea
+ $("#dhcp-hosts").prop("disabled", false);
+ }
+});
+
+// Cancel button: restores the original line value when editing a row
+$(document).on("click", ".cancel-static-row", function () {
+ const rowIdx = Number.parseInt($(this).data("row"), 10);
+ const row = $(`tr[data-row="${rowIdx}"]`);
+
+ // Get the original values
+ const originalLine = row.attr("data-original-line");
+
+ if (originalLine) {
+ const values = parseStaticDHCPLine(originalLine);
+
+ // Reset with original values, ensuring index exists
+ row.find(".static-hwaddr").text(values.hwaddr);
+ row.find(".static-ipaddr").text(values.ipaddr);
+ row.find(".static-hostname").text(values.hostname);
+ } else {
+ // Optional: Handle empty state, e.g., clear fields or set defaults
+ row.find(".static-hwaddr, .static-ipaddr, .static-hostname").text("");
+ }
+
+ // Trigger "blur" event to remove highlight colors and titles from all cells on this row
+ row.find(".static-hwaddr, .static-ipaddr, .static-hostname").blur();
+
+ // Then hide the tooltip and remove Save and Cancel buttons
+ $(this).siblings(".save-static-row").tooltip("hide").remove();
+ $(this).tooltip("hide").remove();
+
+ // Check if all rows were already saved or canceled (no rows are still being edited)
+ if ($("#StaticDHCPTable .save-static-row").length === 0) {
+ // Re-enable all table buttons
+ $("#StaticDHCPTable button").prop("disabled", false);
+ // and re-enable the textarea
+ $("#dhcp-hosts").prop("disabled", false);
+ }
+});
+
+// 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/u);
+ lines.splice(rowIdx, 1);
+ $("#dhcp-hosts").val(lines.join("\n"));
+ // Hide the tooltip
+ $(this).tooltip("hide");
+ 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/u);
+ lines.splice(rowIdx + 1, 0, "");
+ $("#dhcp-hosts").val(lines.join("\n"));
+ // Hide the tooltip
+ $(this).tooltip("hide");
+ 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 .delete-static-row, #StaticDHCPTable .add-static-row").prop("disabled", true);
+
+ // Add save button (a hint asking to click on the button will be shown below the table - CSS pseudo-element)
+ if (row.find(".save-static-row").length === 0) {
+ const idx = row.attr("data-row");
+ const saveBtn = $(
+ ''
+ )
+ .attr("data-row", idx)
+ .attr("title", "Confirm changes to this line")
+ .attr("data-toggle", "tooltip");
+ const cancelBtn = $(
+ ''
+ )
+ .attr("data-row", idx)
+ .attr("title", "Cancel changes and restore original values")
+ .attr("data-toggle", "tooltip");
+
+ // Add the save button to the actions column
+ row.find("td").last().prepend(saveBtn, " ", cancelBtn, " ");
+
+ // Disable the textarea to avoid losing unsaved changes to the table
+ $("#dhcp-hosts").prop("disabled", true);
+ }
+});
+
+// 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/u);
+ for (const [idx, line] of lines.entries()) {
+ const parsed = parseStaticDHCPLine(line);
+
+ 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");
+
+ // Create the new row - store the original data, in case we need to restore the values
+ const tr = $("
';
+
+ // Populate the 3 cells. Validate and highlight in case of failure
+ const tdHwaddr = $(cell).addClass("static-hwaddr").text(parsed.hwaddr);
+ if (parsed.hwaddr && !utils.validateMAC(parsed.hwaddr)) {
+ tdHwaddr.addClass("table-danger");
+ }
+
+ const tdIpaddr = $(cell).addClass("static-ipaddr").text(parsed.ipaddr);
+ if (
+ parsed.ipaddr &&
+ !(utils.validateIPv4(parsed.ipaddr) || utils.validateIPv6Brackets(parsed.ipaddr))
+ ) {
+ tdIpaddr.addClass("table-danger");
+ }
+
+ const tdHostname = $(cell).addClass("static-hostname").text(parsed.hostname);
+ if (parsed.hostname && !utils.validateHostnameStrict(parsed.hostname)) {
+ tdHostname.addClass("table-danger");
+ }
+
+ // Append 3 cells containing parsed values
+ tr.append(tdHwaddr, tdIpaddr, tdHostname);
+ }
+
+ // Append a last cell containing the buttons
+ tr.append($('
').append(delBtn, " ", addBtn));
+
+ tbody.append(tr);
+ }
+
+ tbody.find(".delete-static-row, .add-static-row").prop("disabled", false);
+ showLineNumbers();
+}
+
+// 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
+function showLineNumbers() {
+ updateLineNumbers(true);
+ syncScroll();
+}
+
+function updateLineNumbers(force) {
+ const textarea = document.getElementById("dhcp-hosts");
+ const linesElem = document.getElementById("dhcp-hosts-lines");
+ updateLineNumbers.lastLineCount ||= 0;
+
+ if (!textarea || !linesElem) return;
+ const lines = textarea.value.split("\n").length || 1;
+ if (!force && lines === updateLineNumbers.lastLineCount) return;
+ updateLineNumbers.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",
+ ]) {
+ linesElem.style[property] = globalThis.getComputedStyle(textarea)[property];
+ }
+
+ // Update "--num-lines" variable and let CSS handle the height
+ $(".dhcp-hosts-wrapper").css("--num-lines", lines);
+}
+
+function syncScroll() {
+ document.getElementById("dhcp-hosts-lines").scrollTop =
+ document.getElementById("dhcp-hosts").scrollTop;
+}
+
+document.getElementById("dhcp-hosts").addEventListener("scroll", syncScroll);
+
+document.getElementById("dhcp-hosts").addEventListener("input", function () {
+ updateLineNumbers(false);
+});
+
+window.addEventListener("resize", function () {
+ updateLineNumbers(true);
+});
+
+$(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).attr("title", "Invalid MAC address format");
+ } else {
+ $(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.validateIPv6Brackets(val))) {
+ $(this).addClass("table-danger");
+ $(this).attr("title", "Invalid IP address format");
+ } else {
+ $(this).removeClass("table-danger");
+ $(this).attr("title", "");
+ }
+});
+
+$(document).on("input blur paste", "#StaticDHCPTable td.static-hostname", function () {
+ const val = $(this).text().trim();
+ if (val && !utils.validateHostnameStrict(val)) {
+ $(this).addClass("table-danger");
+ $(this).attr("title", "Invalid hostname: only letters, digits, hyphens, and dots allowed");
+ } else {
+ $(this).removeClass("table-danger");
+ $(this).attr("title", "");
+ }
});
diff --git a/scripts/js/utils.js b/scripts/js/utils.js
index 29fd49057a..893c658e03 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,41 @@ 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 validateIPv6Brackets(ip) {
+ const trimmedIp = ip.trim();
+ // Check if the IPv6 is enclosed in brackets and return in case of failure
+ if (!trimmedIp.startsWith("[") || !trimmedIp.endsWith("]")) return false;
+
+ // Strip brackets before validating the IPv6
+ const ipWithoutBrackets = trimmedIp.slice(1, -1);
+ // Validate the ip
+ return validateIPv6(ipWithoutBrackets);
+}
+
function validateMAC(mac) {
- const macvalidator = /^([\da-fA-F]{2}:){5}([\da-fA-F]{2})$/u;
- 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}([:-]))(?:[\da-f]{2}\1){4}[\da-f]{2}$/iu;
+ return macvalidator.test(mac.trim());
}
function validateHostname(name) {
const namevalidator = /[^<>;"]/u;
- return namevalidator.test(name);
+ return namevalidator.test(name.trim());
+}
+
+function validateHostnameStrict(name) {
+ // 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])?)*$/u;
+ return hostnameValidator.test(name.trim());
}
// set bootstrap-select defaults
@@ -709,12 +743,16 @@ globalThis.utils = (function () {
disableAll,
enableAll,
validateIPv4CIDR,
+ validateIPv4,
validateIPv6CIDR,
+ validateIPv6,
+ validateIPv6Brackets,
setBsSelectDefaults,
stateSaveCallback,
stateLoadCallback,
validateMAC,
validateHostname,
+ validateHostnameStrict,
addFromQueryLog,
addTD,
toPercent,
diff --git a/settings-dhcp.lp b/settings-dhcp.lp
index 6ee2f8dfa1..327190c75d 100644
--- a/settings-dhcp.lp
+++ b/settings-dhcp.lp
@@ -172,32 +172,65 @@ mg.include('scripts/lua/settings_header.lp','r')
-
-
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.
-
+
+
+
+
+
+
MAC address
+
IP address
+
Hostname
+
Actions
+
+
+
+
+
+
+
+
+
+
+
+
+
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:
tells Pi-hole to give the machine with hardware address 00:20:e0:3b:13:af the address 192.168.0.123
-
00:20:e0:3b:13:af,laptop
tells Pi-hole to give the machine with hardware address 00:20:e0:3b:13:af the name laptop
-
00:20:e0:3b:13:af,192.168.0.123,laptop,infinite
tells Pi-hole to give the machine with hardware address 00:20:e0:3b:13:af the address 192.168.0.123, the name laptop, and an infinite DHCP lease
+
Only one entry per MAC address is allowed.
+
IPv6 addresses must be enclosed in square brackets like [2001:db8::1].
+
Only letters, digits, hyphens and dots are allowed in hostnames.
+
+
+
+
+
+
+
+
+
00:20:e0:3b:13:af,192.168.0.123
tells Pi-hole to give the machine with hardware address 00:20:e0:3b:13:af the address 192.168.0.123
+
00:20:e0:3b:13:af,laptop
tells Pi-hole to give the machine with hardware address 00:20:e0:3b:13:af the name laptop
+
00:20:e0:3b:13:af,192.168.0.123,laptop,infinite
tells Pi-hole to give the machine with hardware address 00:20:e0:3b:13:af the address 192.168.0.123, the name laptop, and an infinite DHCP lease
+
00:20:e0:3b:13:af,[2001:db8::1]
tells Pi-hole to give the machine with hardware address 00:20:e0:3b:13:af the address [2001:db8::1]
-
+
-
Advanced description
+
Advanced settings help
-
+
Addresses allocated like this are not constrained to be in the DHCP range specified above but they must be in the same subnet. For subnets which don't need a pool of dynamically allocated addresses, you can set a one-address range above and specify only static leases here.
It is allowed to use client identifiers (called client DUID in IPv6-land) rather than hardware addresses to identify hosts by prefixing with id:. Thus lines like id:01:02:03:04,..... refer to the host with client identifier 01:02:03:04. It is also allowed to specify the client ID as text, like this: id:clientidastext,.....